@aemonge-dev/pi-stardeck 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -12,7 +12,7 @@ import type {
12
12
  } from './gauge.ts';
13
13
  import type { ResourceTelemetry } from './process-resources.ts';
14
14
  import { formatDeckCountdown } from './gauge.ts';
15
- import { formatDeckWorkLabel } from './title-state.ts';
15
+ import { deckWorkSegments } from './title-state.ts';
16
16
 
17
17
  export interface HeaderDeckModeRail {
18
18
  plain: string;
@@ -164,21 +164,40 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
164
164
  return lines.slice(0, maximumLines);
165
165
  }
166
166
 
167
+ function mirrorKey(value: string): string {
168
+ return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
169
+ }
170
+
171
+ function bandWrap(
172
+ content: string,
173
+ width: number,
174
+ theme: Theme,
175
+ contentColor: string,
176
+ ): string[] {
177
+ return wrapFocus(content, Math.max(1, width), 2).map((line) =>
178
+ color(theme, contentColor, line),
179
+ );
180
+ }
181
+
167
182
  function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): string[] {
168
- // Line 2 anchor: the clock always leads the band with its icon; the
169
- // Subject follows when the title state carries one, and a fresh session
170
- // (subject null) leaves the clock leading alone — no dangling separator.
171
- const clock = `${color(theme, 'customMessageLabel', '󰠭')} ${timerTile(state, theme)}`;
183
+ // Human amendment 2026-10-23: line 2 is sacred — Subject and the Work
184
+ // tile only; no timers, no icons, no chrome. The session clock is
185
+ // discarded; liveness lives in the turn cluster on the activity lane.
172
186
  const title = state.header?.title ?? null;
173
187
  if (title) {
174
188
  const subject = safeText(title.subject ?? '');
175
189
  if (!subject) {
176
190
  // Human amendment 2026-10-19: the empty slot renders its dim
177
191
  // placeholder — mirroring the Work side's `— no change owns the
178
- // moment` — instead of leaving the clock leading alone.
179
- return focusWrapLines('— no subject', clock, width, theme, 'dim');
192
+ // moment` — never an empty band while the session talks.
193
+ return bandWrap('— no subject', width, theme, 'dim');
180
194
  }
181
- return focusWrapLines(subject, clock, width, theme, 'accent');
195
+ // When the subject mirrors the focused change (the gate law's
196
+ // "subject mirrors its title"), it renders success-green like the
197
+ // PWD — the mirror visible at a glance.
198
+ const work = title.work;
199
+ const mirrorsFocus = work !== null && mirrorKey(subject) === mirrorKey(work.change);
200
+ return bandWrap(subject, width, theme, mirrorsFocus ? 'success' : 'accent');
182
201
  }
183
202
  // Pre-title publishers: the legacy selection keeps the band alive.
184
203
  const selection = state.header?.selection;
@@ -187,35 +206,16 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
187
206
  .filter(Boolean);
188
207
  if (!selection || titles.length === 0) {
189
208
  if (!state.header) {
190
- // Frozen contract: a publisher that reports nothing at all keeps
191
- // the collapsed frame — clock alone, no placeholder dashes.
192
- return [clock];
209
+ // Frozen contract (amended 2026-10-23): a publisher that reports
210
+ // nothing keeps the collapsed frame — an empty band, no dashes.
211
+ return [''];
193
212
  }
194
- // Amendment: a live session (header present, Work rendering) that
195
- // simply has no subject gets the dim placeholder — the title slot
196
- // never renders empty when the session is talking to us.
197
- return focusWrapLines('— no subject', clock, width, theme, 'dim');
213
+ return bandWrap('— no subject', width, theme, 'dim');
198
214
  }
199
215
  const selectionColor = ['accent', 'success'].includes(selection.color)
200
216
  ? 'accent'
201
217
  : selection.color;
202
- return focusWrapLines(titles.join(' › '), clock, width, theme, selectionColor);
203
- }
204
-
205
- function focusWrapLines(
206
- content: string,
207
- clock: string,
208
- width: number,
209
- theme: Theme,
210
- contentColor: string,
211
- ): string[] {
212
- const lead = `${clock} ${semanticSeparator(theme)} `;
213
- const indent = ' '.repeat(visibleWidth(lead));
214
- const contentWidth = Math.max(1, width - visibleWidth(lead));
215
- return wrapFocus(content, contentWidth, 2).map(
216
- (line, index) =>
217
- `${index === 0 ? lead : indent}${color(theme, contentColor, line)}`,
218
- );
218
+ return bandWrap(titles.join(' › '), width, theme, selectionColor);
219
219
  }
