Teaches a coding agent to write, change and review a Loft specification in spec/ with the loft command line, and shows the Loft status pane while it works.

The Loft Specification Format and its reference implementation: a specification document model that a person or a small team edits in a git repository and controls with the loft command line, with no server.
crates/loftspec — the format: document model, identity, canonical bytes, hashing, conversion from the v1 exportcrates/loftspec-store — memory and file storescrates/loft-cli — the loft binarycrates/software-plant — the Software Plant: simulators of the external systems Loft depends on, for tests onlyLicensed under Apache-2.0 (LICENSE, NOTICE). Contributions: CONTRIBUTING.md. Security reports: SECURITY.md. Changes: CHANGELOG.md.
Released: the latest version and its changes are in CHANGELOG.md; 2.0.0 was the first public release. The format and the command line are specified in this repository's own spec/ tree, in the format itself: loft check validates and lints it, and loft render writes a readable projection to docs/spec.md.
You write a specification by talking to a coding agent, not by editing YAML. The agent writes spec/ with the loft-spec skill and the loft command line; you decide what goes in it.
docs/spec.md from loft render. Then ask it to commit, and to publish. What the agent writes in your name is yours to understand before you accept it.loft fmt and loft check after every step until both pass.loft check catches what it misses; keys and references stay loft's to keep, and each criterion stays one checkable outcome. A hand edit is still held to the same checks, which suits a typo, not a feature.spec/ as its own commit, then into the code, which is checked against the criteria it now has to meet. A gap found while coding goes back into the specification, not around it.npm install --save-dev @ask-loft/loft # a prebuilt binary, in a project that uses npm
cargo install --locked loft-cli # from crates.io, with the tested dependencies
cargo install --path crates/loft-cli # from a clone of this repository
The npm package carries prebuilt binaries for macOS, Linux and Windows on x64 and arm64, and runs as npx loft; in such a project, register the merge driver with npx loft merge-driver --install --command "npx --no-install loft". Building from source needs Rust 1.97 or newer. The binary is loft; loft --version prints its release, the format and the hashing algorithm.
The npm package records that npm installed it when it installs. After cargo install, or after unpacking a binary from a GitHub release, loft record-install cargo or loft record-install release-binary records the same, so that the update notice names the right way to update; without it, loft infers the way from where it lies.
cd my-product # the product's git repository, or `git init` a new one
loft init # spec/, and the loft-spec skill for coding agents
loft merge-driver --install # once per clone: git merges spec/ by key
git add .gitattributes .claude .agents && git commit -m "Add the Loft specification tools"
Then ask your agent, for example: "Use the loft-spec skill to specify a booking system for small venues" (/loft-spec in Claude Code, $loft-spec in Codex). It interviews you, agrees an outline with you, writes spec/, runs loft fmt and loft check until both pass, and commits the draft. When the specification reads right, ask it to publish, or run:
loft publish # on main: labels for new nodes and loft.lock.json
git add -A && git commit -m "spec: publish"
loft verify # in CI: the tree matches the lock
loft init runs before or after git init; its last line says what the merge driver still needs.
| Command | What it does |
|---|---|
loft init [--no-skill] | Creates an empty document in the current directory, and the agent skill beside it |
loft fmt [--check] | Rewrites spec/ in canonical layout, mints missing keys, turns references written by name into keys and drops the label bindings of deleted nodes, or reports drift |
loft check [--fail-on <severity>] | Reads, validates and lints spec/ |
loft render [--openspec <dir> [--force]] | Writes the Markdown projection and the canonical JSON export, or the specification as OpenSpec main specs |
loft find <query> | Prints the key, label, kind, file and name of each node or decision a label, slug, key or name identifies |
loft import v1 <export> | Converts a Loft v1 export into spec/ |
loft import openspec [<dir>] | Converts the main specs of an OpenSpec project into spec/ |
loft publish | Allocates labels and writes the lock, on the publish branch |
loft verify | Recomputes the roots and compares them with the lock |
loft status [--base <revision>] | Reports the lock and whether the tree matches it, the repository's branch and uncommitted paths under spec/, and the nodes added, changed and removed since HEAD left the revision; it sends no telemetry |
loft terms | Lists the names the specification may be using for one concept, and the subjects of each Product's statements, for the term list to decide |
loft reconcile [--fail-on <severity>] [--accept <node>] [--accept-stale] | Checks loft.trace.yaml, which links each criterion to the tests that verify it, against the tree and the test files, and records links a reviewer has checked |
loft merge-driver | Merges one file of spec/ as git's merge driver; --install registers it |
loft skill install | Writes the agent skill into the current directory, or brings it up to date |
The loft-spec skill is how a coding agent writes, changes and reviews a specification with loft, as "Working with a specification" describes: the interview, the edit loop, the format's files and fields, the writing rules, what each lint finding means, review, and publishing. The agent reads one short router, then only the references a task needs.
loft init writes the skill for Claude Code to .claude/skills/loft-spec/ and for Codex to .agents/skills/loft-spec/, and enables the Claude Code plugin loft@ask-loft in .claude/settings.json, keeping every other entry of that file; --no-skill leaves all three out. loft skill install writes it into any project, and after an upgrade of loft brings it to the new version, enabling the plugin the same way. Commit both directories and .claude/settings.json so that everyone working on the project has the skill and is offered the plugin.
The skill is also a plugin, for an agent that works across projects:
/plugin marketplace add Ask-Loft/loft, then /plugin install loft@ask-loft. In a project where loft init or loft skill install ran, the committed .claude/settings.json already names the marketplace and enables loft@ask-loft, so Claude Code offers the plugin to whoever trusts the project.codex plugin marketplace add Ask-Loft/loft, then codex plugin add loft@ask-loft, or choose it in /plugins.The plugin teaches the agent; it does not install loft, which the skill expects on the path.
In Claude Code the plugin also shows the Loft status pane in a project that holds spec/: the checks of the loop (format, check, spec/ committed, trace, lock) and the next step, the nodes the branch added, changed and removed since its base, the criteria it touched that still lack a reviewed test, and the file the agent is writing. /loft-status opens it; /loft-status <revision> compares with another base for the session, and /loft-status auto returns to the baseBranch setting (develop by default). The pane runs loft status, loft fmt --check, loft check and loft reconcile with telemetry and the update check off; where loft is not on the path, set the plugin's loftCommand setting, for example to pnpm exec loft.
Every command exits 0 on success, 1 on an error, a refusal or a failed check, and 2 on a usage error. With --format json a command prints one JSON document on standard output, described by its schema under schemas/ (schemas/<command>.json, whose name carries its version); human-readable text goes to standard error, and the exit code is the same as without it.
Every document carries schema, outcome and errors. outcome names the result in words, including the results that exit 1 without an error: failed for check, drift for fmt --check, conflicted for a merge and mismatch for verify. A command that stops on an error has the outcome error, and its document then holds only those three fields:
{"schema": "loft-fmt/1", "outcome": "error", "errors": [{"kind": "read", "message": "...", "path": "spec/...", "line": 1, "column": 3, "sites": [...]}]}
loft publish, run on the publish branch (main unless spec/loftspec.yaml names another) over a committed tree, allocates labels to new nodes in spec/labels.yaml and writes loft.lock.json, which pins the specification's state, content and binding roots. loft verify recomputes those roots and compares them with the lock; CI runs it on main. A release merges develop into main, publishes there, commits the labels and the lock together, and merges main back into develop.
loft merge-driver --install, run once in a clone, registers Loft as git's merge driver for spec/: it adds spec/**/*.yaml merge=loft to .gitattributes and sets merge.loft.driver in the clone's configuration. git then merges each file of the tree by key. Where two branches change one field apart, the file gains a conflict record in place of conflict markers, git reports the path as conflicted, and loft check reports the record until it is removed.
git runs the driver through the shell, from the top of the working tree, with its own search path, which a dev dependency is not on. Give the command that starts loft there with --command: loft merge-driver --install --command "npx --no-install loft", "pnpm exec loft", "uv run loft", or a path such as node_modules/.bin/loft. The install runs <command> --version as git would and refuses when it fails. The setting lives in the clone's .git/config, so a project runs the install in its setup step, such as npm's prepare script. If the command cannot run during a merge, the driver merges the file as text with conflict markers and leaves it conflicted; make the command run, then git checkout -m <file> merges it again.
Two clones that publish apart can give one label to two nodes. When one pulls the other, by git pull (a merge) or git pull --rebase, the merge driver keeps the disputed bindings out of spec/labels.yaml in one conflict record and git stops. loft publish, run there on the publish branch, settles them: the pulled side keeps its labels, the other side's colliding labels are withdrawn and their nodes get fresh ones, citations of a withdrawn label are rewritten in every element that mentions its node, and the lock is written anew. It then prints the git command that finishes, git add -A && git commit --no-edit after a merge or git add -A && git rebase --continue after a rebase; Loft itself never changes the repository.
loft.trace.yaml, beside spec/, links each acceptance criterion, and each Constraint or Quality Attribute whose verificationFate is proved, to the tests that verify it, by file and test name, or says why no test can. Each link holds the content hash its node had when a reviewer last accepted it, so a changed criterion shows up as a stale link, and each test the digest of its tokens when it was last recorded, so a rewritten test shows up too. loft reconcile locates each test in its file's syntax tree for Rust, TypeScript, JavaScript, Python, Go and Java, and as a whole word elsewhere, without running it, so it works for any language. It reports nodes no link names, links not yet reviewed or gone stale, tests changed since they were recorded, recorded tests it can no longer locate, tests that a part of their file it cannot parse may cut short, tests it cannot find in their files, and links to nodes that are gone. loft reconcile --accept <label> records a review, and CI runs loft reconcile --fail-on warning. The trace file is not part of the specification: it enters no root and no publish writes it.
Telemetry is on by default. At the end of each run of check, find, fmt, import v1, import openspec, init, publish, reconcile, render, skill install, terms and verify, loft sends one command event to https://askloft.ai/api/telemetry, so that the maintainers learn which commands are used, how they end and how long they take. Before the first event leaves a machine, loft prints a notice on standard error saying what it sends and how to switch it off. An event looks like this:
{
"schema": "loft-telemetry-event/2",
"eventId": "0b9a3c0e-7f4d-4a43-9a52-3f3f2c4b8d11",
"version": "2.2.1",
"os": "macos",
"arch": "aarch64",
"installedBy": "npm",
"time": "2026-10-14T15:00:00Z",
"installation": "6f1d2c3b-9a8e-4f7d-8c6b-5a4e3d2c1b0a",
"project": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"branch": "9f86d081884c7d65",
"mode": "agent",
"agent": "claude-code",
"command": "check",
"outcome": "failed",
"errorKinds": [],
"duration": "under-1s"
}
schemas/telemetry-event.json lists every field and every value it may take. The time is the hour, in UTC; mode says whether the run was internal, in CI, by a coding agent, at a terminal or none of these; agent names the agent from the variables its host sets. The three tokens are random: installation is drawn once for the user on the machine, project once for a checkout, in a .loft/ beside spec/ that git ignores, and branch is the branch name keyed with a secret kept in that same file, so the same branch in two checkouts gives two different tokens. An event never carries the specification, the code, a path, a repository address, a name or an error message. The tokens are pseudonymous rather than anonymous, and the collector sees the network address each event comes from. Events with tokens are kept for at most 30 days, and aggregates without them for at most 90 days.
Sending never gets in the way. It starts after the command's output, gives up after 500 milliseconds and prints nothing; a collector that is down, slow or refusing changes no output and no exit code, and a lost event is never sent again.
loft also checks for updates. At the end of the same commands, at most once a day, it asks GitHub for the latest release and keeps the answer; a later run tells you on standard error, at most once a day, when a newer release exists and how to install it. The request carries nothing about you or your project: its User-Agent is loft, without even the version, and it follows no redirect. This check has its own switch and stays on when telemetry is off.
| To | Run or set |
|---|---|
| switch telemetry off for the user | loft telemetry off (on and status too) |
| switch telemetry off for one shell | LOFT_TELEMETRY=off (any value but internal) |
| switch the update check off | loft update-check off, or LOFT_UPDATE_CHECK=off |
| send elsewhere | LOFT_TELEMETRY_ENDPOINT=<url> |
| ask elsewhere for releases | LOFT_UPDATE_ENDPOINT=<GitHub API base> |
loft telemetry off deletes the installation token; a variable set to off wins over a recorded on, and a recorded off wins over any variable. The settings live in loft/ under the user's configuration directory (~/.config on Linux, ~/Library/Application Support on macOS, %APPDATA% on Windows); when they cannot be written, loft does not check for updates at all, rather than ask and tell again on every run. Neither address accepts plain http except on the loopback interface. Both requests go through the proxy named by the first of ALL_PROXY, HTTPS_PROXY and HTTP_PROXY that is set, unless NO_PROXY lists the host. LOFT_TELEMETRY=internal marks runs by Loft's own maintainers and CI.
Loft's commands depend on systems outside Loft: git, the clock, the telemetry collector and GitHub's releases. Tests run the real loft binary, unchanged, against simulators from the Software Plant instead of those systems:
git executable that answers the git calls Loft makes from a scenario the test writes (a clean repository on main, uncommitted changes, another branch, a detached HEAD, a local configuration, a merge or rebase stopped on a conflict, a directory outside any repository, a git error) and refuses any other call;SOURCE_DATE_EPOCH, so a test names the publish time exactly;use std::process::Command;
use software_plant::{Clock, GitScenario};
let git = GitScenario::clean_on("main").with(" M", "spec/tags.yaml").install(&scratch);
let mut loft = Command::new(env!("CARGO_BIN_EXE_loft"));
git.apply(&mut loft);
Clock::fixed(1_700_000_000).apply(&mut loft);
A contract suite keeps each simulator honest by running the same scenarios against the simulator and the real system; cargo nextest run -p loftspec-store git_contract runs the git suite against the git on your search path. git also calls Loft, as the merge driver; cargo nextest run -p loft-cli merge_contract runs the parallel-work scenarios under a real git merge, with no simulator in between. The collector and release-feed suites, collector_contract and release_feed_contract in loft-cli, run their real halves only on demand (--run-ignored only): against the collector named by LOFT_COLLECTOR_CONTRACT_URL, and against GitHub. Every process Cargo starts in this repository, the tests included, begins with telemetry and the update check off (.cargo/config.toml); a test that exercises either switches it on and points it at a simulator. The thinking behind the Software Plant is at <https://askloft.ai/vision>.
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run --workspace
cargo run -q -p loft-cli -- fmt --check
cargo run -q -p loft-cli -- check --fail-on warning
cargo run -q -p loft-cli -- verify # on main, which carries only published states
Rust 1.97 or newer; cargo install cargo-nextest --locked provides the test runner.
The skill's source lies in crates/loft-cli/skill/loft-spec/. After changing it, run scripts/package-skill.sh to rewrite the plugin packages under plugins/; a test fails while they differ.
hooks/register.tsx 309 lines1// The Loft status pane: a pane, a status-line entry and `/loft-status`, kept up to date while the
2// agent works on a specification. Every value comes from `loft … --format json`; the hooks only
3// ask for a read and note which file the agent wrote (the Feature "The Loft status pane").
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { LoftError, LoftRead, LoftSnapshot } from '../types'
9import {
10 ancestors,
11 brokenLinks,
12 checks,
13 commandWords,
14 criteriaToVerify,
15 documentErrors,
16 isLoftSpecSkill,
17 labelOf,
18 nextStep,
19 nodesIn,
20 parseDocument,
21 readOf,
22 relativeTo,
23 runsLoftOrGit,
24 statusLine,
25 stopped,
26 touchesSpecification,
27 window,
28} from './model'
29import { kit } from './ui'
30
31const PANE = 'loft-status'
32const TITLE = 'Loft'
33const TICK_MS = 400
34const POLL_MS = 10000
35/** A read is the pane's, not the person's: it sends no command event and asks for no update. */
36const QUIET = { LOFT_TELEMETRY: 'off', LOFT_UPDATE_CHECK: 'off' }
37
38const snapshot = atom({ plugin: 'loft', key: 'snapshot' } as const, null)
39const baseOverride = atom({ plugin: 'loft', key: 'base' } as const, null)
40const workingOn = atom({ plugin: 'loft', key: 'workingOn' } as const, null)
41
42type Ran = { document?: Record<string, unknown>; exitCode: number; unrun?: string }
43
44/** The words that run loft, and the base the pane follows by default: the plugin's settings. */
45let words = ['loft']
46let setting = 'develop'
47/** The directory that holds `spec/`: undefined until looked for, null where there is none. */
48let root: string | null | undefined
49let isDirty = true
50let isReading = false
51
52function requestRead() {
53 isDirty = true
54}
55
56async function findRoot($: EngineInterface, cwd: string): Promise<string | null> {
57 for (const dir of ancestors(cwd)) {
58 const manifest = `${dir === '/' ? '' : dir}/spec/loftspec.yaml`
59 if (await $.fs.exists(manifest)) return dir
60 }
61 return null
62}
63
64async function run($: EngineInterface, cwd: string, args: string[]): Promise<Ran> {
65 try {
66 const ran = await $.process.run([...words, ...args, '--format', 'json'], { cwd, env: QUIET, timeoutMs: 20000 })
67 return { document: parseDocument(ran.stdout), exitCode: ran.exitCode }
68 } catch (error) {
69 return { exitCode: -1, unrun: error instanceof Error ? error.message : String(error) }
70 }
71}
72
73async function readOnce($: EngineInterface) {
74 if (!root) {
75 await update($, snapshot, () => ({ root: null, read: null, notice: null, errors: [] }))
76 $.ui.status(undefined)
77 return
78 }
79 const dir = root
80 const base = (await read($, baseOverride)) ?? setting
81 const [status, fmt, check, reconcile] = await Promise.all([
82 run($, dir, ['status', '--base', base]),
83 run($, dir, ['fmt', '--check']),
84 run($, dir, ['check']),
85 run($, dir, ['reconcile']),
86 ])
87 const previous = (await read($, snapshot))?.read ?? null
88 const command = `\`${words.join(' ')}\``
89 const notice =
90 status.unrun !== undefined
91 ? `${command} cannot be run (${status.unrun}); set the loft plugin's loftCommand setting.`
92 : status.exitCode === 2 && !status.document
93 ? `${command} has no \`status\` command: it is too old for the Loft status pane; update loft or set loftCommand.`
94 : null
95 if (notice) {
96 await update($, snapshot, () => ({ root: dir, read: previous, notice, errors: [] }))
97 $.ui.status(undefined)
98 return
99 }
100 const errors: LoftError[] = []
101 const documents = [
102 ['status', status],
103 ['fmt --check', fmt],
104 ['check', check],
105 ['reconcile', reconcile],
106 ] as const
107 for (const [name, ran] of documents) {
108 if (!ran.document) {
109 errors.push({ command: name, message: ran.unrun ?? `printed no JSON document (exit ${ran.exitCode})` })
110 } else if (stopped(ran.document)) {
111 errors.push(...documentErrors(name, ran.document))
112 }
113 }
114 const next: LoftRead | null =
115 errors.length === 0 && status.document && fmt.document && check.document && reconcile.document
116 ? readOf(status.document, fmt.document, check.document, reconcile.document)
117 : previous
118 await update($, snapshot, () => ({ root: dir, read: next, notice: null, errors }))
119 $.ui.status(next ? statusLine(next) : undefined)
120}
121
122/** Runs the reads asked for since the last; never two at once. */
123async function tick($: EngineInterface) {
124 if (!isDirty || isReading) return
125 isReading = true
126 try {
127 while (isDirty) {
128 isDirty = false
129 await readOnce($)
130 }
131 } finally {
132 isReading = false
133 }
134}
135
136export const register: Register = (on, options) => {
137 words = commandWords(typeof options.loftCommand === 'string' ? options.loftCommand : 'loft')
138 setting =
139 typeof options.baseBranch === 'string' && options.baseBranch.trim() !== '' ? options.baseBranch.trim() : 'develop'
140 root = undefined
141 isDirty = true
142 isReading = false
143
144 on('session.start', async ($, e, next) => {
145 await $.command.register({
146 name: 'loft-status',
147 description: 'Show the Loft status pane. Args: <revision> to compare with it, auto for the baseBranch setting',
148 })
149 root = await findRoot($, e.cwd)
150 if (root) {
151 void $.ui.open({ id: PANE, title: TITLE })
152 $.clock.every(TICK_MS, () => void tick($))
153 $.clock.every(POLL_MS, requestRead)
154 }
155 requestRead()
156 await tick($)
157 return next(e)
158 })
159
160 on('command.run', { command: 'loft-status' }, async ($, e) => {
161 const arg = e.args.trim()
162 if (arg === 'auto') await update($, baseOverride, () => null)
163 else if (arg !== '') await update($, baseOverride, () => arg)
164 await $.ui.open({ id: PANE, title: TITLE })
165 requestRead()
166 await tick($)
167 if (!root) return { text: 'The Loft status pane found no Loft specification at or above this directory.' }
168 const base = (await read($, baseOverride)) ?? setting
169 return { text: `The Loft status pane compares the tree with \`${base}\`.` }
170 })
171
172 // The person running the `loft-spec` skill as a slash command.
173 on('command.run', async ($, e, next) => {
174 if (isLoftSpecSkill(e.command)) requestRead()
175 return next(e)
176 })
177
178 on('tool.call', async ($, e, next) => {
179 if (!root) return next(e)
180 if (e.tool === 'Skill' && isLoftSpecSkill(e.skill)) {
181 requestRead()
182 return next(e)
183 }
184 if ((e.tool === 'Write' || e.tool === 'Edit') && touchesSpecification(root, e.file_path)) {
185 const ran = await next(e)
186 const relative = relativeTo(root, e.file_path)
187 if (relative?.startsWith('spec/')) await update($, workingOn, () => relative)
188 requestRead()
189 return ran
190 }
191 if (e.tool === 'Bash' && runsLoftOrGit(e.command)) {
192 const ran = await next(e)
193 requestRead()
194 return ran
195 }
196 return next(e)
197 })
198
199 on('turn.complete', async ($, e, next) => {
200 requestRead()
201 return next(e)
202 })
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 const elements = $.ui.resolve(e)
206 const width = Math.max(20, e.props.bodyColumns)
207 const ui = kit(elements, width)
208 const shown = await read($, snapshot)
209 const file = await read($, workingOn)
210 const base = (await read($, baseOverride)) ?? setting
211 return (
212 <elements.Box flexDirection="column">{drawPane(ui, shown, file, base, e.props.scroll.bodyRows)}</elements.Box>
213 )
214 })
215}
216
217type Kit = ReturnType<typeof kit>
218
219const CHANGE_GLYPH = { added: '+', changed: '~', removed: '−' } as const
220
221/** The pane's body for what the last read showed. */
222function drawPane(ui: Kit, shown: LoftSnapshot | null, file: string | null, base: string, rows: number) {
223 const { Box, tone, rule } = ui
224 if (shown === null) return tone('meta', 'Reading the specification…')
225 if (shown.root === null) {
226 return (
227 <Box flexDirection="column">
228 {tone('title', TITLE)}
229 {tone('meta', 'No Loft specification here: no spec/loftspec.yaml at or above this directory.', 'wrap')}
230 </Box>
231 )
232 }
233 const errors = shown.errors.map(error => {
234 const at = error.path ? ` (${error.path}${error.line ? `:${error.line}` : ''})` : ''
235 return tone('error', `✗ ${error.command}: ${error.message}${at}`, 'wrap')
236 })
237 if (shown.notice !== null || shown.read === null) {
238 return (
239 <Box flexDirection="column">
240 {tone('title', TITLE)}
241 {shown.notice !== null && tone('todo', shown.notice, 'wrap')}
242 {errors}
243 </Box>
244 )
245 }
246 const loft = shown.read
247 const all = checks(loft)
248 const toVerify = criteriaToVerify(loft)
249 const reviewed = toVerify.filter(each => !each.open).length
250 const delta = loft.status.base
251 const changes = delta
252 ? [
253 ...delta.added.map(node => ({ change: 'added' as const, node })),
254 ...delta.changed.map(node => ({ change: 'changed' as const, node })),
255 ...delta.removed.map(node => ({ change: 'removed' as const, node })),
256 ]
257 : []
258 const inFile = file ? nodesIn(loft, file) : []
259 const fixed = 16 + all.length + errors.length + inFile.length
260 const room = Math.max(3, Math.floor((Math.max(rows, 24) - fixed) / 2))
261 const listed = window(changes, room)
262 const verifying = window(toVerify, room)
263 const current = toVerify.findIndex(each => each.open)
264 const broken = brokenLinks(loft).length
265
266 return (
267 <Box flexDirection="column">
268 {tone('title', `${TITLE} · ${loft.status.branch ?? 'detached HEAD'}`)}
269 {tone('meta', delta ? `Base ${base} at ${delta.commit.slice(0, 7)}` : `Base ${base}`)}
270 {rule()}
271 {all.map(check =>
272 tone(
273 check.passes ? 'done' : 'todo',
274 `${check.passes ? '✓' : '○'} ${check.label.padEnd(15)}${check.passes ? 'done' : 'to do'} · ${check.detail}`,
275 ),
276 )}
277 {rule()}
278 {tone('body', `Next: ${nextStep(all)}`, 'wrap')}
279 {rule()}
280 {tone('heading', delta ? `This branch +${delta.added.length} ~${delta.changed.length} −${delta.removed.length}` : 'This branch')}
281 {changes.length === 0 && tone('meta', ' no node added, changed or removed')}
282 {listed.shown.map(({ change, node }) =>
283 tone(change === 'removed' ? 'muted' : 'body', ` ${CHANGE_GLYPH[change]} ${change.padEnd(8)}${labelOf(node).padEnd(9)}${node.name}`),
284 )}
285 {listed.more > 0 && tone('meta', ` … ${listed.more} more`)}
286 {rule()}
287 {tone('heading', `Criteria to verify ${reviewed}/${toVerify.length} reviewed`)}
288 {toVerify.length === 0 && tone('meta', ' no criterion added or changed')}
289 {verifying.shown.map((each, index) => {
290 const isCurrent = index === current
291 const mark = !each.open ? '✓ reviewed' : isCurrent ? '▶ current ' : '○ open '
292 const why = each.open ? ` — ${each.open.message}` : ''
293 return tone(
294 !each.open ? 'done' : isCurrent ? 'current' : 'body',
295 ` ${mark} ${labelOf(each.node).padEnd(9)}${each.node.name}${why}`,
296 )
297 })}
298 {verifying.more > 0 && tone('meta', ` … ${verifying.more} more`)}
299 {broken > 0 && tone('error', ` ✗ ${broken} trace link${broken === 1 ? '' : 's'} no longer true`)}
300 {rule()}
301 {tone('heading', 'Working on')}
302 {file === null ? tone('meta', ' no file under spec/ written yet') : tone('current', ` ▸ ${file}`)}
303 {inFile.map(({ change, node }) => tone('body', ` ${CHANGE_GLYPH[change]} ${labelOf(node).padEnd(9)}${node.name}`))}
304 {errors.length > 0 && rule()}
305 {errors}
306 </Box>
307 )
308}
309hooks/model.ts 274 lines1// The Loft status pane's model: pure functions from the JSON documents `loft` prints to what the
2// pane draws. Nothing here runs a command or reads a file (the accepted decision "The status
3// pane reads Loft only through the command line's JSON").
4
5import type { LoftError, LoftFinding, LoftNode, LoftRead, LoftStatus } from '../types'
6
7type Json = Record<string, unknown>
8
9const isObject = (value: unknown): value is Json =>
10 typeof value === 'object' && value !== null && !Array.isArray(value)
11
12const records = (value: unknown): Json[] => (Array.isArray(value) ? value.filter(isObject) : [])
13
14const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
15
16const whole = (value: unknown): number | undefined =>
17 typeof value === 'number' && Number.isInteger(value) ? value : undefined
18
19/** One document as `loft --format json` prints it, or undefined for anything else. */
20export function parseDocument(stdout: string): Json | undefined {
21 try {
22 const value: unknown = JSON.parse(stdout)
23 return isObject(value) && typeof value.outcome === 'string' ? value : undefined
24 } catch {
25 return undefined
26 }
27}
28
29/** The errors a document carries, each with the command that printed it. */
30export function documentErrors(command: string, document: Json): LoftError[] {
31 return records(document.errors).map(error => ({
32 command,
33 message: text(error.message) ?? 'an error without a message',
34 path: text(error.path),
35 line: whole(error.line),
36 }))
37}
38
39/** Whether a command stopped: its document's outcome is `error`. */
40export const stopped = (document: Json): boolean => document.outcome === 'error'
41
42function node(value: Json): LoftNode {
43 return {
44 key: text(value.key) ?? '',
45 label: text(value.label) ?? null,
46 kind: text(value.kind) ?? '',
47 name: text(value.name) ?? '',
48 path: text(value.path) ?? '',
49 line: whole(value.line),
50 column: whole(value.column),
51 }
52}
53
54/** `loft status --base <base> --format json` */
55export function parseStatus(document: Json): LoftStatus {
56 const lock = isObject(document.lock) ? document.lock : undefined
57 const repository = isObject(document.repository) ? document.repository : {}
58 const base = isObject(document.base) ? document.base : undefined
59 return {
60 lock: lock
61 ? {
62 publishSequence: whole(lock.publishSequence) ?? 0,
63 matches: lock.matches === true,
64 differs: text(lock.differs) ?? null,
65 }
66 : null,
67 branch: text(repository.branch) ?? null,
68 modified: Array.isArray(repository.modified) ? repository.modified.filter(p => typeof p === 'string') : [],
69 base: base
70 ? {
71 revision: text(base.revision) ?? '',
72 commit: text(base.commit) ?? '',
73 added: records(base.added).map(node),
74 changed: records(base.changed).map(node),
75 removed: records(base.removed).map(node),
76 }
77 : null,
78 }
79}
80
81/** The findings of `loft check` or `loft reconcile`, each with the key of its node. */
82export function parseFindings(document: Json): LoftFinding[] {
83 return records(document.findings).map(finding => {
84 const subject = records(finding.subjects)[0]
85 return {
86 rule: text(finding.rule) ?? '',
87 severity: text(finding.severity) ?? '',
88 message: text(finding.message) ?? '',
89 key: text(finding.key) ?? text(subject?.key),
90 label: text(finding.label) ?? null,
91 path: text(finding.path),
92 line: whole(finding.line),
93 }
94 })
95}
96
97/** The four documents of one read, made into what the pane keeps. */
98export function readOf(status: Json, fmt: Json, check: Json, reconcile: Json): LoftRead {
99 return {
100 status: parseStatus(status),
101 canonical: fmt.outcome === 'canonical',
102 drift: Array.isArray(fmt.changed) ? fmt.changed.filter(p => typeof p === 'string') : [],
103 checkErrors: records(check.errors).length,
104 checkFindings: parseFindings(check),
105 traceFindings: parseFindings(reconcile),
106 }
107}
108
109/** The words of the `loftCommand` setting: its first is the program, the rest its first arguments. */
110export function commandWords(setting: string): string[] {
111 const words = setting.trim().split(/\s+/).filter(word => word !== '')
112 return words.length > 0 ? words : ['loft']
113}
114
115/** The directories from `dir` up to the file system's root, nearest first. */
116export function ancestors(dir: string): string[] {
117 const parts = dir.split('/').filter(part => part !== '')
118 const dirs: string[] = []
119 for (let n = parts.length; n >= 0; n--) dirs.push('/' + parts.slice(0, n).join('/'))
120 return dirs
121}
122
123/** `path` from `root`, when it lies under it. */
124export function relativeTo(root: string, path: string): string | undefined {
125 const prefix = root.endsWith('/') ? root : `${root}/`
126 return path.startsWith(prefix) ? path.slice(prefix.length) : undefined
127}
128
129/** Whether a write of `path` can change what the pane reads: a file under `spec/`, or the trace. */
130export function touchesSpecification(root: string, path: string): boolean {
131 const relative = relativeTo(root, path)
132 return relative !== undefined && (relative.startsWith('spec/') || relative === 'loft.trace.yaml')
133}
134
135/** Whether a shell command runs `loft` or `git`, each of which can change what the pane reads. */
136export function runsLoftOrGit(command: string): boolean {
137 return /(^|[\s;&|(/])(loft|git)(\s|$)/.test(command)
138}
139
140/** Whether a skill or a slash command is the `loft-spec` skill, by any plugin prefix. */
141export function isLoftSpecSkill(name: string | undefined): boolean {
142 return name !== undefined && /(^|:)loft-spec$/.test(name)
143}
144
145const TRACE_WORK = new Set(['TR-001', 'TR-002', 'TR-003', 'TR-006', 'TR-009'])
146const TRACE_BROKEN = new Set(['TR-004', 'TR-005', 'TR-010'])
147
148/** A criterion the branch added or changed, with the finding that leaves it unreviewed. */
149export type ToVerify = { node: LoftNode; open: LoftFinding | undefined }
150
151/** Each criterion the branch added or changed, in the order of its file and line. */
152export function criteriaToVerify(read: LoftRead): ToVerify[] {
153 const base = read.status.base
154 if (!base) return []
155 const seen = new Set<string>()
156 const touched = [...base.added, ...base.changed]
157 .filter(node => node.kind === 'criterion' && !seen.has(node.key) && seen.add(node.key))
158 .sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0))
159 return touched.map(node => ({
160 node,
161 open: read.traceFindings.find(finding => finding.key === node.key && TRACE_WORK.has(finding.rule)),
162 }))
163}
164
165/** The trace findings that say a link is no longer true, wherever they stand. */
166export const brokenLinks = (read: LoftRead): LoftFinding[] =>
167 read.traceFindings.filter(finding => TRACE_BROKEN.has(finding.rule))
168
169/** The warnings of `loft check`: its findings at warning. */
170export const checkWarnings = (read: LoftRead): number =>
171 read.checkFindings.filter(finding => finding.severity === 'warning').length
172
173export type CheckId = 'format' | 'check' | 'committed' | 'trace' | 'lock'
174
175export type Check = { id: CheckId; label: string; passes: boolean; detail: string }
176
177const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
178
179/** The checks of the loop, in their order. */
180export function checks(read: LoftRead): Check[] {
181 const warnings = checkWarnings(read)
182 const toVerify = criteriaToVerify(read)
183 const reviewed = toVerify.filter(each => !each.open).length
184 const broken = brokenLinks(read).length
185 const lock = read.status.lock
186 return [
187 {
188 id: 'format',
189 label: 'Format',
190 passes: read.canonical,
191 detail: read.canonical ? 'canonical' : `${plural(read.drift.length, 'file', 'files')} to format`,
192 },
193 {
194 id: 'check',
195 label: 'Check',
196 passes: read.checkErrors === 0 && warnings === 0,
197 detail: `${plural(read.checkErrors, 'error', 'errors')} · ${plural(warnings, 'warning', 'warnings')}`,
198 },
199 {
200 id: 'committed',
201 label: 'Spec committed',
202 passes: read.status.modified.length === 0,
203 detail:
204 read.status.modified.length === 0
205 ? 'clean'
206 : `${plural(read.status.modified.length, 'path', 'paths')} uncommitted`,
207 },
208 {
209 id: 'trace',
210 label: 'Trace',
211 passes: reviewed === toVerify.length && broken === 0,
212 detail: `${reviewed}/${toVerify.length} criteria reviewed${broken > 0 ? ` · ${plural(broken, 'broken link', 'broken links')}` : ''}`,
213 },
214 {
215 id: 'lock',
216 label: 'Lock',
217 passes: lock?.matches === true,
218 detail: !lock
219 ? 'not published yet'
220 : lock.matches
221 ? `matches publish ${lock.publishSequence}`
222 : `unpublished changes since publish ${lock.publishSequence}`,
223 },
224 ]
225}
226
227const NEXT: Record<CheckId, string> = {
228 format: 'run `loft fmt`',
229 check: 'fix what `loft check` reports',
230 committed: 'commit `spec/`',
231 trace: 'link and review the tests of the criteria to verify',
232 lock: 'merge the branch, then publish on the publish branch',
233}
234
235/** The action the first check that does not pass asks for. */
236export function nextStep(all: Check[]): string {
237 const first = all.find(check => !check.passes)
238 return first ? NEXT[first.id] : 'nothing left to do'
239}
240
241/** The nodes the branch changed in one file, added then changed. */
242export function nodesIn(read: LoftRead, path: string): { change: 'added' | 'changed'; node: LoftNode }[] {
243 const base = read.status.base
244 if (!base) return []
245 return [
246 ...base.added.filter(node => node.path === path).map(node => ({ change: 'added' as const, node })),
247 ...base.changed.filter(node => node.path === path).map(node => ({ change: 'changed' as const, node })),
248 ]
249}
250
251/** How a node is named in a list: its label, or `new` without one. */
252export const labelOf = (node: LoftNode): string => node.label ?? 'new'
253
254/** The status line's entry for a read. */
255export function statusLine(read: LoftRead): string {
256 const base = read.status.base
257 const delta = base ? `+${base.added.length} ~${base.changed.length} −${base.removed.length}` : 'no base'
258 const toVerify = criteriaToVerify(read)
259 const reviewed = toVerify.filter(each => !each.open).length
260 return [
261 read.status.branch ?? 'detached HEAD',
262 delta,
263 `check ${read.checkErrors}e/${checkWarnings(read)}w`,
264 `trace ${reviewed}/${toVerify.length}`,
265 ].join(' · ')
266}
267
268/** The first `size` items of a list and how many it leaves out. */
269export function window<T>(items: readonly T[], size: number): { shown: T[]; more: number } {
270 const room = Math.max(1, size)
271 if (items.length <= room) return { shown: [...items], more: 0 }
272 return { shown: items.slice(0, room - 1), more: items.length - (room - 1) }
273}
274hooks/ui.tsx 47 lines1// The Loft status pane's tones: every Text the pane draws takes its colour and weight from this
2// table by what it means, each colour a key of the person's theme, and none is drawn dim. A
3// state is never told by colour alone: every check, change and criterion also has a glyph and
4// a word. tests/pane.test.tsx holds every drawn Text to the table.
5
6import type { ThemeKey } from 'claude-code'
7
8export type Tone =
9 | 'title'
10 | 'heading'
11 | 'body'
12 | 'meta'
13 | 'muted'
14 | 'current'
15 | 'done'
16 | 'todo'
17 | 'error'
18 | 'accent'
19 | 'rule'
20
21export const TONES: Record<Tone, { color: ThemeKey; bold?: true }> = {
22 title: { color: 'claude', bold: true },
23 heading: { color: 'text', bold: true },
24 body: { color: 'text' },
25 meta: { color: 'subtle' },
26 muted: { color: 'inactive' },
27 current: { color: 'claude', bold: true },
28 done: { color: 'success' },
29 todo: { color: 'warning' },
30 error: { color: 'error' },
31 accent: { color: 'suggestion', bold: true },
32 rule: { color: 'subtle' },
33}
34
35type Elements = { Box: any; Text: any }
36
37/** The drawing kit of one render: the surface's elements, text drawn by tone only. */
38export function kit({ Box, Text }: Elements, width: number) {
39 const tone = (name: Tone, children: unknown, wrap: 'wrap' | 'truncate-end' = 'truncate-end') => (
40 <Text color={TONES[name].color} bold={TONES[name].bold} wrap={wrap}>
41 {children}
42 </Text>
43 )
44 const rule = () => tone('rule', '─'.repeat(Math.max(4, Math.min(width, 48))))
45 return { Box, tone, rule }
46}
47types/index.d.ts 70 lines1// The Loft status pane's state contract: what one read of a specification gives the pane, and
2// the values the pane keeps for the session in `$.state`.
3
4/** A place in a file, as `loft` reports it. */
5export type LoftSite = { path: string; line?: number; column?: number }
6
7/** A node `loft status --base` lists as added, changed or removed. */
8export type LoftNode = LoftSite & {
9 key: string
10 label: string | null
11 kind: string
12 name: string
13}
14
15/** A finding of `loft check` or `loft reconcile`, with the key of the node it is about. */
16export type LoftFinding = {
17 rule: string
18 severity: string
19 message: string
20 key?: string
21 label?: string | null
22 path?: string
23 line?: number
24}
25
26/** An error a `loft` command stopped on. */
27export type LoftError = { command: string; message: string; path?: string; line?: number }
28
29/** What `loft status --base` reports. */
30export type LoftStatus = {
31 lock: { publishSequence: number; matches: boolean; differs: string | null } | null
32 branch: string | null
33 modified: string[]
34 base: { revision: string; commit: string; added: LoftNode[]; changed: LoftNode[]; removed: LoftNode[] } | null
35}
36
37/** One complete read: every command ran and printed its document. */
38export type LoftRead = {
39 status: LoftStatus
40 canonical: boolean
41 drift: string[]
42 checkErrors: number
43 checkFindings: LoftFinding[]
44 traceFindings: LoftFinding[]
45}
46
47/** What the pane draws from. */
48export type LoftSnapshot = {
49 /** The directory that holds `spec/`, or null where the session has none. */
50 root: string | null
51 /** The last complete read, kept while a later one stops on an error. */
52 read: LoftRead | null
53 /** Why no read can run: `loftCommand` cannot be run or has no `status`. */
54 notice: string | null
55 /** The errors of the last read, empty when it was complete. */
56 errors: LoftError[]
57}
58
59declare module 'claude-code' {
60 interface PluginState {
61 loft: {
62 snapshot: LoftSnapshot | null
63 /** The base `/loft-status <revision>` set for the session, or null for the setting. */
64 base: string | null
65 /** The file under `spec/` the agent last wrote, from the specification's directory. */
66 workingOn: string | null
67 }
68 }
69}
70