SLOPSHOPPER

openspec-status

Shows the OpenSpec changes of the open project under the prompt, for local roots and stores, and warns in any project when the context fills up.

newpaneguardcommandtoaststatus
v0.5.0MITupdated 2026-10-09pierreboissinot/openspec-status
A shopper browsing a rack in a slop shop
README

openspec-status

CI License: MIT

A Claude Code mod that keeps the OpenSpec change you are working on, and how many of its tasks are ticked, in the status line under the prompt, and opens a pane with every change, the specs and the active change's artifacts and tasks on /openspec view. It works with a local openspec/ root and with a shared store declared by store: in openspec/config.yaml.

In any project, with OpenSpec or not, it also warns when the context window fills up, before auto-compaction summarizes the conversation for you.

Claude Code with the status line on add-dark-mode at 3/7 tasks; after a task is ticked /openspec shows 4/7. /openspec view opens the OpenSpec pane: Overview lists 2 specs, 5 requirements and both changes with their progress bars, and the Change tab shows add-dark-mode's artifacts and its seven tasks. A prompt is then answered with the context 78% full: a toast advises capturing where you are with /opsx:update add-dark-mode, then /clear and /opsx:apply add-dark-mode, and the status line ends with context 78%!, until /clear removes it

Features

  • Status line: the active change and its ticked tasks, following the /opsx workflows and the git branch, for a local openspec/ root or a declared store (Which change is active).
  • Health: the most important openspec doctor finding at the end of the line, such as a store behind its upstream (Health).
  • Unusable store: a line saying the declared store is not registered, its store: line is invalid, or the store cannot be used (When the declared store cannot be used).
  • Context warning: in any project, a toast and a status line segment when the context reaches 75% and 90% of the model's window, with the way back for the active change (Context warning).
  • /openspec: reads everything again and answers with a summary and every health finding (/openspec).
  • /openspec view: a pane with every change and its progress, the specs, the health findings, and the active change's artifacts and tasks (/openspec view).

In the OpenSpec repository, after /opsx:apply add-global-install-scope:

openspec  add-global-install-scope  0/38 tasks

In a store whose checkout is three commits behind its upstream:

openspec  add-global-install-scope  0/38 tasks · store 3 commits behind

Claude Code draws the ⚠ openspec-status: prefix in front of every plugin's status line; it does not mean something is wrong.

Install

The repository is its own plugin marketplace:

claude plugin marketplace add pierreboissinot/openspec-status
claude plugin install openspec-status@openspec-status

claude plugin marketplace update openspec-status picks up new releases.

To run it from a clone instead:

git clone https://github.com/pierreboissinot/openspec-status.git ~/src/openspec-status
claude --plugin-dir ~/src/openspec-status

Where no flag can be given (the desktop app, an SDK host), set CLAUDE_CODE_PLUGIN_DIRS to the same path, in the environment or in the env block of ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/src/openspec-status" } }

Requirements

  • OpenSpec CLI 1.14 or newer on the PATH.
  • git, for the current branch.
  • Claude Code with function-hook mods. Verified on Claude Code 2.1.287. The mods API is in early access and may change between releases; run claude plugin validate .claude-plugin/plugin.json from the plugin folder after an update.

Which change is active

  1. The last change an OpenSpec workflow named in this session:
  2. in a command Claude runs through the openspec CLI, as every /opsx workflow does: --change <name>, new change <name>, or an argument that is the name of a change (openspec validate <name>). Launchers such as npx, pnpm or env in front, and a versioned package (npx @fission-ai/openspec@latest), are recognized; openspec quoted inside another command's argument is not;
  3. the first argument of an /opsx:* command, when it is the name of a change.
  4. Otherwise, the change named like the current git branch.
  5. Otherwise none, and there is no status line, unless a health finding is retained, the declared store cannot be used or the context fills up (see below).

Changing directory or /clear forgets the workflow's change. A change created during the turn (openspec new change) shows up once it is listed. A Bash call you refuse at the permission prompt names nothing.

Health

The mod reads openspec doctor --json and keeps its errors and warnings, plus a store checkout behind its upstream tracking branch. It ignores the other notes, such as a store remote that differs from the checkout's origin or a referenced store whose spec index was truncated. The most important finding goes at the end of the status line, in a few words, followed by +N when there are others:

openspec  add-dark-mode  3/7 tasks · team-plans not registered +1

Without an active change, a finding still shows, on its own:

openspec  store 3 commits behind

How far behind the store is comes from its local upstream tracking branch, as of its last git fetch. The finding appears a moment after the change: the session does not wait for doctor. If doctor fails, the status line stays as it would be without it, and the reason goes to the debug log only.

When the declared store cannot be used

When openspec/config.yaml declares a store with store: and the CLI cannot resolve it, every OpenSpec command in the project fails. The status line says so, without the store's name or the CLI's message (/openspec gives both):

CauseStatus line
The store is not registered on this machineopenspec store not registered
The store: line cannot be readopenspec store: line invalid
Any other failure, such as a deleted or damaged clone, or mismatched store identityopenspec store unusable

The line goes away once the store resolves again. doctor is not run while the store cannot be used.

Only the project's own store: declaration counts. A stale global defaultStore (openspec config set defaultStore) is ignored: outside an OpenSpec project the mod shows nothing but the context warning.

Context warning

After every main-conversation turn, Claude Code reports how full the context is, in percent of the model's window. The mod uses the two levels of abtop: a warning from 75%, marked !, and a critical level from 90%, marked ⚠.

Each time the fill reaches a higher level, a toast shows for 10 seconds:

Context 78% full. Write down what matters in an artifact, then start a fresh session or /clear.

With an active change, it names the way back, since /clear forgets the workflow's change:

Context 78% full. Capture where you are in add-dark-mode (/opsx:update add-dark-mode), then /clear and resume with /opsx:apply add-dark-mode.

While the fill stays at a level, the status line carries it, after the change and before any finding:

openspec  add-dark-mode  3/7 tasks · context 91%⚠

Outside an OpenSpec project, the line is context 78%!, write down and /clear. The segment goes away after /clear, or once a compaction brings the fill back below 75%; reaching a level again shows a new toast. The percentage is the model's window, as abtop reads it, so auto-compaction may run before the warning when its own window is smaller.

Both levels are settings of the plugin, in /config: contextWarningPercent (75) and contextCriticalPercent (90). 0 turns a level off. A change applies the next time the plugin is loaded.

When it refreshes

At session start, right after /clear, after a change of working directory, at the end of every main-conversation turn, on /openspec, as soon as a workflow names another change, and after every Edit or Write of a tasks.md and every Bash command that mentions one, so the count follows /opsx:apply as it ticks tasks within a single turn.

