@aemonge-dev/pi-stardeck 0.9.2 → 0.9.3
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
|
@@ -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 {
|
|
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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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` —
|
|
179
|
-
return
|
|
192
|
+
// moment` — never an empty band while the session talks.
|
|
193
|
+
return bandWrap('— no subject', width, theme, 'dim');
|
|
180
194
|
}
|
|
181
|
-
|
|
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
|
|
191
|
-
// the collapsed frame —
|
|
192
|
-
return [
|
|
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
|
-
|
|
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
|
|
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
|
|
250
|
-
theme,
|
|
251
|
-
|
|
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 {
|
|
@@ -381,18 +368,21 @@ function formatTurnElapsed(ms: number): string {
|
|
|
381
368
|
}
|
|
382
369
|
|
|
383
370
|
function turnCostTile(state: HeaderDeckState, theme: Theme): string {
|
|
384
|
-
//
|
|
385
|
-
//
|
|
371
|
+
// Activity lane right: the turn cluster — liveness icon, turn time,
|
|
372
|
+
// and tokens in one aligned group (Human amendment 2026-10-23: the
|
|
373
|
+
// deck's only timer). Idle keeps the accepted dim `— · 0 tok`; the
|
|
374
|
+
// quiet-activity-row contract depends on it.
|
|
386
375
|
const turn = state.header?.turn ?? null;
|
|
387
376
|
if (!turn) return '';
|
|
388
377
|
if (turn.tokens <= 0 && turn.elapsedMs <= 0) {
|
|
389
378
|
return color(theme, 'dim', '— · 0 tok');
|
|
390
379
|
}
|
|
391
|
-
return color(
|
|
380
|
+
return `${color(theme, 'customMessageLabel', '')} ${color(
|
|
392
381
|
theme,
|
|
393
|
-
'
|
|
394
|
-
|
|
395
|
-
|
|
382
|
+
'warning',
|
|
383
|
+
formatTurnElapsed(turn.elapsedMs),
|
|
384
|
+
true,
|
|
385
|
+
)} ${color(theme, 'dim', '·')} ${color(theme, 'warning', formatTokens(turn.tokens))}`;
|
|
396
386
|
}
|
|
397
387
|
|
|
398
388
|
function gitRight(state: HeaderDeckState, theme: Theme): string {
|
|
@@ -820,9 +810,9 @@ export function renderHeaderDeck(
|
|
|
820
810
|
const lineColor = 'thinkingHigh';
|
|
821
811
|
const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
|
|
822
812
|
const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
|
|
823
|
-
// Line 2 (four-line deck): the
|
|
824
|
-
//
|
|
825
|
-
// does.
|
|
813
|
+
// Line 2 (four-line deck): the Subject leads with Work on the right end
|
|
814
|
+
// (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
|
|
815
|
+
// Work slot yields before the Subject does.
|
|
826
816
|
const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
|
|
827
817
|
const focusLines = titleBandLines(state, boundedWidth, theme);
|
|
828
818
|
const titleRows = [
|
|
@@ -42,8 +42,10 @@ const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
|
42
42
|
export type DeckTitleWorkState = {
|
|
43
43
|
change: string;
|
|
44
44
|
task?: string;
|
|
45
|
-
done
|
|
46
|
-
total
|
|
45
|
+
done?: number;
|
|
46
|
+
total?: number;
|
|
47
|
+
schemaName?: string;
|
|
48
|
+
ladder?: { done: number; total: number; rung?: string };
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
export type DeckTitleActivityState = {
|
|
@@ -116,12 +118,47 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
116
118
|
if (value === null) return null;
|
|
117
119
|
if (!isRecord(value)) return undefined;
|
|
118
120
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
if (!change) return undefined;
|
|
122
|
+
// Counts are optional as a pair: absent means unknown (never fabricated
|
|
123
|
+
// zeros); present-but-invalid rejects the object.
|
|
124
|
+
const hasCounts = 'done' in value && 'total' in value;
|
|
125
|
+
if ('done' in value !== 'total' in value) return undefined;
|
|
126
|
+
const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
|
|
127
|
+
const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
|
|
128
|
+
if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
122
131
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
123
132
|
if (task === null) return undefined;
|
|
124
|
-
|
|
133
|
+
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
134
|
+
required: false,
|
|
135
|
+
});
|
|
136
|
+
if (schemaName === null) return undefined;
|
|
137
|
+
let ladder: { done: number; total: number; rung?: string } | undefined;
|
|
138
|
+
if ('ladder' in value) {
|
|
139
|
+
if (!isRecord(value.ladder)) return undefined;
|
|
140
|
+
const ladderDone = boundedTotal(value.ladder.done);
|
|
141
|
+
const ladderTotal = boundedTotal(value.ladder.total);
|
|
142
|
+
if (ladderDone === null || ladderTotal === null) return undefined;
|
|
143
|
+
const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
|
|
144
|
+
if (ladderRung === null) return undefined;
|
|
145
|
+
ladder = {
|
|
146
|
+
done: ladderDone,
|
|
147
|
+
total: ladderTotal,
|
|
148
|
+
...(ladderRung ? { rung: ladderRung } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const counts =
|
|
152
|
+
typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
|
|
153
|
+
? { done: boundedDone, total: boundedTotalValue }
|
|
154
|
+
: {};
|
|
155
|
+
const extras = {
|
|
156
|
+
...(schemaName ? { schemaName } : {}),
|
|
157
|
+
...(ladder ? { ladder } : {}),
|
|
158
|
+
};
|
|
159
|
+
return task === undefined
|
|
160
|
+
? { change, ...counts, ...extras }
|
|
161
|
+
: { change, task, ...counts, ...extras };
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
|
|
@@ -189,7 +226,46 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
189
226
|
}
|
|
190
227
|
}
|
|
191
228
|
|
|
192
|
-
/**
|
|
229
|
+
/** One colored piece of the Work label. */
|
|
230
|
+
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The Work label as colored segments — the human-chosen design: `change ⟩
|
|
234
|
+
* done/total rung › done/total` in one hue per state (accent, success at
|
|
235
|
+
* complete with a filled checkbox and bold counts). The rung name comes
|
|
236
|
+
* straight from OpenSpec's status artifacts — never an invented phase
|
|
237
|
+
* word. Separators are chrome with hard deck-wide colors — `⟩`
|
|
238
|
+
* thinkingHigh-bold like every semantic separator, `›` accent like every
|
|
239
|
+
* minor one. Unknown facts degrade honestly; the bare change is the floor.
|
|
240
|
+
*/
|
|
241
|
+
export function deckWorkSegments(work: DeckTitleWorkState): DeckWorkSegment[] {
|
|
242
|
+
const complete =
|
|
243
|
+
work.done !== undefined &&
|
|
244
|
+
work.total !== undefined &&
|
|
245
|
+
work.total > 0 &&
|
|
246
|
+
work.done >= work.total;
|
|
247
|
+
const base = complete ? 'success' : 'accent';
|
|
248
|
+
const segments: DeckWorkSegment[] = [{ text: work.change, color: base }];
|
|
249
|
+
const ladder = work.ladder;
|
|
250
|
+
if (ladder) {
|
|
251
|
+
segments.push({ text: ' ⟩ ', color: 'thinkingHigh', bold: true });
|
|
252
|
+
const group = `${ladder.done}/${ladder.total}${ladder.rung ? ` ${ladder.rung}` : ''}`;
|
|
253
|
+
segments.push({ text: group, color: base });
|
|
254
|
+
}
|
|
255
|
+
if (work.done !== undefined && work.total !== undefined) {
|
|
256
|
+
segments.push({ text: ' › ', color: 'accent' });
|
|
257
|
+
segments.push({
|
|
258
|
+
text: `${complete ? '✔' : ''} ${work.done}/${work.total}`,
|
|
259
|
+
color: base,
|
|
260
|
+
...(complete ? { bold: true } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return segments;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The plain-text Work label — the segment join without colors. */
|
|
193
267
|
export function formatDeckWorkLabel(work: DeckTitleWorkState): string {
|
|
194
|
-
return
|
|
268
|
+
return deckWorkSegments(work)
|
|
269
|
+
.map((segment) => segment.text)
|
|
270
|
+
.join('');
|
|
195
271
|
}
|
|
@@ -204,12 +204,19 @@ export function parseApplyInstructions(value: unknown): ApplyInstructionsResult
|
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
const
|
|
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,
|
|
@@ -38,8 +38,10 @@ const NOTICE_MAX = 180;
|
|
|
38
38
|
export type TitleWorkState = {
|
|
39
39
|
change: string;
|
|
40
40
|
task?: string;
|
|
41
|
-
done
|
|
42
|
-
total
|
|
41
|
+
done?: number;
|
|
42
|
+
total?: number;
|
|
43
|
+
schemaName?: string;
|
|
44
|
+
ladder?: { done: number; total: number; rung?: string };
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
export type TitleActivityState = {
|
|
@@ -109,12 +111,47 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
109
111
|
if (value === null) return null;
|
|
110
112
|
if (!isRecord(value)) return undefined;
|
|
111
113
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
if (!change) return undefined;
|
|
115
|
+
// Counts are optional as a pair: absent means unknown (never fabricated
|
|
116
|
+
// zeros); present-but-invalid rejects the object.
|
|
117
|
+
const hasCounts = 'done' in value && 'total' in value;
|
|
118
|
+
if ('done' in value !== 'total' in value) return undefined;
|
|
119
|
+
const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
|
|
120
|
+
const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
|
|
121
|
+
if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
115
124
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
116
125
|
if (task === null) return undefined;
|
|
117
|
-
|
|
126
|
+
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
127
|
+
required: false,
|
|
128
|
+
});
|
|
129
|
+
if (schemaName === null) return undefined;
|
|
130
|
+
let ladder: { done: number; total: number; rung?: string } | undefined;
|
|
131
|
+
if ('ladder' in value) {
|
|
132
|
+
if (!isRecord(value.ladder)) return undefined;
|
|
133
|
+
const ladderDone = boundedTotal(value.ladder.done);
|
|
134
|
+
const ladderTotal = boundedTotal(value.ladder.total);
|
|
135
|
+
if (ladderDone === null || ladderTotal === null) return undefined;
|
|
136
|
+
const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
|
|
137
|
+
if (ladderRung === null) return undefined;
|
|
138
|
+
ladder = {
|
|
139
|
+
done: ladderDone,
|
|
140
|
+
total: ladderTotal,
|
|
141
|
+
...(ladderRung ? { rung: ladderRung } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const counts =
|
|
145
|
+
typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
|
|
146
|
+
? { done: boundedDone, total: boundedTotalValue }
|
|
147
|
+
: {};
|
|
148
|
+
const extras = {
|
|
149
|
+
...(schemaName ? { schemaName } : {}),
|
|
150
|
+
...(ladder ? { ladder } : {}),
|
|
151
|
+
};
|
|
152
|
+
return task === undefined
|
|
153
|
+
? { change, ...counts, ...extras }
|
|
154
|
+
: { change, task, ...counts, ...extras };
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
@@ -180,16 +217,28 @@ export function deriveTitleWork(
|
|
|
180
217
|
done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
|
|
181
218
|
total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
|
|
182
219
|
});
|
|
220
|
+
// Human steering 2026-10-23: no invented phase words — the ladder's
|
|
221
|
+
// rung name comes straight from OpenSpec.
|
|
222
|
+
const extras = (state: OpenSpecState | undefined) => {
|
|
223
|
+
if (!state) return {};
|
|
224
|
+
return {
|
|
225
|
+
...(state.schemaName ? { schemaName: state.schemaName } : {}),
|
|
226
|
+
...(state.ladder ? { ladder: state.ladder } : {}),
|
|
227
|
+
};
|
|
228
|
+
};
|
|
183
229
|
if (focus.mode === 'task') {
|
|
184
230
|
const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
|
|
231
|
+
const taskCounts = state ? counts(state) : undefined;
|
|
185
232
|
return {
|
|
186
233
|
change: focus.change,
|
|
187
234
|
task: focus.taskId,
|
|
188
|
-
...(
|
|
235
|
+
...(taskCounts ?? {}),
|
|
236
|
+
...extras(state),
|
|
189
237
|
};
|
|
190
238
|
}
|
|
191
239
|
if (!openspec) return null;
|
|
192
|
-
|
|
240
|
+
const taskCounts = counts(openspec);
|
|
241
|
+
return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
|
|
193
242
|
}
|
|
194
243
|
|
|
195
244
|
/**
|