220
220
 
221
221
  function semanticSeparator(theme: Theme): string {
@@ -246,22 +246,9 @@ function workTile(state: HeaderDeckState, theme: Theme): string {
246
246
  if (!title) return '';
247
247
  const work = title.work;
248
248
  if (!work) return color(theme, 'dim', '— no change owns the moment');
249
- return color(
250
- theme,
251
- work.total > 0 && work.done >= work.total ? 'success' : 'text',
252
- formatDeckWorkLabel(work),
253
- );
254
- }
255
-
256
- // Relocation phase (Human-directed): the clock leads the title band beside
257
- // the anchor, runs ride the lifecycle row, and Work keeps the title's
258
- // right end — real values when live, the dim hint when idle.
259
- function timerTile(state: HeaderDeckState, theme: Theme): string {
260
- const elapsed = state.elapsedMs;
261
- const idle = state.idleMs;
262
- return elapsed != null
263
- ? color(theme, 'accent', formatDeckElapsed(elapsed))
264
- : color(theme, 'dim', formatDeckElapsed(idle ?? 0));
249
+ return deckWorkSegments(work)
250
+ .map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
251
+ .join('');
265
252
  }
266
253
 
267
254
  function spanElapsed(ms: number): string {
@@ -370,29 +357,22 @@ function activityTile(state: HeaderDeckState, theme: Theme): string {
370
357
  return label ? color(theme, 'text', label) : color(theme, 'dim', 'awaiting Human');
371
358
  }
372
359
 
373
- function formatTurnElapsed(ms: number): string {
374
- // Matches the turn meter's span-idle ceiling: 99m59s999ms → `99:59`.
375
- const bounded = Math.min(
376
- (99 * 60 + 59) * 1_000 + 999,
377
- Math.max(0, Math.floor(Number.isFinite(ms) ? ms : 0)),
378
- );
379
- const totalSeconds = Math.floor(bounded / 1_000);
380
- return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
381
- }
382
-
383
360
  function turnCostTile(state: HeaderDeckState, theme: Theme): string {
384
- // Line 3 right: the per-turn cost meter. Idle renders the accepted
385
- // `— · 0 tok`; pre-title publishers (no meter) stay silent.
361
+ // Activity lane right: the turn cluster — turn time and tokens in one
362
+ // aligned group (Human amendment 2026-10-23: the deck's only timer;
363
+ // Human amendment 2026-10-24: the liveness icon is gone — the ticking
364
+ // chronometer `mmm:ss'cc` is the liveness). Idle keeps the accepted
365
+ // dim `— · 0 tok`; the quiet-activity-row contract depends on it.
386
366
  const turn = state.header?.turn ?? null;
387
367
  if (!turn) return '';
388
368
  if (turn.tokens <= 0 && turn.elapsedMs <= 0) {
389
369
  return color(theme, 'dim', '— · 0 tok');
390
370
  }
391
- return color(
371
+ return `${color(theme, 'warning', formatDeckElapsed(turn.elapsedMs), true)} ${color(
392
372
  theme,
393
- 'accent',
394
- `this turn ${formatTokens(turn.tokens)} · ${formatTurnElapsed(turn.elapsedMs)}`,
395
- );
373
+ 'dim',
374
+ '·',
375
+ )} ${color(theme, 'warning', formatTokens(turn.tokens))}`;
396
376
  }
397
377
 
398
378
  function gitRight(state: HeaderDeckState, theme: Theme): string {
@@ -820,9 +800,9 @@ export function renderHeaderDeck(
820
800
  const lineColor = 'thinkingHigh';
821
801
  const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
822
802
  const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
823
- // Line 2 (four-line deck): the clock and Subject lead with Work on the
824
- // right end. Narrow frames: the Work slot yields before the clock anchor
825
- // does.
803
+ // Line 2 (four-line deck): the Subject leads with Work on the right end
804
+ // (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
805
+ // Work slot yields before the Subject does.
826
806
  const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
827
807
  const focusLines = titleBandLines(state, boundedWidth, theme);
828
808
  const titleRows = [
@@ -24,8 +24,10 @@ export const DECK_TITLE_ACTIVITY_CLASSES = [
24
24
 
25
25
  export type DeckTitleActivityClass = (typeof DECK_TITLE_ACTIVITY_CLASSES)[number];
26
26
 
27
- /** Short target label bound from the accepted schema. */
28
- export const DECK_TITLE_TARGET_MAX = 48;
27
+ /** Short target label bound from the accepted schema (Human amendment
28
+ * 2026-10-24: 48 → 120, mirroring the status producer — the deck's
29
+ * render-time width crop owns the real limit now). */
30
+ export const DECK_TITLE_TARGET_MAX = 120;
29
31
 
30
32
  /** Task/step totals bound from the accepted schema. */
31
33
  export const DECK_TITLE_TOTAL_MAX = 999;
@@ -42,8 +44,10 @@ const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
42
44
  export type DeckTitleWorkState = {
43
45
  change: string;
44
46
  task?: string;
45
- done: number;
46
- total: number;
47
+ done?: number;
48
+ total?: number;
49
+ schemaName?: string;
50
+ ladder?: { done: number; total: number; rung?: string };
47
51
  };
48
52
 
49
53
  export type DeckTitleActivityState = {
@@ -116,12 +120,47 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
116
120
  if (value === null) return null;
117
121
  if (!isRecord(value)) return undefined;
118
122
  const change = boundedText(value.change, IDENTIFIER_MAX);
119
- const done = boundedTotal(value.done);
120
- const total = boundedTotal(value.total);
121
- if (!change || done === null || total === null) return undefined;
123
+ if (!change) return undefined;
124
+ // Counts are optional as a pair: absent means unknown (never fabricated
125
+ // zeros); present-but-invalid rejects the object.
126
+ const hasCounts = 'done' in value && 'total' in value;
127
+ if ('done' in value !== 'total' in value) return undefined;
128
+ const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
129
+ const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
130
+ if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
131
+ return undefined;
132
+ }
122
133
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
123
134
  if (task === null) return undefined;
124
- return task === undefined ? { change, done, total } : { change, task, done, total };
135
+ const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
136
+ required: false,
137
+ });
138
+ if (schemaName === null) return undefined;
139
+ let ladder: { done: number; total: number; rung?: string } | undefined;
140
+ if ('ladder' in value) {
141
+ if (!isRecord(value.ladder)) return undefined;
142
+ const ladderDone = boundedTotal(value.ladder.done);
143
+ const ladderTotal = boundedTotal(value.ladder.total);
144
+ if (ladderDone === null || ladderTotal === null) return undefined;
145
+ const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
146
+ if (ladderRung === null) return undefined;
147
+ ladder = {
148
+ done: ladderDone,
149
+ total: ladderTotal,
150
+ ...(ladderRung ? { rung: ladderRung } : {}),
151
+ };
152
+ }
153
+ const counts =
154
+ typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
155
+ ? { done: boundedDone, total: boundedTotalValue }
156
+ : {};
157
+ const extras = {
158
+ ...(schemaName ? { schemaName } : {}),
159
+ ...(ladder ? { ladder } : {}),
160
+ };
161
+ return task === undefined
162
+ ? { change, ...counts, ...extras }
163
+ : { change, task, ...counts, ...extras };
125
164
  }
126
165
 
127
166
  function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
@@ -189,7 +228,46 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
189
228
  }
190
229
  }
191
230
 
192
- /** `change · done/total` — the Work label from the accepted mockups. */
231
+ /** One colored piece of the Work label. */
232
+ export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
233
+
234
+ /**
235
+ * The Work label as colored segments — the human-chosen design: `change ⟩
236
+ * done/total rung › 󰄱 done/total` in one hue per state (accent, success at
237
+ * complete with a filled checkbox and bold counts). The rung name comes
238
+ * straight from OpenSpec's status artifacts — never an invented phase
239
+ * word. Separators are chrome with hard deck-wide colors — `⟩`
240
+ * thinkingHigh-bold like every semantic separator, `›` accent like every
241
+ * minor one. Unknown facts degrade honestly; the bare change is the floor.
242
+ */
243
+ export function deckWorkSegments(work: DeckTitleWorkState): DeckWorkSegment[] {
244
+ const complete =
245
+ work.done !== undefined &&
246
+ work.total !== undefined &&
247
+ work.total > 0 &&
248
+ work.done >= work.total;
249
+ const base = complete ? 'success' : 'accent';
250
+ const segments: DeckWorkSegment[] = [{ text: work.change, color: base }];
251
+ const ladder = work.ladder;
252
+ if (ladder) {
253
+ segments.push({ text: ' ⟩ ', color: 'thinkingHigh', bold: true });
254
+ const group = `${ladder.done}/${ladder.total}${ladder.rung ? ` ${ladder.rung}` : ''}`;
255
+ segments.push({ text: group, color: base });
256
+ }
257
+ if (work.done !== undefined && work.total !== undefined) {
258
+ segments.push({ text: ' › ', color: 'accent' });
259
+ segments.push({
260
+ text: `${complete ? '✔' : '󰄱'} ${work.done}/${work.total}`,
261
+ color: base,
262
+ ...(complete ? { bold: true } : {}),
263
+ });
264
+ }
265
+ return segments;
266
+ }
267
+
268
+ /** The plain-text Work label — the segment join without colors. */
193
269
  export function formatDeckWorkLabel(work: DeckTitleWorkState): string {
194
- return `${work.change} · ${work.done}/${work.total}`;
270
+ return deckWorkSegments(work)
271
+ .map((segment) => segment.text)
272
+ .join('');
195
273
  }
@@ -204,12 +204,19 @@ export function parseApplyInstructions(value: unknown): ApplyInstructionsResult
204
204
  );
205
205
  }
