@aemonge-dev/pi-stardeck 0.13.0 → 0.15.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.
- package/README.md +20 -2
- package/demo/pi-stardeck.tape +6 -3
- package/docs/architecture.md +2 -1
- package/package.json +2 -3
- package/packages/pi-stardeck/index.ts +0 -10
- package/packages/pi-stardeck/src/deck.ts +30 -52
- package/packages/pi-stardeck/src/title-state.ts +63 -136
- package/packages/status/README.md +20 -34
- package/packages/status/index.ts +28 -143
- package/packages/status/src/active-work.ts +16 -109
- package/packages/status/src/config.ts +1 -3
- package/packages/status/src/ledger.ts +1 -7
- package/packages/status/src/openspec.ts +25 -17
- package/packages/status/src/publisher.ts +30 -91
- package/packages/status/src/reducer.ts +2 -36
- package/packages/status/src/room-aim.ts +151 -0
- package/packages/status/src/title-state.ts +43 -135
- package/packages/status/src/types.ts +7 -8
- package/src/index.ts +0 -2
- package/packages/pi-stardeck/src/activity-age.ts +0 -184
- package/packages/work-state/README.md +0 -33
- package/packages/work-state/index.ts +0 -160
- package/packages/work-state/package.json +0 -33
- package/packages/work-state/src/focus.ts +0 -100
package/README.md
CHANGED
|
@@ -28,8 +28,21 @@ The package composes four bounded modules in dependency-safe order:
|
|
|
28
28
|
|
|
29
29
|
- **Footer telemetry** — session usage cost for the deck's telemetry row, with an
|
|
30
30
|
empty quota seam;
|
|
31
|
-
- **Status provider** — lifecycle, OpenSpec
|
|
32
|
-
|
|
31
|
+
- **Status provider** — lifecycle, the room aim's OpenSpec state, and diagnostic
|
|
32
|
+
state;
|
|
33
|
+
- **Header Deck** — current activity, the focused change, project state, and compact
|
|
34
|
+
telemetry.
|
|
35
|
+
|
|
36
|
+
The focused change is the room aim — `openspec/.focus`, written by your terminal
|
|
37
|
+
(`eospec`/`pios-gate` verbs) and read verbatim by the cockpit:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
{icon} {artifact-name} {artifact-idx} › {task} ⟩ {next-step} ← line 2, right
|
|
41
|
+
{icon} {change} ← engine row, right
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
An aimless room wears gray `(unfocus openspec)` in that slot; warnings speak on their
|
|
45
|
+
own dynamic line; nothing renders when there is nothing to say.
|
|
33
46
|
Companion extensions surface as leading telemetry icons:
|
|
34
47
|
|
|
35
48
|
| Chip | Extension | Meaning |
|
|
@@ -54,6 +67,11 @@ fail soft when absent. Pi core remains the API authority.
|
|
|
54
67
|
its only argument, opens it in the enclosing Neovim instance, and exits when that
|
|
55
68
|
buffer closes.
|
|
56
69
|
|
|
70
|
+
**Recommended pairing — [pi-title](https://www.npmjs.com/package/pi-title):** the deck's
|
|
71
|
+
left title band prefers the native session name, and pi-title names each session from
|
|
72
|
+
its first request. Install it alongside the deck and every session opens already named;
|
|
73
|
+
manual titles are never replaced automatically.
|
|
74
|
+
|
|
57
75
|
The bridge is deliberately not shipped: connecting to a private Neovim server is
|
|
58
76
|
machine-specific and belongs in your trusted user configuration.
|
|
59
77
|
|
package/demo/pi-stardeck.tape
CHANGED
|
@@ -21,7 +21,7 @@ Set PlaybackSpeed 1
|
|
|
21
21
|
# stays attached so the recording does not stall on a cold cache. Keep setup
|
|
22
22
|
# out of the recording.
|
|
23
23
|
Hide
|
|
24
|
-
Type "export DEMO_HOME=/tmp/pi-stardeck-demo && rm -rf $DEMO_HOME && mkdir -p $DEMO_HOME && export NPM_CACHE_KEEP=$HOME/.npm && export HOME=$DEMO_HOME && export NPM_CONFIG_PREFIX=$HOME/.npm-global && export npm_config_cache=$NPM_CACHE_KEEP && export PATH=$NPM_CONFIG_PREFIX/bin:$PATH && clear"
|
|
24
|
+
Type "export DEMO_HOME=/tmp/pi-stardeck-demo && rm -rf $DEMO_HOME && mkdir -p $DEMO_HOME/demo-room/openspec/changes/hello-script && printf '# one active change per room — eospec writes, verbs refresh\nchange: hello-script\nartifact-name: 1-brainstorm.md\nartifact-idx: 1/4\ntask: \"\"\nnext-step: draft 1-brainstorm.md\n' > $DEMO_HOME/demo-room/openspec/.focus && export NPM_CACHE_KEEP=$HOME/.npm && export HOME=$DEMO_HOME && export NPM_CONFIG_PREFIX=$HOME/.npm-global && export npm_config_cache=$NPM_CACHE_KEEP && export PATH=$NPM_CONFIG_PREFIX/bin:$PATH && clear"
|
|
25
25
|
Enter
|
|
26
26
|
Wait
|
|
27
27
|
Show
|
|
@@ -37,13 +37,16 @@ Show
|
|
|
37
37
|
Wait+Screen@30s /Installed npm:@aemonge-dev\/pi-stardeck/
|
|
38
38
|
Sleep 1s
|
|
39
39
|
|
|
40
|
-
# Launch a clean cockpit session
|
|
41
|
-
|
|
40
|
+
# Launch a clean cockpit session inside the aimed demo room: the room aim
|
|
41
|
+
# (openspec/.focus) drives the verbatim work tile and the focus spec on
|
|
42
|
+
# the engine row.
|
|
43
|
+
Type "cd $DEMO_HOME/demo-room && clear && pi --offline --no-session --no-context-files --tui-mode regular"
|
|
42
44
|
Enter
|
|
43
45
|
Hide
|
|
44
46
|
Sleep 12s
|
|
45
47
|
Show
|
|
46
48
|
Wait+Screen@60s /waiting/
|
|
49
|
+
Wait+Screen@10s /hello-script/
|
|
47
50
|
Sleep 4s
|
|
48
51
|
|
|
49
52
|
# Exercise a real ephemeral title change without creating a session.
|
package/docs/architecture.md
CHANGED
|
@@ -44,7 +44,8 @@ The composed modules currently coordinate through:
|
|
|
44
44
|
|
|
45
45
|
These channels are compatibility boundaries. Consolidation may centralize their types
|
|
46
46
|
but must not silently change semantics or permit sensitive payloads. Selection is an
|
|
47
|
-
additive protocol-1 projection of explicit
|
|
47
|
+
additive protocol-1 projection of explicit Session Work state; the focused change
|
|
48
|
+
lives in the room aim (`openspec/.focus`) and renders verbatim without selection;
|
|
48
49
|
suggestion is controlled vocabulary rather than generated prose. Active-run telemetry
|
|
49
50
|
contains only bounded child and subagent counts derived from Pi lifecycle events. It
|
|
50
51
|
never carries prompts, names, process identities, arguments, or child output. The deck
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aemonge-dev/pi-stardeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "A Vim- and tmux-inspired command deck for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -48,11 +48,10 @@
|
|
|
48
48
|
"check:openspec": "node scripts/check-openspec.mjs",
|
|
49
49
|
"test:tooling": "node --import tsx/esm --test test/*.test.ts",
|
|
50
50
|
"test:status": "npm --prefix packages/status test",
|
|
51
|
-
"test:work-state": "npm --prefix packages/work-state test",
|
|
52
51
|
"test:stardeck": "npm --prefix packages/pi-stardeck test",
|
|
53
52
|
"test:footer": "npm --prefix packages/footer test",
|
|
54
53
|
"test:vim": "npm --prefix packages/pi-vim-top-border test",
|
|
55
|
-
"test:components": "npm run test:status && npm run test:
|
|
54
|
+
"test:components": "npm run test:status && npm run test:stardeck && npm run test:footer && npm run test:vim"
|
|
56
55
|
},
|
|
57
56
|
"peerDependencies": {
|
|
58
57
|
"@earendil-works/pi-ai": "*",
|
|
@@ -125,13 +125,6 @@ export function installEmptyDeckFooter(
|
|
|
125
125
|
return () => ctx.ui.setFooter(undefined);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/** Git-style short id of the running Pi session — 8 hex chars of the
|
|
129
|
-
* UUIDv7, undefined when the id does not match the expected shape. */
|
|
130
|
-
function shortSessionId(ctx: ExtensionContext): string | undefined {
|
|
131
|
-
const short = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
132
|
-
return /^[0-9a-f]{8}$/u.test(short) ? short : undefined;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
128
|
export default function galacticaContextHeader(
|
|
136
129
|
pi: ExtensionAPI,
|
|
137
130
|
options: ContextHeaderOptions = {},
|
|
@@ -149,7 +142,6 @@ export default function galacticaContextHeader(
|
|
|
149
142
|
let lastObservedActivityAt: number | null = null;
|
|
150
143
|
let activityAgeTimer: ReturnType<typeof setInterval> | undefined;
|
|
151
144
|
let sessionGeneration = 0;
|
|
152
|
-
let sessionShortId: string | undefined;
|
|
153
145
|
let activeCwd = '';
|
|
154
146
|
let activeCtx: ExtensionContext | undefined;
|
|
155
147
|
let gitRefreshInFlight = false;
|
|
@@ -369,7 +361,6 @@ export default function galacticaContextHeader(
|
|
|
369
361
|
resourceTimer = undefined;
|
|
370
362
|
++sessionGeneration;
|
|
371
363
|
activeCtx = ctx;
|
|
372
|
-
sessionShortId = shortSessionId(ctx);
|
|
373
364
|
activeCwd = '';
|
|
374
365
|
branch = '';
|
|
375
366
|
gitAvailable = false;
|
|
@@ -444,7 +435,6 @@ export default function galacticaContextHeader(
|
|
|
444
435
|
lsp: lspCapability,
|
|
445
436
|
mcp: mcpCapability,
|
|
446
437
|
mode: vimMode,
|
|
447
|
-
...(sessionShortId !== undefined ? { sessionShortId } : {}),
|
|
448
438
|
},
|
|
449
439
|
width,
|
|
450
440
|
theme,
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from './gauge.ts';
|
|
12
12
|
import type { ResourceTelemetry } from './process-resources.ts';
|
|
13
13
|
import { formatDeckCountdown } from './gauge.ts';
|
|
14
|
-
import { deckWorkSegments } from './title-state.ts';
|
|
14
|
+
import { deckWorkSegments, OPEN_SPEC_ICON } from './title-state.ts';
|
|
15
15
|
|
|
16
16
|
export interface HeaderDeckModeRail {
|
|
17
17
|
plain: string;
|
|
@@ -39,8 +39,6 @@ export interface HeaderDeckState {
|
|
|
39
39
|
lsp: LspCapability | null;
|
|
40
40
|
mcp: McpCapability | null;
|
|
41
41
|
mode: VimMode;
|
|
42
|
-
/** Git-style first 8 chars of the Pi session id; worn by the sentinel. */
|
|
43
|
-
sessionShortId?: string;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
const ANSI_ESCAPE = new RegExp(
|
|
@@ -163,10 +161,6 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
|
|
|
163
161
|
return lines.slice(0, maximumLines);
|
|
164
162
|
}
|
|
165
163
|
|
|
166
|
-
function mirrorKey(value: string): string {
|
|
167
|
-
return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
164
|
function bandWrap(
|
|
171
165
|
content: string,
|
|
172
166
|
width: number,
|
|
@@ -191,12 +185,10 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
|
|
|
191
185
|
// moment` — never an empty band while the session talks.
|
|
192
186
|
return bandWrap('— no subject', width, theme, 'dim');
|
|
193
187
|
}
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
const mirrorsFocus = work !== null && mirrorKey(subject) === mirrorKey(work.change);
|
|
199
|
-
return bandWrap(subject, width, theme, mirrorsFocus ? 'success' : 'accent');
|
|
188
|
+
// Human layout law 2026-11-10: the left band is Human-owned title —
|
|
189
|
+
// it can never mirror openspec, so the mirror coloring is gone and
|
|
190
|
+
// the band renders accent unconditionally.
|
|
191
|
+
return bandWrap(subject, width, theme, 'accent');
|
|
200
192
|
}
|
|
201
193
|
// Pre-title publishers: the legacy selection keeps the band alive.
|
|
202
194
|
const selection = state.header?.selection;
|
|
@@ -248,7 +240,7 @@ function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
|
|
|
248
240
|
if (!title) return '';
|
|
249
241
|
const work = title.work;
|
|
250
242
|
if (!work) return '';
|
|
251
|
-
return deckWorkSegments(work,
|
|
243
|
+
return deckWorkSegments(work, width < 56)
|
|
252
244
|
.map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
|
|
253
245
|
.join('');
|
|
254
246
|
}
|
|
@@ -406,7 +398,7 @@ const COMPANION_ERROR_PATTERN = /(error|fail|down|unreachable|unhealthy)/iu;
|
|
|
406
398
|
type ChipSeverity = 'healthy' | 'degraded' | 'error' | 'off';
|
|
407
399
|
|
|
408
400
|
function companionSeverity(text: string | undefined): ChipSeverity {
|
|
409
|
-
// Error-grade words
|
|
401
|
+
// Error-grade words win over degrade-grade when both appear.
|
|
410
402
|
if (text === undefined) return 'off';
|
|
411
403
|
if (COMPANION_ERROR_PATTERN.test(text)) return 'error';
|
|
412
404
|
if (COMPANION_DEGRADE_PATTERN.test(text)) return 'degraded';
|
|
@@ -477,14 +469,9 @@ function identityCluster(
|
|
|
477
469
|
const BLOCKED_NOTICE = '\u26d4 blocked';
|
|
478
470
|
const APPROVAL_NOTICE = 'approval required';
|
|
479
471
|
|
|
480
|
-
/** Narrowest frame that hosts the id-bearing sentinel cluster (12 cells
|
|
481
|
-
* plus breathing room for the machine ladder). */
|
|
482
|
-
const SENTINEL_ID_MIN_WIDTH = 24;
|
|
483
|
-
const SENTINEL_BUG = '\u{F082D}';
|
|
484
|
-
|
|
485
472
|
function companionWarningTexts(state: HeaderDeckState): string[] {
|
|
486
473
|
// Same sources as the capability-health chips: the known companions'
|
|
487
|
-
// native-status texts,
|
|
474
|
+
// native-status texts, ordered after blocked/approval.
|
|
488
475
|
const texts = new Map<string, string>();
|
|
489
476
|
for (const item of state.piStatus?.nativeStatuses ?? []) {
|
|
490
477
|
if (texts.has(item.key)) continue;
|
|
@@ -505,7 +492,7 @@ function companionWarningTexts(state: HeaderDeckState): string[] {
|
|
|
505
492
|
|
|
506
493
|
/**
|
|
507
494
|
* Aggregate the deck's scattered warning signals into one notice slot
|
|
508
|
-
* (Task 3.1).
|
|
495
|
+
* (Task 3.1). Ordered per the spec's accepted mixed render: the companion
|
|
509
496
|
* group (blocked, approval, warning texts), then the diagnostics count —
|
|
510
497
|
* `·`-joined, empty when clean.
|
|
511
498
|
*/
|
|
@@ -525,24 +512,12 @@ export function collectDeckNotices(state: HeaderDeckState): string[] {
|
|
|
525
512
|
return waiver ? notices.filter((notice) => notice !== waiver.notice) : notices;
|
|
526
513
|
}
|
|
527
514
|
|
|
528
|
-
function
|
|
515
|
+
function warningLine(state: HeaderDeckState, theme: Theme): string {
|
|
516
|
+
// Dynamic line, Human-directed 2026-11-11 (reorder-deck-frame): the
|
|
517
|
+
// warning exists only when there is something to say — its dotted seam
|
|
518
|
+
// exists with it. Silence renders nothing, never a reserved slot.
|
|
529
519
|
const notices = collectDeckNotices(state);
|
|
530
|
-
|
|
531
|
-
// when the ladder is silent the dim ladybug holds the right end — the
|
|
532
|
-
// deck's signature glyph and a reserved one (safeText strips from
|
|
533
|
-
// published text), so the status row always speaks: ⚠ with notices,
|
|
534
|
-
// the quiet ladybug when all is well. Ladybug-session-id amendment
|
|
535
|
-
// (Human-directed 2026-10-20): a clean frame with a known session id
|
|
536
|
-
// wears it between two ladybugs — blue bugs, purple id — so the deck
|
|
537
|
-
// doubles as a track-and-restore handle; the plain dim bug stays when
|
|
538
|
-
// the id is unknown or the frame is too narrow for the cluster.
|
|
539
|
-
if (notices.length === 0) {
|
|
540
|
-
const id = state.sessionShortId;
|
|
541
|
-
if (id !== undefined && width >= SENTINEL_ID_MIN_WIDTH) {
|
|
542
|
-
return `${color(theme, 'accent', SENTINEL_BUG)} ${color(theme, 'thinkingHigh', id)} ${color(theme, 'accent', SENTINEL_BUG)}`;
|
|
543
|
-
}
|
|
544
|
-
return color(theme, 'dim', SENTINEL_BUG);
|
|
545
|
-
}
|
|
520
|
+
if (notices.length === 0) return '';
|
|
546
521
|
// Blocked carries the error severity it wore on the deleted lifecycle
|
|
547
522
|
// tile; every other notice renders at warning.
|
|
548
523
|
const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
|
|
@@ -621,7 +596,7 @@ function deckCornerRight(state: HeaderDeckState, theme: Theme, width: number): s
|
|
|
621
596
|
return width - visibleWidth(engine) - 1 >= 20 ? engine : '';
|
|
622
597
|
}
|
|
623
598
|
|
|
624
|
-
function
|
|
599
|
+
function machineCornerNotices(
|
|
625
600
|
state: HeaderDeckState,
|
|
626
601
|
theme: Theme,
|
|
627
602
|
width: number,
|
|
@@ -777,21 +752,23 @@ export function renderHeaderDeck(
|
|
|
777
752
|
boundedWidth,
|
|
778
753
|
)
|
|
779
754
|
: '';
|
|
780
|
-
// deck-reshape, swap amendment
|
|
781
|
-
// tail on the left
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
const
|
|
786
|
-
const
|
|
755
|
+
// deck-reshape, swap amendment (reorder-deck-frame 2026-11-11): the
|
|
756
|
+
// bottom row carries the MACHINE tail on the left and THE FOCUS SPEC
|
|
757
|
+
// on the right — the room aim's change slug, read as-is from
|
|
758
|
+
// `openspec/.focus`. An aimless room wears the gray placeholder;
|
|
759
|
+
// nothing else ever sits in this seat.
|
|
760
|
+
const focusSpec = safeText(state.header?.title?.work?.change ?? '');
|
|
761
|
+
const aimRight =
|
|
762
|
+
focusSpec === ''
|
|
763
|
+
? color(theme, 'dim', '(unfocus openspec)')
|
|
764
|
+
: color(theme, 'success', `${OPEN_SPEC_ICON} ${focusSpec}`);
|
|
765
|
+
const machineRow = machineCornerNotices(
|
|
787
766
|
state,
|
|
788
767
|
theme,
|
|
789
|
-
|
|
768
|
+
boundedWidth - visibleWidth(aimRight) - 1,
|
|
790
769
|
);
|
|
791
|
-
const engineRow =
|
|
792
|
-
|
|
793
|
-
? ''
|
|
794
|
-
: alignedSingleRow(machineRow, statusRight, boundedWidth);
|
|
770
|
+
const engineRow = alignedSingleRow(machineRow, aimRight, boundedWidth);
|
|
771
|
+
const warning = warningLine(state, theme);
|
|
795
772
|
|
|
796
773
|
// Band adjacency (Human-directed, accepted mockups; third 2.4
|
|
797
774
|
// amendment): the deck opens with a full ─ rule directly beneath the
|
|
@@ -819,6 +796,7 @@ export function renderHeaderDeck(
|
|
|
819
796
|
...(bandSpeaks ? [activityRow] : []),
|
|
820
797
|
...spanRows,
|
|
821
798
|
...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
|
|
799
|
+
...(warning.trim() !== '' ? [dottedRule, warning] : []),
|
|
822
800
|
];
|
|
823
801
|
return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
|
|
824
802
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deck-side defensive parser for the title-state contract
|
|
2
|
+
* Deck-side defensive parser for the title-state contract v2
|
|
3
3
|
* (`deck-title-state`, published as the `title` field on
|
|
4
4
|
* `galactica-status:header` with the per-turn `turn` meter beside it).
|
|
5
5
|
*
|
|
@@ -12,8 +12,6 @@
|
|
|
12
12
|
* the legacy selection/activity rendering.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { compactActivityAge } from './activity-age.ts';
|
|
16
|
-
|
|
17
15
|
export const DECK_TITLE_STATE_VERSION = 2 as const;
|
|
18
16
|
|
|
19
17
|
export const DECK_TITLE_ACTIVITY_CLASSES = [
|
|
@@ -31,36 +29,23 @@ export type DeckTitleActivityClass = (typeof DECK_TITLE_ACTIVITY_CLASSES)[number
|
|
|
31
29
|
* render-time width crop owns the real limit now). */
|
|
32
30
|
export const DECK_TITLE_TARGET_MAX = 120;
|
|
33
31
|
|
|
34
|
-
/** Task/step totals bound from the accepted schema. */
|
|
35
|
-
export const DECK_TITLE_TOTAL_MAX = 999;
|
|
36
|
-
|
|
37
32
|
const SUBJECT_MAX = 180;
|
|
38
33
|
const IDENTIFIER_MAX = 120;
|
|
39
34
|
const TASK_ID_MAX = 32;
|
|
40
35
|
const SPAN_ID_MAX = 64;
|
|
41
36
|
const WAIVER_ID_MAX = 64;
|
|
42
37
|
const NOTICE_MAX = 180;
|
|
43
|
-
const ARTIFACTS_MAX = 16;
|
|
44
38
|
const TURN_TOKENS_MAX = 999_999_999;
|
|
45
39
|
const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
46
40
|
|
|
47
|
-
export type DeckTitleArtifactState = {
|
|
48
|
-
id: string;
|
|
49
|
-
done: boolean;
|
|
50
|
-
items?: { done: number; total: number };
|
|
51
|
-
};
|
|
52
|
-
|
|
53
41
|
export type DeckTitleWorkState = {
|
|
54
42
|
change: string;
|
|
43
|
+
/** Room-aim verbatim fields (observe-room-aim render law): printed
|
|
44
|
+
* as-is — the deck is a printer, not a translator. */
|
|
45
|
+
artifactName?: string;
|
|
46
|
+
artifactIdx?: string;
|
|
55
47
|
task?: string;
|
|
56
|
-
|
|
57
|
-
artifacts?: DeckTitleArtifactState[];
|
|
58
|
-
/** The artifact the work is in — first not done, else last done. */
|
|
59
|
-
currentArtifact?: string;
|
|
60
|
-
/** Attention facts, derived from publisher-owned truth only. */
|
|
61
|
-
awaitingValidation?: boolean;
|
|
62
|
-
focusAt?: number;
|
|
63
|
-
focusStale?: boolean;
|
|
48
|
+
nextStep?: string;
|
|
64
49
|
};
|
|
65
50
|
|
|
66
51
|
export type DeckTitleActivityState = {
|
|
@@ -100,15 +85,6 @@ function boundedText(
|
|
|
100
85
|
return points.join('');
|
|
101
86
|
}
|
|
102
87
|
|
|
103
|
-
function boundedTotal(value: unknown): number | null {
|
|
104
|
-
return typeof value === 'number' &&
|
|
105
|
-
Number.isInteger(value) &&
|
|
106
|
-
value >= 0 &&
|
|
107
|
-
value <= DECK_TITLE_TOTAL_MAX
|
|
108
|
-
? value
|
|
109
|
-
: null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
88
|
function parseTitleActivity(value: unknown): DeckTitleActivityState | null | undefined {
|
|
113
89
|
if (value === null) return null;
|
|
114
90
|
if (!isRecord(value)) return undefined;
|
|
@@ -134,66 +110,25 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
134
110
|
if (!isRecord(value)) return undefined;
|
|
135
111
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
136
112
|
if (!change) return undefined;
|
|
137
|
-
const
|
|
138
|
-
if (task === null) return undefined;
|
|
139
|
-
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
113
|
+
const artifactName = boundedText(value.artifactName, IDENTIFIER_MAX, {
|
|
140
114
|
required: false,
|
|
141
115
|
});
|
|
142
|
-
if (
|
|
143
|
-
const
|
|
116
|
+
if (artifactName === null) return undefined;
|
|
117
|
+
const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
|
|
144
118
|
required: false,
|
|
145
119
|
});
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (typeof entry.done !== 'boolean') return undefined;
|
|
158
|
-
const artifactDone = entry.done;
|
|
159
|
-
let items: { done: number; total: number } | undefined;
|
|
160
|
-
if ('items' in entry) {
|
|
161
|
-
if (!isRecord(entry.items)) return undefined;
|
|
162
|
-
const done = boundedTotal(entry.items.done);
|
|
163
|
-
const total = boundedTotal(entry.items.total);
|
|
164
|
-
if (done === null || total === null) return undefined;
|
|
165
|
-
items = { done, total };
|
|
166
|
-
}
|
|
167
|
-
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
168
|
-
}
|
|
169
|
-
artifacts = parsed;
|
|
170
|
-
}
|
|
171
|
-
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
let focusAt: number | undefined;
|
|
175
|
-
if ('focusAt' in value) {
|
|
176
|
-
if (
|
|
177
|
-
typeof value.focusAt !== 'number' ||
|
|
178
|
-
!Number.isSafeInteger(value.focusAt) ||
|
|
179
|
-
value.focusAt < 0
|
|
180
|
-
) {
|
|
181
|
-
return undefined;
|
|
182
|
-
}
|
|
183
|
-
focusAt = value.focusAt;
|
|
184
|
-
}
|
|
185
|
-
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
188
|
-
const extras = {
|
|
189
|
-
...(schemaName ? { schemaName } : {}),
|
|
190
|
-
...(artifacts ? { artifacts } : {}),
|
|
191
|
-
...(currentArtifact ? { currentArtifact } : {}),
|
|
192
|
-
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
193
|
-
...(focusAt !== undefined ? { focusAt } : {}),
|
|
194
|
-
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
120
|
+
if (artifactIdx === null) return undefined;
|
|
121
|
+
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
122
|
+
if (task === null) return undefined;
|
|
123
|
+
const nextStep = boundedText(value.nextStep, NOTICE_MAX, { required: false });
|
|
124
|
+
if (nextStep === null) return undefined;
|
|
125
|
+
return {
|
|
126
|
+
change,
|
|
127
|
+
...(artifactName ? { artifactName } : {}),
|
|
128
|
+
...(artifactIdx ? { artifactIdx } : {}),
|
|
129
|
+
...(task ? { task } : {}),
|
|
130
|
+
...(nextStep ? { nextStep } : {}),
|
|
195
131
|
};
|
|
196
|
-
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
197
132
|
}
|
|
198
133
|
|
|
199
134
|
function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
|
|
@@ -265,68 +200,60 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
265
200
|
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
266
201
|
|
|
267
202
|
/**
|
|
268
|
-
* The Work label as colored segments — the
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
203
|
+
* The Work label as colored segments — the render law (observe-room-aim
|
|
204
|
+
* 2026-11-11), the room aim printed verbatim:
|
|
205
|
+
*
|
|
206
|
+
* `{change} ⟩ {artifact_name} {artifact-idx} › {task} ⟩ {next-step}`
|
|
207
|
+
*
|
|
208
|
+
* Read as-is, printed as-is: the deck is a printer, not a translator.
|
|
209
|
+
* No prefixes, no relabeling, no mapping into cockpit words. A missing
|
|
210
|
+
* field drops its segment and the separator that led it; absence never
|
|
211
|
+
* becomes zero. Compact width yields the dim `next-step` first —
|
|
212
|
+
* identity and progress survive longest. Separators are chrome with
|
|
213
|
+
* hard deck-wide colors — `›` accent like every minor separator.
|
|
278
214
|
*/
|
|
215
|
+
/** The OpenSpec mark — nf-md file-document-outline. One constant: the
|
|
216
|
+
* glyph is a taste choice, the placement is law (both lines). */
|
|
217
|
+
export const OPEN_SPEC_ICON = '';
|
|
218
|
+
|
|
279
219
|
export function deckWorkSegments(
|
|
280
220
|
work: DeckTitleWorkState,
|
|
281
|
-
now: number = Date.now(),
|
|
282
221
|
compact = false,
|
|
283
222
|
): DeckWorkSegment[] {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
223
|
+
// Human amendment (reorder-deck-frame, 2026-11-11): the slug moved to
|
|
224
|
+
// the engine row — the focus spec's home. Line 2 carries the progress
|
|
225
|
+
// fields and the dim action hint:
|
|
226
|
+
// `{icon} {artifact_name} {artifact-idx} › {task} ⟩ {next-step}` — read
|
|
227
|
+
// as-is, printed as-is; the icon leads the first rendered segment; the
|
|
228
|
+
// dim hint yields first on narrow frames.
|
|
287
229
|
const segments: DeckWorkSegment[] = [];
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
});
|
|
295
|
-
} else {
|
|
296
|
-
const artifacts = work.artifacts;
|
|
297
|
-
const current = work.currentArtifact;
|
|
298
|
-
if (artifacts && artifacts.length > 0 && current) {
|
|
299
|
-
const done = artifacts.filter((artifact) => artifact.done).length;
|
|
300
|
-
const currentItems = artifacts.find(
|
|
301
|
-
(artifact) => artifact.id === current,
|
|
302
|
-
)?.items;
|
|
303
|
-
const itemsSettled =
|
|
304
|
-
currentItems === undefined ||
|
|
305
|
-
currentItems.total === 0 ||
|
|
306
|
-
currentItems.done >= currentItems.total;
|
|
307
|
-
// One hue per state: success only when the artifacts are done AND
|
|
308
|
-
// the current artifact's items are settled — otherwise work is open.
|
|
309
|
-
const complete = done === artifacts.length && itemsSettled;
|
|
310
|
-
segments.push({
|
|
311
|
-
text: `${current} ${done}/${artifacts.length}`,
|
|
312
|
-
color: complete ? 'success' : 'accent',
|
|
313
|
-
});
|
|
314
|
-
}
|
|
230
|
+
let iconPending = true;
|
|
231
|
+
const lead = (segment: DeckWorkSegment): void => {
|
|
232
|
+
if (iconPending) {
|
|
233
|
+
iconPending = false;
|
|
234
|
+
segments.push({ ...segment, text: `${OPEN_SPEC_ICON} ${segment.text}` });
|
|
235
|
+
return;
|
|
315
236
|
}
|
|
237
|
+
segments.push(segment);
|
|
238
|
+
};
|
|
239
|
+
if (work.artifactName) {
|
|
240
|
+
lead({
|
|
241
|
+
text: work.artifactIdx
|
|
242
|
+
? `${work.artifactName} ${work.artifactIdx}`
|
|
243
|
+
: work.artifactName,
|
|
244
|
+
color: 'accent',
|
|
245
|
+
});
|
|
316
246
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
: undefined;
|
|
320
|
-
if (items && items.total > 0) {
|
|
321
|
-
const complete = items.done >= items.total;
|
|
247
|
+
if (work.task) {
|
|
248
|
+
// A separator only follows a segment — a leading one dangles.
|
|
322
249
|
if (segments.length > 0) {
|
|
323
250
|
segments.push({ text: ' › ', color: 'accent' });
|
|
324
251
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
});
|
|
252
|
+
lead({ text: work.task, color: 'accent' });
|
|
253
|
+
}
|
|
254
|
+
if (work.nextStep && !compact) {
|
|
255
|
+
if (segments.length > 0) segments.push({ text: ' ⟩ ', color: 'accent' });
|
|
256
|
+
lead({ text: work.nextStep, color: 'dim' });
|
|
330
257
|
}
|
|
331
258
|
return segments;
|
|
332
259
|
}
|