@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.0

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -2
  3. package/demo/pi-stardeck.tape +59 -0
  4. package/docs/architecture.md +103 -0
  5. package/docs/assets/pi-stardeck.gif +0 -0
  6. package/docs/development.md +128 -0
  7. package/licenses/README.md +17 -0
  8. package/licenses/pi-fancy-footer-LICENSE +21 -0
  9. package/licenses/pi-vim-LICENSE +21 -0
  10. package/package.json +81 -4
  11. package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
  12. package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
  13. package/packages/pi-fancy-footer-full-palette/README.md +468 -0
  14. package/packages/pi-fancy-footer-full-palette/package.json +52 -0
  15. package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
  16. package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
  17. package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
  18. package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
  19. package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
  20. package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
  21. package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
  22. package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
  23. package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
  24. package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
  25. package/packages/pi-stardeck/README.md +219 -0
  26. package/packages/pi-stardeck/index.ts +691 -0
  27. package/packages/pi-stardeck/package.json +32 -0
  28. package/packages/pi-stardeck/src/activity-age.ts +184 -0
  29. package/packages/pi-stardeck/src/capabilities.ts +105 -0
  30. package/packages/pi-stardeck/src/deck.ts +807 -0
  31. package/packages/pi-stardeck/src/gauge.ts +1323 -0
  32. package/packages/pi-stardeck/src/git-refresh.ts +5 -0
  33. package/packages/pi-stardeck/src/process-resources.ts +428 -0
  34. package/packages/pi-stardeck/src/title-state.ts +195 -0
  35. package/packages/pi-stardeck/src/title.ts +443 -0
  36. package/packages/pi-vim-top-border/LICENSE +21 -0
  37. package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
  38. package/packages/pi-vim-top-border/README.md +577 -0
  39. package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
  40. package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
  41. package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
  42. package/packages/pi-vim-top-border/index.ts +4195 -0
  43. package/packages/pi-vim-top-border/input-keys.ts +49 -0
  44. package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
  45. package/packages/pi-vim-top-border/mode-colors.ts +97 -0
  46. package/packages/pi-vim-top-border/mode-label.ts +62 -0
  47. package/packages/pi-vim-top-border/motions.ts +297 -0
  48. package/packages/pi-vim-top-border/package.json +47 -0
  49. package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
  50. package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
  51. package/packages/pi-vim-top-border/prompt-status.ts +131 -0
  52. package/packages/pi-vim-top-border/settings.ts +287 -0
  53. package/packages/pi-vim-top-border/text-objects.ts +389 -0
  54. package/packages/pi-vim-top-border/types.ts +51 -0
  55. package/packages/pi-vim-top-border/visual.ts +65 -0
  56. package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
  57. package/packages/status/README.md +398 -0
  58. package/packages/status/index.ts +1717 -0
  59. package/packages/status/package.json +33 -0
  60. package/packages/status/src/active-work.ts +235 -0
  61. package/packages/status/src/config.ts +218 -0
  62. package/packages/status/src/diagnostics.ts +238 -0
  63. package/packages/status/src/goal-block.ts +377 -0
  64. package/packages/status/src/goal.ts +170 -0
  65. package/packages/status/src/ledger.ts +101 -0
  66. package/packages/status/src/openspec.ts +394 -0
  67. package/packages/status/src/orchestration.ts +237 -0
  68. package/packages/status/src/publisher.ts +1294 -0
  69. package/packages/status/src/reducer.ts +190 -0
  70. package/packages/status/src/runtime-runs.ts +267 -0
  71. package/packages/status/src/state.ts +178 -0
  72. package/packages/status/src/title-activity.ts +224 -0
  73. package/packages/status/src/title-state.ts +218 -0
  74. package/packages/status/src/turn-cost.ts +63 -0
  75. package/packages/status/src/types.ts +228 -0
  76. package/src/deck-surface.ts +46 -0
  77. package/src/health.ts +228 -0
  78. package/src/index.ts +28 -0
  79. package/src/pi-status.ts +334 -0
