@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
|
@@ -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 {
|
|
@@ -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
|
-
//
|
|
385
|
-
//
|
|
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
|
-
'
|
|
394
|
-
|
|
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
|
|
824
|
-
//
|
|
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
|
-
|
|
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
|
|
46
|
-
total
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
42
|
-
total
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
...(
|
|
238
|
+
...(taskCounts ?? {}),
|
|
239
|
+
...extras(state),
|
|
189
240
|
};
|
|
190
241
|
}
|
|
191
242
|
if (!openspec) return null;
|
|
192
|
-
|
|
243
|
+
const taskCounts = counts(openspec);
|
|
244
|
+
return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
|
|
193
245
|
}
|
|
194
246
|
|
|
195
247
|
/**
|