SLOPSHOPPER

loft

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.

paneguardcommandstatusprocesstimer
★ 7v2.2.1Apache-2.0updated 2026-10-25Ask-Loft/loft/plugins/claude/loft
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loft
│ ┃ Loft ✕ › fix the failing auth test and add an audit log call │ ┃ Loft │ ┃ No Loft specification here: no ⏺ Read(src/auth.ts) │ ┃ spec/loftspec.yaml at or above this ⎿ Read 6 lines │ ┃ directory. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /loft-status │ ⎿ loft: The Loft status pane found no Loft specification at or abo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Loft
Loft No Loft specification here: no spec/loftspec.yaml at or above this directory.
README

Loft

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 export
  • crates/loftspec-store — memory and file stores
  • crates/loft-cli — the loft binary
  • crates/software-plant — the Software Plant: simulators of the external systems Loft depends on, for tests only

Licensed under Apache-2.0 (LICENSE, NOTICE). Contributions: CONTRIBUTING.md. Security reports: SECURITY.md. Changes: CHANGELOG.md.

Status

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.

Working with a specification

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.

  • Your part. Say what the product is for and where it ends, answer the agent's questions, agree the outline it proposes, choose between the options it lays out, and read what it wrote: the diff, or 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.
  • The agent's part. It asks rather than guesses, proposes the outline before writing any criterion, writes the nodes, and runs loft fmt and loft check after every step until both pass.
  • Why not by hand. A change rarely stays in one place: a reworded rule, a split Requirement or a new decision touches criteria, edges and citations elsewhere. The agent follows them, and 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.
  • The specification first, then the code. A change goes into 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.

Install

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.

Quick start

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.

CommandWhat 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 publishAllocates labels and writes the lock, on the publish branch
loft verifyRecomputes 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 termsLists 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-driverMerges one file of spec/ as git's merge driver; --install registers it
loft skill installWrites the agent skill into the current directory, or brings it up to date

Agents

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:

  • Claude Code: /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: 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.

Exit codes and JSON

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": [...]}]}

Publishing and merging

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.

Tracing tests to criteria

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 and the update check

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.

ToRun or set
switch telemetry off for the userloft telemetry off (on and status too)
switch telemetry off for one shellLOFT_TELEMETRY=off (any value but internal)
switch the update check offloft update-check off, or LOFT_UPDATE_CHECK=off
send elsewhereLOFT_TELEMETRY_ENDPOINT=<url>
ask elsewhere for releasesLOFT_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.

Software Plant

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:

  • a 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;
  • a clock that sets SOURCE_DATE_EPOCH, so a test names the publish time exactly;
  • a collector on a loopback port that accepts, rejects, fails, stays silent or refuses connections as the collector's wire contract says, and records what it received;
  • a release feed on a loopback port that answers GitHub's latest-release request with a release, a draft, a prerelease, an error or a rate limit, or stays silent.
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>.

Build and gate

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.

Source 4 files
hooks/register.tsx 309 lines
1// 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}
309
hooks/model.ts 274 lines
1// 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}
274
hooks/ui.tsx 47 lines
1// 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}
47
types/index.d.ts 70 lines
1// 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