Health is read at session start, right after /clear, after a change of working directory and on /openspec, but never at the end of a turn that stays in the same directory.

When Claude Code does not report a change of working directory, the mod notices it at the end of the turn: it then forgets the workflow's change and reads the health in the new directory.

The context fill comes from Claude Code's own measurement after every main-conversation turn.

/openspec

Reads the changes again, updates the status line, and answers with a one-line summary:

ContextAnswer
Local rootopenspec: local, 30 active changes
Declared storeopenspec: store:team-plans, 12 active changes
Store declared but not registered on this machineopenspec: unknown store, <the fix the CLI suggests>
Store declared but unusable, or store: line invalidopenspec: unusable store, then - <the CLI's message> and Fix: <its fix>
No root (after leaving an OpenSpec project)openspec: no OpenSpec root resolved from <cwd>

When the CLI call fails, the answer keeps the last known summary and ends with (refresh failed: <error>).

/openspec also reads the health again. Under the summary it lists every finding, most important first, with the full message from openspec doctor and, when it suggests one, its fix:

openspec: store:demo-plans, 30 active changes
- Referenced store 'team-plans' is not registered on this machine.
  Fix: git clone -- git@github.com:dev/team-plans.git '/home/dev/openspec/team-plans' && openspec store register '/home/dev/openspec/team-plans' --id team-plans
- This store checkout is 3 commits behind its upstream tracking branch; teammates on newer commits may resolve different specs.

When openspec doctor --json fails, the summary ends with (doctor failed: <reason>) and no finding is listed.

/openspec is registered only once an OpenSpec root has been resolved, or the project declares a store that cannot be used. Claude Code cannot unregister a command, so after moving to a directory without OpenSpec in the same session it stays listed.

/openspec view

Reads everything /openspec reads, then opens the OpenSpec pane instead of answering in the transcript. In a wide fullscreen terminal the pane sits beside the transcript; otherwise it sits above the prompt. It does not take the keyboard when it opens: Ctrl+X then Tab gives it the keyboard, and Esc closes it.

  • 1: Overview: the root (local or store:<id>), how many specs and requirements it holds, then one row per active change with its ticked tasks and, when the pane is wide enough, a progress bar. ● marks the active change. The health findings follow, with their full message and fix.
  • 2: Change: the active change, or the one picked in Overview, with its schema, the state of each artifact, and its tasks, ticked or not, one line each. Nothing can be ticked from the pane.

Picking a change in Overview shows it in the Change tab; the active change and the status line stay as they are. The pick is forgotten when the pane closes or the working directory changes.

With a declared store that is not registered, the pane shows the fix the CLI suggests; with one that cannot be used, the CLI's message, then its fix. After leaving for a directory without OpenSpec, it says no root is resolved, and /openspec view answers like /openspec without opening the pane. A read that fails shows unavailable: <reason> in its section, and the reason goes to the debug log.

While the pane is open, it is read again whenever the status line is, and the Change tab as soon as the change it shows is another one. Health keeps its own rule. While the pane is closed, the mod runs nothing more than without it.

What it never does

  • In a project without OpenSpec, or without the openspec CLI, it shows nothing but the context warning: no OpenSpec line, no command. A stale global defaultStore does not change that.
  • It never compacts, clears or ends the session: it only advises.
  • It never writes to disk and never repairs anything. It runs only openspec list --json, openspec doctor --json and git branch --show-current, in the session's working directory; and, while the /openspec view pane is open, openspec list --specs --json, openspec status --change <name> --json and openspec instructions apply --change <name> --json, for the one change the pane shows. The context warning runs no command and makes no API call.
  • It never opens the pane on its own.
  • It never calls the model.

Development

claude plugin validate --strict .claude-plugin/plugin.json
claude plugin validate --strict .claude-plugin/marketplace.json
claude plugin test .
npx -p typescript@5 tsc -p .

The tests run against the engine's test kit with recorded openspec list --json, openspec doctor --json, openspec list --specs --json, openspec status --json and openspec instructions apply --json outputs in hooks/fixtures/; they need neither the CLI nor git. tsc reads the engine's declarations from .claude-plugin/types/, which Claude Code writes the first time a session loads the mod from this folder.

The demo is regenerated with VHS, from the repository root:

vhs demo/demo.tape

It records a session against a throwaway project and Claude Code home built by demo/setup.sh, and needs python3. Its one prompt is answered by demo/mock-api.py, a stand-in for the Messages API on 127.0.0.1 that reports a context 78% full, so the recording makes no API call and shows the context warning.

Releasing

.github/workflows/ci.yml runs the checks above on every pull request and push to main, against the pinned Claude Code version.

To release, bump version in .claude-plugin/plugin.json and merge to main. .github/workflows/release.yml runs CI, creates the openspec-status--v<version> tag with claude plugin tag, and publishes the GitHub release.

Roadmap

  • Filtering a shared store's changes by target repository (affected_areas).

License

MIT