@@ -0,0 +1,5 @@
1
+ const GIT_AFFECTING_TOOLS = new Set(['bash', 'edit', 'write']);
2
+
3
+ export function shouldRefreshGitAfterTool(toolName: string, isError: boolean): boolean {
4
+ return !isError && GIT_AFFECTING_TOOLS.has(toolName);
5
+ }
@@ -0,0 +1,428 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { availableParallelism } from 'node:os';
3
+
4
+ export const CPU_WIDGET_ID = 'galactica.session-cpu';
5
+ export const MEMORY_WIDGET_ID = 'galactica.session-memory';
6
+ export const RESOURCE_REFRESH_MS = 3_000;
7
+ export const CPU_WARNING_PERCENT = 80;
8
+ export const CPU_WARNING_SAMPLES = 3;
9
+ export const MEMORY_WARNING_BYTES = 4 * 1024 ** 3;
10
+
11
+ const LINUX_CLOCK_TICKS_PER_SECOND = 100;
12
+ const LINUX_PAGE_BYTES = 4_096;
13
+ const MAX_PROC_PROCESSES = 4_096;
14
+ const CPU_SMOOTHING_ALPHA = 0.35;
15
+
16
+ export interface ProcStat {
17
+ pid: number;
18
+ parentPid: number;
19
+ cpuTicks: number;
20
+ startTimeTicks: number;
21
+ rssPages: number;
22
+ }
23
+
24
+ export interface ProcessCpuSample {
25
+ pid: number;
26
+ startTimeTicks: number;
27
+ cpuTimeMs: number;
28
+ }
29
+
30
+ export interface ProcessTreeSample {
31
+ sampledAtMs: number;
32
+ memoryBytes: number;
33
+ memorySource: 'private-anon' | 'rss';
34
+ processes: ProcessCpuSample[];
35
+ source: 'proc' | 'current-process';
36
+ }
37
+
38
+ export interface CpuSmoothingState {
39
+ percent: number;
40
+ pressureSamples: number;
41
+ warning: boolean;
42
+ }
43
+
44
+ export interface ResourceTelemetry {
45
+ cpuPercent: number;
46
+ memoryBytes: number;
47
+ cpuWarning: boolean;
48
+ memoryWarning: boolean;
49
+ }
50
+
51
+ export interface ProcReader {
52
+ listPids(): Promise<number[]>;
53
+ readStat(pid: number): Promise<string>;
54
+ readStatus?(pid: number): Promise<string>;
55
+ readChildren?(pid: number): Promise<string>;
56
+ }
57
+
58
+ export function parseProcStat(text: string): ProcStat | undefined {
59
+ const open = text.indexOf('(');
60
+ const close = text.lastIndexOf(')');
61
+ if (open <= 0 || close <= open) return undefined;
62
+
63
+ const pid = Number.parseInt(text.slice(0, open).trim(), 10);
64
+ const fields = text
65
+ .slice(close + 1)
66
+ .trim()
67
+ .split(/\s+/u);
68
+ const parentPid = Number.parseInt(fields[1] ?? '', 10);
69
+ const userTicks = Number.parseInt(fields[11] ?? '', 10);
70
+ const systemTicks = Number.parseInt(fields[12] ?? '', 10);
71
+ const startTimeTicks = Number.parseInt(fields[19] ?? '', 10);
72
+ const rssPages = Number.parseInt(fields[21] ?? '', 10);
73
+
74
+ if (
75
+ !Number.isSafeInteger(pid) ||
76
+ pid <= 0 ||
77
+ !Number.isSafeInteger(parentPid) ||
78
+ parentPid < 0 ||
79
+ !Number.isFinite(userTicks) ||
80
+ userTicks < 0 ||
81
+ !Number.isFinite(systemTicks) ||
82
+ systemTicks < 0 ||
83
+ !Number.isFinite(startTimeTicks) ||
84
+ startTimeTicks < 0 ||
85
+ !Number.isFinite(rssPages)
86
+ ) {
87
+ return undefined;
88
+ }
89
+
90
+ return {
91
+ pid,
92
+ parentPid,
93
+ cpuTicks: userTicks + systemTicks,
94
+ startTimeTicks,
95
+ rssPages: Math.max(0, rssPages),
96
+ };
97
+ }
98
+
99
+ export function parseProcStatusPrivateBytes(text: string): number | undefined {
100
+ const match = text.match(/^RssAnon:\s+(\d+)\s+kB$/mu);
101
+ if (!match) return undefined;
102
+ const kibibytes = Number.parseInt(match[1] ?? '', 10);
103
+ return Number.isSafeInteger(kibibytes) && kibibytes >= 0
104
+ ? kibibytes * 1024
105
+ : undefined;
106
+ }
107
+
108
+ export function selectProcessTree(
109
+ records: readonly ProcStat[],
110
+ rootPid: number,
111
+ ): ProcStat[] {
112
+ const byPid = new Map(records.map((record) => [record.pid, record]));
113
+ if (!byPid.has(rootPid)) return [];
114
+
115
+ const children = new Map<number, number[]>();
116
+ for (const record of records) {
117
+ const siblings = children.get(record.parentPid) ?? [];
118
+ siblings.push(record.pid);
119
+ children.set(record.parentPid, siblings);
120
+ }
121
+
122
+ const selected: ProcStat[] = [];
123
+ const pending = [rootPid];
124
+ const visited = new Set<number>();
125
+ while (pending.length > 0 && selected.length < MAX_PROC_PROCESSES) {
126
+ const pid = pending.shift();
127
+ if (pid === undefined || visited.has(pid)) continue;
128
+ visited.add(pid);
129
+ const record = byPid.get(pid);
130
+ if (!record) continue;
131
+ selected.push(record);
132
+ pending.push(...(children.get(pid) ?? []));
133
+ }
134
+ return selected;
135
+ }
136
+
137
+ export function buildProcessTreeSample(
138
+ records: readonly ProcStat[],
139
+ rootPid: number,
140
+ sampledAtMs: number,
141
+ ): ProcessTreeSample | undefined {
142
+ const tree = selectProcessTree(records, rootPid);
143
+ if (tree.length === 0) return undefined;
144
+ return {
145
+ sampledAtMs,
146
+ memoryBytes: tree.reduce(
147
+ (total, record) => total + record.rssPages * LINUX_PAGE_BYTES,
148
+ 0,
149
+ ),
150
+ memorySource: 'rss',
151
+ processes: tree.map((record) => ({
152
+ pid: record.pid,
153
+ startTimeTicks: record.startTimeTicks,
154
+ cpuTimeMs: (record.cpuTicks * 1_000) / LINUX_CLOCK_TICKS_PER_SECOND,
155
+ })),
156
+ source: 'proc',
157
+ };
158
+ }
159
+
160
+ async function collectCandidatePids(
161
+ rootPid: number,
162
+ reader: ProcReader,
163
+ ): Promise<number[]> {
164
+ if (!reader.readChildren) {
165
+ const listed = await reader.listPids();
166
+ return Array.from(
167
+ new Set([
168
+ rootPid,
169
+ ...listed.filter((pid) => Number.isSafeInteger(pid) && pid > 0),
170
+ ]),
171
+ ).slice(0, MAX_PROC_PROCESSES);
172
+ }
173
+
174
+ const selected: number[] = [];
175
+ const pending = [rootPid];
176
+ const visited = new Set<number>();
177
+ while (pending.length > 0 && selected.length < MAX_PROC_PROCESSES) {
178
+ const pid = pending.shift();
179
+ if (pid === undefined || visited.has(pid)) continue;
180
+ visited.add(pid);
181
+ selected.push(pid);
182
+ try {
183
+ const children = (await reader.readChildren(pid))
184
+ .trim()
185
+ .split(/\s+/u)
186
+ .flatMap((value) => {
187
+ const child = Number.parseInt(value, 10);
188
+ return Number.isSafeInteger(child) && child > 0 ? [child] : [];
189
+ });
190
+ pending.push(...children);
191
+ } catch {
192
+ // A child may exit while the bounded tree is being discovered.
193
+ }
194
+ }
195
+ return selected;
196
+ }
197
+
198
+ export async function collectProcessTreeFromProc(
199
+ rootPid: number,
200
+ reader: ProcReader,
201
+ sampledAtMs = Date.now(),
202
+ ): Promise<ProcessTreeSample | undefined> {
203
+ const pids = await collectCandidatePids(rootPid, reader);
204
+ const settled = await Promise.allSettled(pids.map((pid) => reader.readStat(pid)));
205
+ const records: ProcStat[] = [];
206
+ for (const result of settled) {
207
+ if (result.status !== 'fulfilled') continue;
208
+ const record = parseProcStat(result.value);
209
+ if (record) records.push(record);
210
+ }
211
+ const sample = buildProcessTreeSample(records, rootPid, sampledAtMs);
212
+ if (!sample || !reader.readStatus) return sample;
213
+
214
+ const tree = selectProcessTree(records, rootPid);
215
+ const statuses = await Promise.allSettled(
216
+ tree.map((record) => reader.readStatus!(record.pid)),
217
+ );
218
+ let privateSamples = 0;
219
+ let memoryBytes = 0;
220
+ for (let index = 0; index < tree.length; index += 1) {
221
+ const record = tree[index]!;
222
+ const result = statuses[index];
223
+ const privateBytes =
224
+ result?.status === 'fulfilled'
225
+ ? parseProcStatusPrivateBytes(result.value)
226
+ : undefined;
227
+ if (privateBytes === undefined) {
228
+ memoryBytes += record.rssPages * LINUX_PAGE_BYTES;
229
+ } else {
230
+ memoryBytes += privateBytes;
231
+ privateSamples += 1;
232
+ }
233
+ }
234
+ return {
235
+ ...sample,
236
+ memoryBytes,
237
+ memorySource: privateSamples > 0 ? 'private-anon' : 'rss',
238
+ };
239
+ }
240
+
241
+ function currentProcessSample(sampledAtMs = Date.now()): ProcessTreeSample {
242
+ const usage = process.cpuUsage();
243
+ return {
244
+ sampledAtMs,
245
+ memoryBytes: Math.max(0, process.memoryUsage().rss),
246
+ memorySource: 'rss',
247
+ processes: [
248
+ {
249
+ pid: process.pid,
250
+ startTimeTicks: 0,
251
+ cpuTimeMs: Math.max(0, usage.user + usage.system) / 1_000,
252
+ },
253
+ ],
254
+ source: 'current-process',
255
+ };
256
+ }
257
+
258
+ function linuxProcReader(procRoot = '/proc'): ProcReader {
259
+ return {
260
+ async listPids() {
261
+ const entries = await readdir(procRoot, { withFileTypes: true });
262
+ return entries
263
+ .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
264
+ .map((entry) => Number.parseInt(entry.name, 10));
265
+ },
266
+ async readStat(pid) {
267
+ return readFile(`${procRoot}/${pid}/stat`, 'utf8');
268
+ },
269
+ async readStatus(pid) {
270
+ return readFile(`${procRoot}/${pid}/status`, 'utf8');
271
+ },
272
+ async readChildren(pid) {
273
+ return readFile(`${procRoot}/${pid}/task/${pid}/children`, 'utf8');
274
+ },
275
+ };
276
+ }
277
+
278
+ export async function sampleProcessTreeResources(
279
+ options: {
280
+ rootPid?: number;
281
+ platform?: NodeJS.Platform;
282
+ reader?: ProcReader;
283
+ sampledAtMs?: number;
284
+ } = {},
285
+ ): Promise<ProcessTreeSample> {
286
+ const sampledAtMs = options.sampledAtMs ?? Date.now();
287
+ if ((options.platform ?? process.platform) === 'linux') {
288
+ try {
289
+ const sample = await collectProcessTreeFromProc(
290
+ options.rootPid ?? process.pid,
291
+ options.reader ?? linuxProcReader(),
292
+ sampledAtMs,
293
+ );
294
+ if (sample) return sample;
295
+ } catch {
296
+ // /proc may be unavailable or transiently unreadable. The fallback below
297
+ // deliberately reports only the current process rather than estimating descendants.
298
+ }
299
+ }
300
+ return currentProcessSample(sampledAtMs);
301
+ }
302
+
303
+ export function calculateCpuPercent(
304
+ previous: ProcessTreeSample | undefined,
305
+ current: ProcessTreeSample,
306
+ cpuCapacity = availableParallelism(),
307
+ ): number {
308
+ if (!previous) return 0;
309
+ const elapsedMs = current.sampledAtMs - previous.sampledAtMs;
310
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return 0;
311
+
312
+ const previousCpu = new Map(
313
+ previous.processes.map((sample) => [
314
+ `${sample.pid}:${sample.startTimeTicks}`,
315
+ sample.cpuTimeMs,
316
+ ]),
317
+ );
318
+ let usedCpuMs = 0;
319
+ for (const sample of current.processes) {
320
+ const before = previousCpu.get(`${sample.pid}:${sample.startTimeTicks}`);
321
+ if (before === undefined) continue;
322
+ usedCpuMs += Math.max(0, sample.cpuTimeMs - before);
323
+ }
324
+ const capacity =
325
+ Number.isFinite(cpuCapacity) && cpuCapacity > 0 ? Math.floor(cpuCapacity) : 1;
326
+ return Math.min(100, Math.max(0, (usedCpuMs / elapsedMs / capacity) * 100));
327
+ }
328
+
329
+ export function updateCpuSmoothing(
330
+ previous: CpuSmoothingState | undefined,
331
+ rawPercent: number,
332
+ ): CpuSmoothingState {
333
+ const normalized = Number.isFinite(rawPercent)
334
+ ? Math.max(0, Math.min(100, rawPercent))
335
+ : 0;
336
+ const percent = previous
337
+ ? previous.percent + CPU_SMOOTHING_ALPHA * (normalized - previous.percent)
338
+ : normalized;
339
+ const pressureSamples =
340
+ normalized >= CPU_WARNING_PERCENT && percent >= CPU_WARNING_PERCENT
341
+ ? (previous?.pressureSamples ?? 0) + 1
342
+ : 0;
343
+ return {
344
+ percent,
345
+ pressureSamples,
346
+ warning: pressureSamples >= CPU_WARNING_SAMPLES,
347
+ };
348
+ }
349
+
350
+ export function formatCpuPercent(percent: number): string {
351
+ const normalized = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0;
352
+ return `${Math.round(normalized)}%`;
353
+ }
354
+
355
+ export function formatIecBytes(bytes: number): string {
356
+ const normalized = Number.isFinite(bytes) ? Math.max(0, bytes) : 0;
357
+ const units = ['', 'K', 'M', 'G', 'T'];
358
+ let value = normalized;
359
+ let unit = 0;
360
+ while (value >= 1024 && unit < units.length - 1) {
361
+ value /= 1024;
362
+ unit += 1;
363
+ }
364
+ const decimals = unit > 0 && value < 10 ? 1 : 0;
365
+ return `${value.toFixed(decimals).replace(/\.0$/u, '')}${units[unit]}`;
366
+ }
367
+
368
+ export function buildResourceTelemetry(
369
+ cpu: CpuSmoothingState,
370
+ memoryBytes: number,
371
+ ): ResourceTelemetry {
372
+ const normalizedMemory = Math.max(0, memoryBytes);
373
+ return {
374
+ cpuPercent: Math.max(0, Math.min(100, cpu.percent)),
375
+ memoryBytes: normalizedMemory,
376
+ cpuWarning: cpu.warning,
377
+ memoryWarning: normalizedMemory >= MEMORY_WARNING_BYTES,
378
+ };
379
+ }
380
+
381
+ export function buildResourceFooterWidgets(telemetry: ResourceTelemetry) {
382
+ const cpuColor = telemetry.cpuWarning ? 'warning' : 'accent';
383
+ const memoryColor = telemetry.memoryWarning ? 'warning' : 'accent';
384
+ return [
385
+ {
386
+ protocol: 1,
387
+ type: 'upsert',
388
+ widget: {
389
+ id: CPU_WIDGET_ID,
390
+ label: 'Session CPU',
391
+ description: 'Share of available CPU capacity used by the Pi process tree',
392
+ content: { type: 'text', text: formatCpuPercent(telemetry.cpuPercent) },
393
+ icon: {
394
+ glyphs: {
395
+ nerd: '› ',
396
+ emoji: '› 🧮',
397
+ unicode: '› CPU',
398
+ ascii: '> CPU',
399
+ },
400
+ color: cpuColor,
401
+ },
402
+ style: { textColor: cpuColor },
403
+ layout: { row: 1, position: 7, align: 'right', fill: 'none' },
404
+ },
405
+ },
406
+ {
407
+ protocol: 1,
408
+ type: 'upsert',
409
+ widget: {
410
+ id: MEMORY_WIDGET_ID,
411
+ label: 'Session memory',
412
+ description: 'Private resident memory used by the Pi process tree',
413
+ content: { type: 'text', text: formatIecBytes(telemetry.memoryBytes) },
414
+ icon: {
415
+ glyphs: {
416
+ nerd: '▤',
417
+ emoji: '🧠',
418
+ unicode: 'RAM',
419
+ ascii: 'RAM',
420
+ },
421
+ color: memoryColor,
422
+ },
423
+ style: { textColor: memoryColor },
424
+ layout: { row: 1, position: 6, align: 'right', fill: 'none' },
425
+ },
426
+ },
427
+ ] as const;
428
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Deck-side defensive parser for the title-state contract v1
3
+ * (`deck-title-state`, published as the `title` field on
4
+ * `galactica-status:header` with the per-turn `turn` meter beside it).
5
+ *
6
+ * The contract lives in `packages/status/src/title-state.ts`; pi-stardeck
7
+ * keeps this local copy because the deck package carries no npm dependency
8
+ * on the status workspace and consumes it only through the event channel.
9
+ * Bounds mirror the publisher parser exactly: unknown versions and
10
+ * malformed shapes reject the whole object with `null`; parsing never
11
+ * throws, so older publisher events (missing `title`/`turn`) degrade to
12
+ * the legacy selection/activity rendering.
13
+ */
14
+
15
+ export const DECK_TITLE_STATE_VERSION = 1 as const;
16
+
17
+ export const DECK_TITLE_ACTIVITY_CLASSES = [
18
+ 'editing',
19
+ 'reading',
20
+ 'grepping',
21
+ 'bash',
22
+ 'subagent',
23
+ ] as const;
24
+
25
+ export type DeckTitleActivityClass = (typeof DECK_TITLE_ACTIVITY_CLASSES)[number];
26
+
27
+ /** Short target label bound from the accepted schema. */
28
+ export const DECK_TITLE_TARGET_MAX = 48;
29
+
30
+ /** Task/step totals bound from the accepted schema. */
31
+ export const DECK_TITLE_TOTAL_MAX = 999;
32
+
33
+ const SUBJECT_MAX = 180;
34
+ const IDENTIFIER_MAX = 120;
35
+ const TASK_ID_MAX = 32;
36
+ const SPAN_ID_MAX = 64;
37
+ const WAIVER_ID_MAX = 64;
38
+ const NOTICE_MAX = 180;
39
+ const TURN_TOKENS_MAX = 999_999_999;
40
+ const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
41
+
42
+ export type DeckTitleWorkState = {
43
+ change: string;
44
+ task?: string;
45
+ done: number;
46
+ total: number;
47
+ };
48
+
49
+ export type DeckTitleActivityState = {
50
+ class: DeckTitleActivityClass;
51
+ target: string;
52
+ span: string;
53
+ parent: string | null;
54
+ };
55
+
56
+ export type DeckTitleWaiverState = { id: string; notice: string };
57
+
58
+ export type DeckTitleState = {
59
+ v: typeof DECK_TITLE_STATE_VERSION;
60
+ subject: string | null;
61
+ work: DeckTitleWorkState | null;
62
+ activity: DeckTitleActivityState | null;
63
+ waiver: DeckTitleWaiverState | null;
64
+ };
65
+
66
+ /** Per-turn cost meter published beside the title state. */
67
+ export type DeckTurnCost = { tokens: number; elapsedMs: number };
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function boundedText(
74
+ value: unknown,
75
+ maximum: number,
76
+ { required = true }: { required?: boolean } = {},
77
+ ): string | null | undefined {
78
+ if (value === null || value === undefined) return required ? null : undefined;
79
+ if (typeof value !== 'string') return null;
80
+ const points = Array.from(value.replace(/\s+/gu, ' ').trim());
81
+ if (points.length === 0) return null;
82
+ if (points.length > maximum) return null;
83
+ return points.join('');
84
+ }
85
+
86
+ function boundedTotal(value: unknown): number | null {
87
+ return typeof value === 'number' &&
88
+ Number.isInteger(value) &&
89
+ value >= 0 &&
90
+ value <= DECK_TITLE_TOTAL_MAX
91
+ ? value
92
+ : null;
93
+ }
94
+
95
+ function parseTitleActivity(value: unknown): DeckTitleActivityState | null | undefined {
96
+ if (value === null) return null;
97
+ if (!isRecord(value)) return undefined;
98
+ const activityClass = DECK_TITLE_ACTIVITY_CLASSES.includes(
99
+ value.class as DeckTitleActivityClass,
100
+ )
101
+ ? (value.class as DeckTitleActivityClass)
102
+ : undefined;
103
+ const target = boundedText(value.target, DECK_TITLE_TARGET_MAX);
104
+ const span = boundedText(value.span, SPAN_ID_MAX);
105
+ let parent: string | null | undefined;
106
+ if (value.parent === null) parent = null;
107
+ else if (typeof value.parent === 'string') {
108
+ const normalized = boundedText(value.parent, SPAN_ID_MAX);
109
+ parent = normalized === null ? undefined : normalized;
110
+ } else parent = undefined;
111
+ if (!activityClass || !target || !span || parent === undefined) return undefined;
112
+ return { class: activityClass, target, span, parent };
113
+ }
114
+
115
+ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
116
+ if (value === null) return null;
117
+ if (!isRecord(value)) return undefined;
118
+ 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;
122
+ const task = boundedText(value.task, TASK_ID_MAX, { required: false });
123
+ if (task === null) return undefined;
124
+ return task === undefined ? { change, done, total } : { change, task, done, total };
125
+ }
126
+
127
+ function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
128
+ if (value === null) return null;
129
+ if (!isRecord(value)) return undefined;
130
+ const id = boundedText(value.id, WAIVER_ID_MAX);
131
+ const notice = boundedText(value.notice, NOTICE_MAX);
132
+ if (!id || !notice) return undefined;
133
+ return { id, notice };
134
+ }
135
+
136
+ /**
137
+ * Defensive parser for the published `title` state. Unknown versions,
138
+ * malformed shapes, and out-of-bounds values reject the whole object with
139
+ * `null`; it never throws.
140
+ */
141
+ export function parseDeckTitleState(value: unknown): DeckTitleState | null {
142
+ try {
143
+ if (!isRecord(value)) return null;
144
+ if (value.v !== DECK_TITLE_STATE_VERSION) return null;
145
+ const subject = boundedText(value.subject, SUBJECT_MAX, { required: false });
146
+ if (subject === null) return null;
147
+ const work = parseTitleWork(value.work);
148
+ if (work === undefined) return null;
149
+ const activity = parseTitleActivity(value.activity);
150
+ if (activity === undefined) return null;
151
+ const waiver = parseTitleWaiver(value.waiver);
152
+ if (waiver === undefined) return null;
153
+ return {
154
+ v: DECK_TITLE_STATE_VERSION,
155
+ subject: subject ?? null,
156
+ work,
157
+ activity,
158
+ waiver,
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Defensive parser for the per-turn cost meter. Non-finite or negative
167
+ * values reject with `null`; it never throws.
168
+ */
169
+ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
170
+ try {
171
+ if (!isRecord(value)) return null;
172
+ const tokens = value.tokens;
173
+ const elapsedMs = value.elapsedMs;
174
+ if (
175
+ typeof tokens !== 'number' ||
176
+ !Number.isFinite(tokens) ||
177
+ tokens < 0 ||
178
+ tokens > TURN_TOKENS_MAX ||
179
+ typeof elapsedMs !== 'number' ||
180
+ !Number.isFinite(elapsedMs) ||
181
+ elapsedMs < 0 ||
182
+ elapsedMs > TURN_ELAPSED_MAX
183
+ ) {
184
+ return null;
185
+ }
186
+ return { tokens: Math.round(tokens), elapsedMs: Math.floor(elapsedMs) };
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /** `change · done/total` — the Work label from the accepted mockups. */
193
+ export function formatDeckWorkLabel(work: DeckTitleWorkState): string {
194
+ return `${work.change} · ${work.done}/${work.total}`;
195
+ }