206
206
 
207
- const progress = isRecord(value.progress)
207
+ const state = typeof value.state === 'string' ? value.state : undefined;
208
+ const reportedProgress = isRecord(value.progress)
208
209
  ? {
209
210
  total: finiteNumber(value.progress.total),
210
211
  complete: finiteNumber(value.progress.complete),
211
212
  }
212
213
  : undefined;
214
+ // A blocked apply (mid-ladder, artifacts missing) reports zero progress;
215
+ // those zeros must never shadow the honest `openspec list` counts.
216
+ const progress =
217
+ state !== 'blocked' && (reportedProgress?.total ?? 0) > 0
218
+ ? reportedProgress
219
+ : undefined;
213
220
 
214
221
  return { tasks, tasksPath, progress };
215
222
  }
@@ -227,6 +234,33 @@ function chooseChange(
227
234
  return selected;
228
235
  }
229
236
 
237
+ function parseOpenSpecStatus(value: unknown): {
238
+ schemaName?: string;
239
+ ladder?: { done: number; total: number; rung?: string };
240
+ } {
241
+ if (!isRecord(value)) return {};
242
+ const schemaName =
243
+ typeof value.schemaName === 'string' && value.schemaName.trim()
244
+ ? value.schemaName.trim()
245
+ : undefined;
246
+ let ladder: { done: number; total: number; rung?: string } | undefined;
247
+ if (Array.isArray(value.artifacts) && value.artifacts.length > 0) {
248
+ let done = 0;
249
+ let rung: string | undefined;
250
+ for (const artifact of value.artifacts) {
251
+ if (!isRecord(artifact)) continue;
252
+ const id = typeof artifact.id === 'string' ? artifact.id : undefined;
253
+ if (rung === undefined && id) rung = id;
254
+ if (artifact.status === 'done') {
255
+ done += 1;
256
+ if (id) rung = id;
257
+ }
258
+ }
259
+ ladder = { done, total: value.artifacts.length, ...(rung ? { rung } : {}) };
260
+ }
261
+ return { ...(schemaName ? { schemaName } : {}), ...(ladder ? { ladder } : {}) };
262
+ }
263
+
230
264
  async function runJson(
231
265
  runner: CommandRunner,
232
266
  args: string[],
@@ -326,6 +360,23 @@ export async function collectOpenSpec(options: {
326
360
  // The list result is still useful when an optional title lookup fails.
327
361
  }
328
362
 
363
+ let schemaName: string | undefined;
364
+ let ladder: OpenSpecState['ladder'];
365
+ try {
366
+ const status = parseOpenSpecStatus(
367
+ await runJson(
368
+ options.runner,
369
+ ['status', '--change', change.name, '--json'],
370
+ projectRoot,
371
+ 'openspec status',
372
+ ),
373
+ );
374
+ schemaName = status.schemaName;
375
+ ladder = status.ladder;
376
+ } catch {
377
+ // Ladder facts are optional; the list counts remain valid without them.
378
+ }
379
+
329
380
  let tasks: OpenSpecTask[] = [];
330
381
  let tasksPath: string | undefined;
331
382
  let instructionProgress: ApplyInstructionsResult['progress'];
@@ -383,6 +434,8 @@ export async function collectOpenSpec(options: {
383
434
  allTasks: tasks,
384
435
  ...(tasksPath ? { tasksPath } : {}),
385
436
  ...(hierarchy ? { hierarchy } : {}),
437
+ ...(schemaName ? { schemaName } : {}),
438
+ ...(ladder ? { ladder } : {}),
386
439
  meta: {
387
440
  source: 'openspec-cli',
388
441
  refreshedAt: now,
@@ -22,8 +22,11 @@ export const TITLE_ACTIVITY_CLASSES = [
22
22
 
23
23
  export type TitleActivityClass = (typeof TITLE_ACTIVITY_CLASSES)[number];
24
24
 
25
- /** Short target label bound from the accepted schema. */
26
- export const TITLE_TARGET_MAX = 48;
25
+ /** Short target label bound from the accepted schema (Human amendment
26
+ * 2026-10-24: 48 → 120 — the deck's activity lane now owns the full
27
+ * width beside the turn cluster, so the protocol bound yields to the
28
+ * render-time crop instead of pre-cutting subagent/bash labels). */
29
+ export const TITLE_TARGET_MAX = 120;
27
30
 
28
31
  /** Task/step totals bound from the accepted schema. */
29
32
  export const TITLE_TOTAL_MAX = 999;
@@ -38,8 +41,10 @@ const NOTICE_MAX = 180;
38
41
  export type TitleWorkState = {
39
42
  change: string;
40
43
  task?: string;
41
- done: number;
42
- total: number;
44
+ done?: number;
45
+ total?: number;
46
+ schemaName?: string;
47
+ ladder?: { done: number; total: number; rung?: string };
43
48
  };
44
49
 
45
50
  export type TitleActivityState = {
@@ -109,12 +114,47 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
109
114
  if (value === null) return null;
110
115
  if (!isRecord(value)) return undefined;
111
116
  const change = boundedText(value.change, IDENTIFIER_MAX);
112
- const done = boundedTotal(value.done);
113
- const total = boundedTotal(value.total);
114
- if (!change || done === null || total === null) return undefined;
117
+ if (!change) return undefined;
118
+ // Counts are optional as a pair: absent means unknown (never fabricated
119
+ // zeros); present-but-invalid rejects the object.
120
+ const hasCounts = 'done' in value && 'total' in value;
121
+ if ('done' in value !== 'total' in value) return undefined;
122
+ const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
123
+ const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
124
+ if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
125
+ return undefined;
126
+ }
115
127
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
116
128
  if (task === null) return undefined;
117
- return task === undefined ? { change, done, total } : { change, task, done, total };
129
+ const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
130
+ required: false,
131
+ });
132
+ if (schemaName === null) return undefined;
133
+ let ladder: { done: number; total: number; rung?: string } | undefined;
134
+ if ('ladder' in value) {
135
+ if (!isRecord(value.ladder)) return undefined;
136
+ const ladderDone = boundedTotal(value.ladder.done);
137
+ const ladderTotal = boundedTotal(value.ladder.total);
138
+ if (ladderDone === null || ladderTotal === null) return undefined;
139
+ const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
140
+ if (ladderRung === null) return undefined;
141
+ ladder = {
142
+ done: ladderDone,
143
+ total: ladderTotal,
144
+ ...(ladderRung ? { rung: ladderRung } : {}),
145
+ };
146
+ }
147
+ const counts =
148
+ typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
149
+ ? { done: boundedDone, total: boundedTotalValue }
150
+ : {};
151
+ const extras = {
152
+ ...(schemaName ? { schemaName } : {}),
153
+ ...(ladder ? { ladder } : {}),
154
+ };
155
+ return task === undefined
156
+ ? { change, ...counts, ...extras }
157
+ : { change, task, ...counts, ...extras };
118
158
  }
119
159
 
120
160
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -128,7 +168,7 @@ function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
128
168
 
129
169
  /**
130
170
  * Defensive parser for incoming title-state payloads. Unknown versions,
131
- * malformed shapes, and out-of-bounds values (target > 48 characters,
171
+ * malformed shapes, and out-of-bounds values (target > 120 characters,
132
172
  * totals > 999) reject the whole object with `null`; it never throws.
133
173
  */
134
174
  export function parseTitleState(value: unknown): TitleState | null {
@@ -180,16 +220,28 @@ export function deriveTitleWork(
180
220
  done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
181
221
  total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
182
222
  });
223
+ // Human steering 2026-10-23: no invented phase words — the ladder's
224
+ // rung name comes straight from OpenSpec.
225
+ const extras = (state: OpenSpecState | undefined) => {
226
+ if (!state) return {};
227
+ return {
228
+ ...(state.schemaName ? { schemaName: state.schemaName } : {}),
229
+ ...(state.ladder ? { ladder: state.ladder } : {}),
230
+ };
231
+ };
183
232
  if (focus.mode === 'task') {
184
233
  const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
234
+ const taskCounts = state ? counts(state) : undefined;
185
235
  return {
186
236
  change: focus.change,
187
237
  task: focus.taskId,
188
- ...(state ? counts(state) : { done: 0, total: 0 }),
238
+ ...(taskCounts ?? {}),
239
+ ...extras(state),
189
240
  };
190
241
  }
191
242
  if (!openspec) return null;
192
- return { change: openspec.changeId, ...counts(openspec) };
243
+ const taskCounts = counts(openspec);
244
+ return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
193
245
  }
194
246
 
195
247
  /**
@@ -89,6 +89,8 @@ export interface OpenSpecState {
89
89
  allTasks?: OpenSpecTask[];
90
90
  tasksPath?: string;
91
91
  hierarchy?: OpenSpecHierarchyProgress;
92
+ schemaName?: string;
93
+ ladder?: { done: number; total: number; rung?: string };
92
94
  meta: SourceMeta;
93
95
  }
94
96