Source 2 files
hooks/register.tsx 879 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  BoxProps,
4  ButtonProps,
5  ElementConstructor,
6  EngineInterface,
7  Register,
8  RenderElement,
9  TextProps,
10} from 'claude-code'
11
12import type {
13  ChangeStatus,
14  ChangeSummary,
15  ChangeTask,
16  ContextFill,
17  ContextKind,
18  Failed,
19  HealthFinding,
20  OpenSpecContext,
21  OpenSpecHealth,
22  PaneData,
23  PaneTab,
24  PaneView,
25  ShownChange,
26  SpecsSummary,
27} from '../types'
28
29export type ParsedList = Pick<OpenSpecContext, 'kind' | 'storeId' | 'fix' | 'message' | 'code' | 'changes'>
30
31type ListJson = {
32  changes?: unknown
33  root?: { source?: string; store_id?: string } | null
34  status?: { severity?: string; code?: string; message?: string; fix?: string }[]
35}
36
37const DECLARED_PREFIX = 'Declared in '
38
39const UNREGISTERED_CODES = ['unknown_store', 'no_registered_stores']
40
41const toSummary = (raw: Record<string, unknown>): ChangeSummary => ({
42  name: String(raw.name ?? ''),
43  completedTasks: Number(raw.completedTasks ?? 0),
44  totalTasks: Number(raw.totalTasks ?? 0),
45  lastModified: String(raw.lastModified ?? ''),
46  status: String(raw.status ?? ''),
47})
48
49export const parseListOutput = (stdout: string): ParsedList | null => {
50  let json: ListJson
51  try {
52    json = JSON.parse(stdout)
53  } catch {
54    return null
55  }
56  if (typeof json !== 'object' || json === null || !('root' in json)) {
57    return null
58  }
59
60  if (json.root) {
61    const changes = Array.isArray(json.changes) ? json.changes.map(toSummary) : []
62    if (json.root.source === 'declared') {
63      return json.root.store_id
64        ? { kind: 'store', storeId: json.root.store_id, changes }
65        : { kind: 'store', changes }
66    }
67    return { kind: 'local', changes }
68  }
69
70  const error = json.status?.find(s => s.severity === 'error')
71  const declared = error?.message?.startsWith(DECLARED_PREFIX) === true
72  if (declared && UNREGISTERED_CODES.includes(error?.code ?? '')) {
73    return { kind: 'unknown-store', fix: error?.fix ?? error?.message ?? '', changes: [] }
74  }
75  if (error && (declared || error.code === 'invalid_store_pointer')) {
76    return {
77      kind: 'unusable-store',
78      message: error.message ?? '',
79      ...(error.code === undefined ? {} : { code: error.code }),
80      ...(error.fix === undefined ? {} : { fix: error.fix }),
81      changes: [],
82    }
83  }
84  return { kind: 'none', changes: [] }
85}
86
87type Diagnostic = { severity?: unknown; code?: unknown; message?: unknown; fix?: unknown }
88
89type DoctorJson = {
90  root?: { status?: unknown } | null
91  store?: { drift?: { behind?: unknown }; status?: unknown } | null
92  references?: unknown
93  status?: unknown
94}
95
96const objects = (value: unknown): Record<string, unknown>[] =>
97  Array.isArray(value)
98    ? value.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
99    : []
100
101const diagnostics = (status: unknown): Diagnostic[] => objects(status)
102
103const SUMMARIES: Record<string, string> = {
104  reference_invalid_id: 'invalid reference',
105  reference_registry_unreadable: 'store registry unreadable',
106  relationship_registry_unreadable: 'store registry unreadable',
107  root_pointer_ignored: 'store: line ignored',
108  root_pointer_invalid: 'store: line invalid',
109  pointer_declarations_inert: 'references inert',
110  openspec_config_missing: 'config.yaml missing',
111  openspec_config_not_file: 'config.yaml not a file',
112  openspec_specs_not_directory: 'specs/ not a directory',
113  openspec_changes_not_directory: 'changes/ not a directory',
114  openspec_archive_not_directory: 'archive/ not a directory',
115}
116
117const summarize = (code: string, storeId: string | undefined, behind: number): string => {
118  if (code === 'store_checkout_drift') return `store ${behind} commit${behind === 1 ? '' : 's'} behind`
119  if (code === 'reference_unresolved') return `${storeId ?? 'reference'} not registered`
120  if (code === 'reference_root_unhealthy') return `${storeId ?? 'reference'} unusable`
121  return SUMMARIES[code] ?? (code.startsWith('openspec_') ? 'root unhealthy' : code)
122}
123
124export const parseDoctorOutput = (stdout: string): HealthFinding[] | string => {
125  let json: DoctorJson
126  try {
127    json = JSON.parse(stdout)
128  } catch {
129    return 'unparsable `openspec doctor --json` output'
130  }
131  if (typeof json !== 'object' || json === null) return 'unparsable `openspec doctor --json` output'
132  if (!json.root) {
133    const reason = diagnostics(json.status).find(entry => typeof entry.message === 'string')?.message
134    return `\`openspec doctor --json\` resolved no root${reason ? `: ${String(reason)}` : ''}`
135  }
136
137  const behind = Number(json.store?.drift?.behind ?? 0) || 0
138  const sourced: { diagnostic: Diagnostic; storeId?: string }[] = [
139    ...diagnostics(json.root.status).map(diagnostic => ({ diagnostic })),
140    ...diagnostics(json.store?.status).map(diagnostic => ({ diagnostic })),
141    ...diagnostics(json.references).flatMap(reference => {
142      const { store_id: storeId, status } = reference as { store_id?: unknown; status?: unknown }
143      return diagnostics(status).map(diagnostic =>
144        typeof storeId === 'string' ? { diagnostic, storeId } : { diagnostic },
145      )
146    }),
147    ...diagnostics(json.status).map(diagnostic => ({ diagnostic })),
148  ]
149
150  const rank = ({ severity, code }: Diagnostic): number | undefined => {
151    if (code === 'store_checkout_drift') return behind > 0 ? 2 : undefined
152    if (code === 'reference_index_truncated') return undefined
153    return severity === 'error' ? 0 : severity === 'warning' ? 1 : undefined
154  }
155
156  return sourced
157    .flatMap(({ diagnostic, storeId }) => {
158      const order = rank(diagnostic)
159      const { severity, code, message, fix } = diagnostic
160      if (order === undefined || typeof severity !== 'string' || typeof code !== 'string') return []
161      const finding: HealthFinding = {
162        severity,
163        code,
164        message: typeof message === 'string' ? message : code,
165        summary: summarize(code, storeId, behind),
166      }
167      return [{ order, finding: typeof fix === 'string' ? { ...finding, fix } : finding }]
168    })
169    .sort((a, b) => a.order - b.order)
170    .map(({ finding }) => finding)
171}
172
173const parseJsonObject = (stdout: string): Record<string, unknown> | null => {
174  try {
175    const json: unknown = JSON.parse(stdout)
176    return typeof json === 'object' && json !== null && !Array.isArray(json) ? (json as Record<string, unknown>) : null
177  } catch {
178    return null
179  }
180}
181
182export const parseSpecsOutput = (stdout: string): SpecsSummary | string => {
183  const json = parseJsonObject(stdout)
184  if (json === null || !Array.isArray(json.specs)) return 'unparsable `openspec list --specs --json` output'
185  const specs = objects(json.specs)
186  return { count: specs.length, requirements: specs.reduce((sum, spec) => sum + (Number(spec.requirementCount) || 0), 0) }
187}
188
189export const parseStatusOutput = (stdout: string): ChangeStatus | string => {
190  const json = parseJsonObject(stdout)
191  if (json === null || typeof json.schemaName !== 'string' || !Array.isArray(json.artifacts)) {
192    return 'unparsable `openspec status --json` output'
193  }
194  const artifacts = objects(json.artifacts).flatMap(artifact =>
195    typeof artifact.id === 'string' && typeof artifact.status === 'string' ? [{ id: artifact.id, status: artifact.status }] : [],
196  )
197  return { schema: json.schemaName, artifacts }
198}
199
200export const parseApplyOutput = (stdout: string): ChangeTask[] | string => {
201  const json = parseJsonObject(stdout)
202  if (json === null || !Array.isArray(json.tasks)) return 'unparsable `openspec instructions apply --json` output'
203  return objects(json.tasks).flatMap(task =>
204    typeof task.description === 'string' ? [{ description: task.description, done: task.done === true }] : [],
205  )
206}
207
208const unquote = (token: string): string => token.replace(/^["']+|["']+$/g, '')
209
210const LAUNCHERS = new Set(['npx', 'pnpx', 'bunx', 'npm', 'pnpm', 'yarn', 'bun', 'exec', 'dlx', 'command', 'env', 'time', 'sudo'])
211
212const isLauncher = (token: string): boolean =>
213  LAUNCHERS.has(token) || token.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
214
215const isOpenspec = (token: string): boolean => /(?:^|\/)openspec(?:@[^/]*)?$/.test(token)
216
217const shellSegments = (command: string): string[][] => {
218  const segments: string[][] = [[]]
219  let token: string | null = null
220  let quote: string | null = null
221  const endToken = () => {
222    if (token !== null) segments[segments.length - 1]?.push(token)
223    token = null
224  }
225  for (let i = 0; i < command.length; i++) {
226    const char = command.charAt(i)
227    if (quote !== null) {
228      if (char === quote) quote = null
229      else if (char === '\\' && quote === '"' && /["\\$`]/.test(command.charAt(i + 1))) token = (token ?? '') + command.charAt(++i)
230      else token = (token ?? '') + char
231    } else if (char === '"' || char === "'") {
232      quote = char
233      token ??= ''
234    } else if (char === '\\') {
235      token = (token ?? '') + command.charAt(++i)
236    } else if (char === '\n' || /[;&|()]/.test(char)) {
237      endToken()
238      segments.push([])
239    } else if (/\s/.test(char)) {
240      endToken()
241    } else {
242      token = (token ?? '') + char
243    }
244  }
245  endToken()
246  return segments
247}
248
249export const changeFromOpenspecCommand = (command: string, knownNames: readonly string[]): string | undefined => {
250  for (const tokens of shellSegments(command)) {
251    const start = tokens.findIndex(isOpenspec)
252    if (start === -1 || !tokens.slice(0, start).every(isLauncher)) continue
253    const args = tokens.slice(start + 1)
254
255    const flag = args.findIndex(arg => arg === '--change' || arg.startsWith('--change='))
256    if (flag !== -1) {
257      const value = args[flag] === '--change' ? args[flag + 1] : args[flag]?.slice('--change='.length)
258      if (value && !value.startsWith('-')) return value
259    }
260    if (args[0] === 'new' && args[1] === 'change' && args[2] && !args[2].startsWith('-')) {
261      return args[2]
262    }
263    const named = args.find(arg => knownNames.includes(arg))
264    if (named) return named
265  }
266  return undefined
267}
268
269export const contextAtom = atom({ plugin: 'openspec-status', key: 'context' } as const, null)
270export const workflowChangeAtom = atom({ plugin: 'openspec-status', key: 'workflowChange' } as const, null)
271export const lastErrorAtom = atom({ plugin: 'openspec-status', key: 'lastError' } as const, null)
272export const healthAtom = atom({ plugin: 'openspec-status', key: 'health' } as const, null)
273export const healthReadAtom = atom({ plugin: 'openspec-status', key: 'healthRead' } as const, 0)
274export const paneAtom = atom({ plugin: 'openspec-status', key: 'pane' } as const, null)
275const FRESH_VIEW: PaneView = { tab: 'overview', pick: null }
276export const paneViewAtom = atom({ plugin: 'openspec-status', key: 'paneView' } as const, FRESH_VIEW)
277
278export const PANE = 'openspec'
279export const contextFillAtom = atom({ plugin: 'openspec-status', key: 'contextFill' } as const, null)
280
281export type ContextThresholds = { warning: number; critical: number }
282
283export const levelOf = (percent: number | undefined, thresholds: ContextThresholds): ContextFill | null => {
284  if (percent === undefined) return null
285  if (thresholds.critical > 0 && percent >= thresholds.critical) return { percent, level: 'critical' }
286  if (thresholds.warning > 0 && percent >= thresholds.warning) return { percent, level: 'warning' }
287  return null
288}
289
290const rank = (fill: ContextFill | null): number => (fill === null ? 0 : fill.level === 'warning' ? 1 : 2)
291
292export const contextToast = (context: OpenSpecContext | null, percent: number): string => {
293  const change = context !== null && hasRoot(context.kind) ? context.currentChange : undefined
294  const advice =
295    change === undefined
296      ? 'Write down what matters in an artifact, then start a fresh session or /clear.'
297      : `Capture where you are in ${change} (/opsx:update ${change}), then /clear and resume with /opsx:apply ${change}.`
298  return `Context ${percent}% full. ${advice}`
299}
300
301const hasRoot = (kind: ContextKind): boolean => kind === 'local' || kind === 'store'
302
303const withCurrent = (context: OpenSpecContext, workflowChange: string | null): OpenSpecContext => {
304  const { currentChange: _previous, ...rest } = context
305  const isListed = (name: string | null | undefined): name is string =>
306    typeof name === 'string' && context.changes.some(change => change.name === name)
307  const current = isListed(workflowChange) ? workflowChange : isListed(context.branch) ? context.branch : undefined
308  return current === undefined ? rest : { ...rest, currentChange: current }
309}
310
311export const statusText = (
312  context: OpenSpecContext | null,
313  health: OpenSpecHealth | null,
314  fill: ContextFill | null,
315): string | undefined => {
316  const fillSegment = fill === null ? undefined : `context ${fill.percent}%${fill.level === 'critical' ? '⚠' : '!'}`
317  if (context === null || context.kind === 'none') {
318    return fillSegment && `${fillSegment}, write down and /clear`
319  }
320  const change = context.changes.find(candidate => candidate.name === context.currentChange)
321  const progress = change && (change.totalTasks === 0 ? 'no tasks' : `${change.completedTasks}/${change.totalTasks} tasks`)
322  const head = change && `${change.name}  ${progress}`
323  const [first, ...others] = hasRoot(context.kind) && health?.cwd === context.cwd ? health.findings : []
324  const finding = first && `${first.summary}${others.length > 0 ? ` +${others.length}` : ''}`
325  const tail =
326    context.kind === 'unknown-store'
327      ? 'store not registered'
328      : context.kind === 'unusable-store'
329        ? context.code === 'invalid_store_pointer'
330          ? 'store: line invalid'
331          : 'store unusable'
332        : finding
333  const segments = [head, fillSegment, tail].filter(segment => segment !== undefined)
334  return segments.length === 0 ? undefined : `openspec  ${segments.join(' · ')}`
335}
336
337const writeLine = ($: EngineInterface, before: string | undefined, after: string | undefined): void => {
338  if (after !== undefined) {
339    $.ui.status(after)
340  } else if (before !== undefined) {
341    $.ui.status(undefined)
342  }
343}
344
345const currentLine = async ($: EngineInterface): Promise<string | undefined> =>
346  statusText(await read($, contextAtom), await read($, healthAtom), await read($, contextFillAtom))
347
348const writeContext = async ($: EngineInterface, context: OpenSpecContext): Promise<void> => {
349  const before = await currentLine($)
350  await update($, contextAtom, () => context)
351  writeLine($, before, await currentLine($))
352}
353
354const writeHealth = async ($: EngineInterface, health: OpenSpecHealth | null): Promise<void> => {
355  const before = await currentLine($)
356  await update($, healthAtom, () => health)
357  const after = await currentLine($)
358  if (after !== before) writeLine($, before, after)
359}
360
361const writeFill = async ($: EngineInterface, fill: ContextFill | null): Promise<void> => {
362  const before = await currentLine($)
363  await update($, contextFillAtom, () => fill)
364  const after = await currentLine($)
365  if (after !== before) writeLine($, before, after)
366}
367
368const readBranch = async ($: EngineInterface, cwd: string): Promise<string | undefined> => {
369  try {
370    const { exitCode, stdout } = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 5_000 })
371    const branch = stdout.trim()
372    return exitCode === 0 && branch !== '' ? branch : undefined
373  } catch {
374    return undefined
375  }
376}
377
378const listChanges = async ($: EngineInterface, cwd: string): Promise<ParsedList | string> => {
379  try {
380    const { stdout } = await $.process.run(['openspec', 'list', '--json'], { cwd, timeoutMs: 10_000 })
381    return parseListOutput(stdout) ?? `unparsable \`openspec list --json\` output in ${cwd}`
382  } catch (error) {
383    return `\`openspec list --json\` failed in ${cwd}: ${error instanceof Error ? error.message : String(error)}`
384  }
385}
386
387export const refresh = async ($: EngineInterface, cwd: string): Promise<OpenSpecContext> => {
388  const listed = await listChanges($, cwd)
389
390  if (typeof listed === 'string') {
391    await update($, lastErrorAtom, () => listed)
392    $.ui.log(`openspec-status: ${listed}`, { to: 'debug' })
393    const kept = withCurrent((await read($, contextAtom)) ?? { kind: 'none', cwd, changes: [] }, await read($, workflowChangeAtom))
394    await writeContext($, kept)
395    return kept
396  }
397
398  const branch = hasRoot(listed.kind) ? await readBranch($, cwd) : undefined
399  const resolved: OpenSpecContext = branch === undefined ? { ...listed, cwd } : { ...listed, cwd, branch }
400  const context = withCurrent(resolved, await read($, workflowChangeAtom))
401
402  await update($, lastErrorAtom, () => null)
403  await writeContext($, context)
404  if (context.kind !== 'none') {
405    await $.command.register({
406      name: 'openspec',
407      description: 'Refresh the active OpenSpec change and summarize the changes',
408      argumentHint: '[view]',
409    })
410  }
411  return context
412}
413
414
415/**
416 * Resolves the cleared session after `/clear`'s own run. There, `$.state` still reads as before the clear while
417 * writes land in the cleared session, so every value is passed along rather than read back; the line on screen
418 * is the one `session.end` left, computed from that same pre-clear state.
419 */
420const afterClear = async ($: EngineInterface, cwd: string): Promise<OpenSpecContext> => {
421  const previous = await read($, contextAtom)
422  const shown = statusText(previous && withCurrent(previous, null), await read($, healthAtom), null)
423
424  const listed = await listChanges($, cwd)
425  if (typeof listed === 'string') $.ui.log(`openspec-status: ${listed}`, { to: 'debug' })
426  const parsed: ParsedList = typeof listed === 'string' ? { kind: 'none', changes: [] } : listed
427  const branch = hasRoot(parsed.kind) ? await readBranch($, cwd) : undefined
428  const context = withCurrent(branch === undefined ? { ...parsed, cwd } : { ...parsed, cwd, branch }, null)
429
430  await update($, contextAtom, () => context)
431  await update($, workflowChangeAtom, () => null)
432  await update($, healthAtom, () => null)
433  await update($, contextFillAtom, () => null)
434  await update($, lastErrorAtom, () => (typeof listed === 'string' ? listed : null))
435  const line = statusText(context, null, null)
436  if (line !== shown) writeLine($, shown, line)
437  if (context.kind !== 'none') {
438    await $.command.register({ name: 'openspec', description: 'Refresh the active OpenSpec change and summarize the changes' })
439  }
440  if (!hasRoot(context.kind)) return context
441
442  const health = await diagnose($, cwd)
443  await update($, healthAtom, () => health)
444  const withHealth = statusText(context, health, null)
445  if (withHealth !== line) writeLine($, line, withHealth)
446  return context
447}
448
449const diagnose = async ($: EngineInterface, cwd: string): Promise<OpenSpecHealth> => {
450  let findings: HealthFinding[] | string
451  try {
452    const { stdout } = await $.process.run(['openspec', 'doctor', '--json'], { cwd, timeoutMs: 10_000 })
453    findings = parseDoctorOutput(stdout)
454  } catch (error) {
455    findings = `\`openspec doctor --json\` failed in ${cwd}: ${error instanceof Error ? error.message : String(error)}`
456  }
457  if (typeof findings !== 'string') return { cwd, findings }
458  $.ui.log(`openspec-status: ${findings}`, { to: 'debug' })
459  return { cwd, findings: [], error: findings }
460}
461
462const readHealth = async ($: EngineInterface, context: OpenSpecContext): Promise<void> => {
463  const ticket = await update($, healthReadAtom, count => count + 1)
464  const health = hasRoot(context.kind) ? await diagnose($, context.cwd) : null
465  if ((await read($, healthReadAtom)) !== ticket) return
466  const current = await read($, contextAtom)
467  if (current === null || current.cwd !== context.cwd) return
468  await writeHealth($, health)
469}
470
471const startHealthRead = ($: EngineInterface, context: OpenSpecContext): void => {
472  // The mod may be unloaded before doctor answers; its state is then refused, with no one left to tell.
473  readHealth($, context).catch(() => undefined)
474}
475
476const readCli = async <T extends object>(
477  $: EngineInterface,
478  args: string[],
479  cwd: string,
480  parse: (stdout: string) => T | string,
481): Promise<T | Failed> => {
482  let error: string
483  try {
484    const { stdout, stderr } = await $.process.run(['openspec', ...args], { cwd, timeoutMs: 10_000 })
485    const parsed = parse(stdout)
486    if (typeof parsed !== 'string') return parsed
487    error = stderr.trim().split('\n')[0] || `${parsed} in ${cwd}`
488  } catch (reason) {
489    error = `\`openspec ${args.join(' ')}\` failed in ${cwd}: ${reason instanceof Error ? reason.message : String(reason)}`
490  }
491  $.ui.log(`openspec-status: ${error}`, { to: 'debug' })
492  return { error }
493}
494
495// Every $.state read of one dispatch sees one moment, so a counter kept there cannot tell a long read that a newer one started.
496let paneReads = 0
497
498export const shownName = (context: OpenSpecContext, pick: string | null): string | null =>
499  pick !== null && context.changes.some(change => change.name === pick) ? pick : (context.currentChange ?? null)
500
501const readShown = async ($: EngineInterface, cwd: string, name: string): Promise<ShownChange> => {
502  const [status, tasks] = await Promise.all([
503    readCli($, ['status', '--change', name, '--json'], cwd, parseStatusOutput),
504    readCli($, ['instructions', 'apply', '--change', name, '--json'], cwd, parseApplyOutput),
505  ])
506  return { name, status, tasks }
507}
508
509export const refreshPane = async (
510  $: EngineInterface,
511  context: OpenSpecContext,
512  { withSpecs = true }: { withSpecs?: boolean } = {},
513): Promise<void> => {
514  const ticket = ++paneReads
515  const previous = await read($, paneAtom)
516  const kept = previous?.cwd === context.cwd ? previous : null
517  let data: PaneData
518  if (!hasRoot(context.kind)) {
519    data = { cwd: context.cwd, specs: null, shown: null }
520  } else {
521    const name = shownName(context, (await read($, paneViewAtom)).pick)
522    const [specs, shown] = await Promise.all([
523      withSpecs || kept === null
524        ? readCli($, ['list', '--specs', '--json'], context.cwd, parseSpecsOutput)
525        : kept.specs,
526      name === null ? null : readShown($, context.cwd, name),
527    ])
528    data = { cwd: context.cwd, specs, shown }
529  }
530  if (ticket !== paneReads) return
531  await update($, paneAtom, () => data)
532}
533
534const isPaneOpen = async ($: EngineInterface): Promise<boolean> => (await $.ui.panes()).some(pane => pane.id === PANE)
535
536const refreshOpenPane = async ($: EngineInterface, context: OpenSpecContext): Promise<void> => {
537  if (await isPaneOpen($)) await refreshPane($, context)
538}
539
540const nameWorkflowChange = async ($: EngineInterface, name: string): Promise<void> => {
541  await update($, workflowChangeAtom, () => name)
542  const context = await read($, contextAtom)
543  if (context !== null && hasRoot(context.kind)) {
544    const named = withCurrent(context, name)
545    await writeContext($, named)
546    const pane = await read($, paneAtom)
547    if (pane !== null && pane.shown?.name !== shownName(named, (await read($, paneViewAtom)).pick) && (await isPaneOpen($))) {
548      await refreshPane($, named, { withSpecs: false })
549    }
550  }
551}
552
553const knownNames = (context: OpenSpecContext | null): string[] =>
554  context !== null && hasRoot(context.kind) ? context.changes.map(change => change.name) : []
555
556const sourceOf = (context: OpenSpecContext): string =>
557  context.kind === 'store' ? (context.storeId ? `store:${context.storeId}` : 'store') : 'local'
558
559const summaryLine = (context: OpenSpecContext): string => {
560  if (context.kind === 'none') return `openspec: no OpenSpec root resolved from ${context.cwd}`
561  if (context.kind === 'unknown-store') return `openspec: unknown store, ${context.fix ?? ''}`
562  if (context.kind === 'unusable-store') return 'openspec: unusable store'
563  const source = sourceOf(context)
564  const count = context.changes.length
565  return `openspec: ${source}, ${count} active change${count === 1 ? '' : 's'}`
566}
567
568export const commandAnswer = (
569  context: OpenSpecContext,
570  refreshError: string | null,
571  health: OpenSpecHealth | null,
572): string => {
573  const current = health?.cwd === context.cwd ? health : null
574  let summary = summaryLine(context)
575  if (refreshError !== null) summary += ` (refresh failed: ${refreshError})`
576  if (current?.error !== undefined) summary += ` (doctor failed: ${current.error})`
577  const storeError = context.kind === 'unusable-store' ? [{ message: context.message ?? '', fix: context.fix }] : []
578  const findings = [...storeError, ...(current?.findings ?? [])].flatMap(finding =>
579    finding.fix === undefined ? [`- ${finding.message}`] : [`- ${finding.message}`, `  Fix: ${finding.fix}`],
580  )
581  return [summary, ...findings].join('\n')
582}
583
584export type PaneElements = {
585  Box: ElementConstructor<BoxProps>
586  Text: ElementConstructor<TextProps>
587  Button: ElementConstructor<ButtonProps>
588}
589
590export type PaneInput = {
591  context: OpenSpecContext | null
592  health: OpenSpecHealth | null
593  pane: PaneData | null
594  view: PaneView
595  columns: number
596  onTab: (tab: PaneTab) => Promise<void>
597  onPick: (name: string) => Promise<void>
598}
599
600const isFailed = (value: object): value is Failed => 'error' in value
601
602const progressOf = (change: ChangeSummary): string =>
603  change.totalTasks === 0 ? 'no tasks' : `${change.completedTasks}/${change.totalTasks}`
604
605const fitted = (text: string, width: number): string =>
606  text.length <= width ? text.padEnd(width) : `${text.slice(0, Math.max(0, width - 1))}…`
607
608const MIN_BAR = 5
609
610const overviewOf = (el: PaneElements, input: PaneInput, context: OpenSpecContext, pane: PaneData): RenderElement[] => {
611  const { Box, Text, Button } = el
612  const source = sourceOf(context)
613  const specs =
614    pane.specs === null ? '' : isFailed(pane.specs) ? `unavailable: ${pane.specs.error}` : `${pane.specs.count} specs, ${pane.specs.requirements} requirements`
615  const countWidth = Math.max(0, ...context.changes.map(change => progressOf(change).length))
616  const labelWidth = Math.min(
617    Math.max(0, ...context.changes.map(change => change.name.length + 2)),
618    Math.max(4, input.columns - countWidth - 1),
619  )
620  const barWidth = input.columns - labelWidth - countWidth - 2
621  const rows = context.changes.map(change => {
622    const marker = change.name === context.currentChange ? '● ' : '  '
623    const share = change.totalTasks === 0 ? undefined : Math.min(1, Math.max(0, change.completedTasks / change.totalTasks))
624    const filled = share === undefined ? 0 : Math.round(share * barWidth)
625    return Box({
626      key: `row-${change.name}`,
627      flexDirection: 'row',
628      columnGap: 1,
629      children: [
630        Button({
631          key: `change-${change.name}`,
632          label: fitted(marker + change.name, labelWidth),
633          plain: true,
634          onPress: () => input.onPick(change.name),
635        }),
636        Text({ dimColor: change.totalTasks === 0, children: [progressOf(change).padStart(countWidth)] }),
637        ...(share === undefined || barWidth < MIN_BAR ? [] : [Text({ children: ['█'.repeat(filled) + '░'.repeat(barWidth - filled)] })]),
638      ],
639    })
640  })
641  const findings = input.health?.cwd === context.cwd ? input.health.findings : []
642  const health = findings.flatMap(finding => [
643    Text({ color: finding.severity === 'error' ? 'error' : 'warning', wrap: 'wrap', children: [finding.message] }),
644    ...(finding.fix === undefined ? [] : [Text({ dimColor: true, wrap: 'wrap', children: [`Fix: ${finding.fix}`] })]),
645  ])
646  return [
647    Text({ bold: true, children: [source] }),
648    ...(specs === '' ? [] : [Text({ dimColor: true, children: [specs] })]),
649    Text({ children: [' '] }),
650    ...(rows.length === 0 ? [Text({ dimColor: true, children: ['No active change.'] })] : rows),
651    ...(health.length === 0 ? [] : [Text({ children: [' '] }), Text({ bold: true, children: ['Health'] }), ...health]),
652  ]
653}
654
655const changeTabOf = (el: PaneElements, input: PaneInput, context: OpenSpecContext, pane: PaneData): RenderElement[] => {
656  const { Text } = el
657  const expected = shownName(context, input.view.pick)
658  if (expected === null) return [Text({ dimColor: true, children: ['No active change: pick one in Overview (1).'] })]
659  const shown = pane.shown
660  if (shown === null || shown.name !== expected) return [Text({ dimColor: true, children: ['loading…'] })]
661  const artifacts = isFailed(shown.status)
662    ? [Text({ color: 'error', wrap: 'wrap', children: [`unavailable: ${shown.status.error}`] })]
663    : (() => {
664        const width = Math.max(0, ...shown.status.artifacts.map(artifact => artifact.id.length))
665        return shown.status.artifacts.map(artifact =>
666          Text({
667            color: artifact.status === 'done' ? 'success' : undefined,
668            dimColor: artifact.status === 'blocked' || artifact.status === 'skipped',
669            children: [`${artifact.id.padEnd(width)}  ${artifact.status}`],
670          }),
671        )
672      })()
673  const tasks = isFailed(shown.tasks)
674    ? [Text({ color: 'error', wrap: 'wrap', children: [`unavailable: ${shown.tasks.error}`] })]
675    : shown.tasks.length === 0
676      ? [Text({ dimColor: true, children: ['No tasks yet.'] })]
677      : shown.tasks.map(task =>
678          Text({ dimColor: task.done, wrap: 'truncate-end', children: [`${task.done ? '[x]' : '[ ]'} ${task.description}`] }),
679        )
680  const schema = isFailed(shown.status) ? [] : [Text({ dimColor: true, children: [shown.status.schema] })]
681  return [
682    Text({ bold: true, children: [shown.name] }),
683    ...schema,
684    Text({ children: [' '] }),
685    ...artifacts,
686    Text({ children: [' '] }),
687    ...tasks,
688  ]
689}
690
691export const paneTree = (el: PaneElements, input: PaneInput): RenderElement => {
692  const { Box, Text, Button } = el
693  const { context, pane } = input
694  const column = (children: RenderElement[]) => Box({ flexDirection: 'column', children })
695  if (context === null || pane === null || pane.cwd !== context.cwd) {
696    return column([Text({ dimColor: true, children: ['loading…'] })])
697  }
698  if (context.kind === 'none') return column([Text({ children: [summaryLine(context)] })])
699  if (context.kind === 'unknown-store') {
700    return column([
701      Text({ bold: true, children: ['store not registered'] }),
702      Text({ wrap: 'wrap', children: [`Fix: ${context.fix ?? ''}`] }),
703    ])
704  }
705  if (context.kind === 'unusable-store') {
706    return column([
707      Text({ bold: true, children: ['store unusable'] }),
708      Text({ wrap: 'wrap', children: [context.message ?? ''] }),
709      ...(context.fix === undefined ? [] : [Text({ wrap: 'wrap', children: [`Fix: ${context.fix}`] })]),
710    ])
711  }
712  const tab = (name: PaneTab, label: string, hotkey: string) =>
713    Button({
714      key: `tab-${name}`,
715      label,
716      hotkey,
717      plain: true,
718      dimColor: input.view.tab !== name,
719      onPress: () => input.onTab(name),
720    })
721  return column([
722    Box({ flexDirection: 'row', columnGap: 3, children: [tab('overview', 'Overview', '1'), tab('change', 'Change', '2')] }),
723    Text({ children: [' '] }),
724    ...(input.view.tab === 'overview' ? overviewOf(el, input, context, pane) : changeTabOf(el, input, context, pane)),
725  ])
726}
727
728export const register: Register = (on, options) => {
729  const thresholds: ContextThresholds = {
730    warning: Number(options.contextWarningPercent),
731    critical: Number(options.contextCriticalPercent),
732  }
733
734  on('session.measure', async ($, e, next) => {
735    const fill = levelOf(e.context.percent, thresholds)
736    const previous = await read($, contextFillAtom)
737    if (fill !== null && rank(fill) > rank(previous)) {
738      $.ui.toast(contextToast(await read($, contextAtom), fill.percent), { timeoutMs: 10_000 })
739    }
740    if (fill?.percent !== previous?.percent || fill?.level !== previous?.level) {
741      await writeFill($, fill)
742    }
743    return next(e)
744  })
745
746  on('session.end', async ($, e, next) => {
747    if (e.reason === 'clear') {
748      const before = await currentLine($)
749      await update($, contextFillAtom, () => null)
750      const context = await read($, contextAtom)
751      const after = statusText(context && withCurrent(context, null), await read($, healthAtom), null)
752      if (after !== before) writeLine($, before, after)
753    }
754    return next(e)
755  })
756
757  on('command.run', { command: 'clear' }, async ($, e, next) => {
758    const result = await next(e)
759    await refreshOpenPane($, await afterClear($, await $.session.cwd()))
760    return result
761  })
762
763  on('command.run', { command: 'openspec' }, async ($, e) => {
764    const context = await refresh($, await $.session.cwd())
765    if (e.args.trim() === 'view' && context.kind !== 'none') {
766      await Promise.all([readHealth($, context), refreshPane($, context)])
767      await $.ui.open({ id: PANE, title: 'OpenSpec', closeOnEscape: true })
768      return {}
769    }
770    await Promise.all([readHealth($, context), refreshOpenPane($, context)])
771    return { text: commandAnswer(context, await read($, lastErrorAtom), await read($, healthAtom)) }
772  })
773
774  on('command.run', { command: /^opsx:/ }, async ($, e, next) => {
775    const context = await read($, contextAtom)
776    const first = unquote(e.args.trim().split(/\s+/)[0] ?? '')
777    if (knownNames(context).includes(first)) {
778      await nameWorkflowChange($, first)
779    }
780    return next(e)
781  })
782
783  on('tool.call', { tool: 'Bash', command: /\bopenspec\b/ }, async ($, e, next) => {
784    const result = await next(e)
785    if (result.deny !== undefined) return result
786    const name = changeFromOpenspecCommand(e.command, knownNames(await read($, contextAtom)))
787    if (name !== undefined) {
788      await nameWorkflowChange($, name)
789    }
790    return result
791  })
792
793  on('tool.call', { tool: ['Edit', 'Write'], file_path: /(^|\/)tasks\.md$/ }, async ($, e, next) => {
794    const result = await next(e)
795    if (result.deny === undefined) {
796      await refresh($, await $.session.cwd())
797    }
798    return result
799  })
800
801  on('tool.call', { tool: 'Bash', command: /\btasks\.md\b/ }, async ($, e, next) => {
802    const result = await next(e)
803    if (result.deny === undefined) {
804      await refresh($, await $.session.cwd())
805    }
806    return result
807  })
808
809  on('session.start', async ($, e, next) => {
810    startHealthRead($, await refresh($, e.cwd))
811    return next(e)
812  })
813
814  on('classic.CwdChanged', async ($, e, next) => {
815    await update($, workflowChangeAtom, () => null)
816    await update($, paneViewAtom, view => ({ ...view, pick: null }))
817    const context = await refresh($, e.new_cwd)
818    startHealthRead($, context)
819    await refreshOpenPane($, context)
820    return next(e)
821  })
822
823  on('turn.complete', async ($, e, next) => {
824    const result = await next(e)
825    if (e.agentId === undefined) {
826      const cwd = await $.session.cwd()
827      const previous = await read($, contextAtom)
828      const hasMoved = previous !== null && previous.cwd !== cwd
829      if (hasMoved) {
830        await update($, workflowChangeAtom, () => null)
831        await update($, paneViewAtom, view => ({ ...view, pick: null }))
832      }
833      const context = await refresh($, cwd)
834      if (hasMoved) startHealthRead($, context)
835      await refreshOpenPane($, context)
836    }
837    return result
838  })
839
840  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
841    if (e.requestId !== PANE) return next(e)
842    const { Box, Text, Button } = $.ui.resolve(e)
843    const [context, health, pane, view] = await Promise.all([
844      read($, contextAtom),
845      read($, healthAtom),
846      read($, paneAtom),
847      read($, paneViewAtom),
848    ])
849    return paneTree(
850      { Box, Text, Button },
851      {
852        context,
853        health,
854        pane,
855        view,
856        columns: e.props.bodyColumns,
857        onTab: async tab => {
858          await update($, paneViewAtom, current => ({ ...current, tab }))
859        },
860        onPick: async name => {
861          await update($, paneViewAtom, (): PaneView => ({ tab: 'change', pick: name }))
862          const current = await read($, contextAtom)
863          if (current !== null) await refreshPane($, current, { withSpecs: false })
864        },
865      },
866    )
867  })
868
869  on('ui.close', async ($, e, next) => {
870    const result = await next(e)
871    if (e.id === PANE) {
872      paneReads++
873      await update($, paneAtom, () => null)
874      await update($, paneViewAtom, () => FRESH_VIEW)
875    }
876    return result
877  })
878}
879
types/index.d.ts 104 lines
1export type ContextKind = 'local' | 'store' | 'unknown-store' | 'unusable-store' | 'none'
2
3export type ChangeSummary = {
4  name: string
5  completedTasks: number
6  totalTasks: number
7  lastModified: string
8  status: string
9}
10
11export type OpenSpecContext = {
12  kind: ContextKind
13  storeId?: string
14  fix?: string
15  /** The CLI's resolution error, in `unusable-store`. */
16  message?: string
17  code?: string
18  cwd: string
19  changes: ChangeSummary[]
20  /** The git branch of `cwd`, absent outside a repository or on a detached HEAD. */
21  branch?: string
22  /** The active change: the workflow's when listed, else the one named like `branch`. */
23  currentChange?: string
24}
25
26export type HealthFinding = {
27  severity: string
28  code: string
29  message: string
30  fix?: string
31  /** A few English words naming what is affected and how, for the status line. */
32  summary: string
33}
34
35export type OpenSpecHealth = {
36  /** The cwd `openspec doctor --json` ran in. */
37  cwd: string
38  /** The unhealthy findings, most important first. */
39  findings: HealthFinding[]
40  error?: string
41}
42
43/** A read of the CLI that failed, with the reason shown in the pane. */
44export type Failed = { error: string }
45
46export type SpecsSummary = { count: number; requirements: number }
47
48export type ArtifactState = { id: string; status: string }
49
50export type ChangeStatus = { schema: string; artifacts: ArtifactState[] }
51
52export type ChangeTask = { description: string; done: boolean }
53
54export type ShownChange = {
55  name: string
56  status: ChangeStatus | Failed
57  tasks: ChangeTask[] | Failed
58}
59
60export type PaneTab = 'overview' | 'change'
61
62export type PaneView = {
63  tab: PaneTab
64  /** The change picked in Overview, shown instead of the active one while it is listed. */
65  pick: string | null
66}
67
68export type PaneData = {
69  /** The cwd the pane's data was read in. */
70  cwd: string
71  /** `null` outside a local or store root, where nothing is read. */
72  specs: SpecsSummary | Failed | null
73  /** `null` when no change is picked or active. */
74  shown: ShownChange | null
75}
76
77export type ContextFill = {
78  /** Percent of the model's window, as `session.measure` reports it. */
79  percent: number
80  level: 'warning' | 'critical'
81}
82
83declare module 'claude-code' {
84  interface PluginState {
85    'openspec-status': {
86      /** `null` until the first refresh of the session, even a failed one. */
87      context: OpenSpecContext | null
88      /** The change last named by an OpenSpec workflow in this session, listed or not. */
89      workflowChange: string | null
90      lastError: string | null
91      /** `null` outside a local or store root, and until its first `openspec doctor --json`. */
92      health: OpenSpecHealth | null
93      /** Counts the health reads started; a read that ends after a newer one started is dropped. */
94      healthRead: number
95      /** `null` while the pane is closed. */
96      pane: PaneData | null
97      /** What the person chose in the pane; only presses, a close and a cwd change write it. */
98      paneView: PaneView
99      /** The main session's context fill once it reaches a level; `null` below every level. */
100      contextFill: ContextFill | null
101    }
102  }
103}
104