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.

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.

/opsx workflows and the git branch, for a local openspec/ root or a declared store (Which change is active).openspec doctor finding at the end of the line, such as a store behind its upstream (Health).store: line is invalid, or the store cannot be used (When the declared store cannot be used)./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.
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" } }
PATH.git, for the current branch.claude plugin validate .claude-plugin/plugin.json from the plugin folder after an update.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;/opsx:* command, when it is the name of a change.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.
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 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):
| Cause | Status line |
|---|---|
| The store is not registered on this machine | openspec store not registered |
The store: line cannot be read | openspec store: line invalid |
| Any other failure, such as a deleted or damaged clone, or mismatched store identity | openspec 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.
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.
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.
/openspecReads the changes again, updates the status line, and answers with a one-line summary:
| Context | Answer |
|---|---|
| Local root | openspec: local, 30 active changes |
| Declared store | openspec: store:team-plans, 12 active changes |
| Store declared but not registered on this machine | openspec: unknown store, <the fix the CLI suggests> |
Store declared but unusable, or store: line invalid | openspec: 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 viewReads 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.
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.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.
openspec CLI, it shows nothing but the context warning: no OpenSpec line, no command. A stale global defaultStore does not change that.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.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.
.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.
affected_areas).hooks/register.tsx 879 lines1import { 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}
879types/index.d.ts 104 lines1export 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