@aemonge-dev/pi-stardeck 0.14.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 +26 -42
- package/packages/pi-stardeck/src/title-state.ts +64 -162
- package/packages/status/README.md +20 -34
- package/packages/status/index.ts +26 -155
- package/packages/status/src/active-work.ts +0 -84
- package/packages/status/src/config.ts +1 -3
- package/packages/status/src/ledger.ts +1 -7
- package/packages/status/src/openspec.ts +2 -2
- package/packages/status/src/publisher.ts +8 -78
- package/packages/status/src/reducer.ts +2 -36
- package/packages/status/src/room-aim.ts +151 -0
- package/packages/status/src/title-state.ts +42 -148
- package/packages/status/src/types.ts +1 -5
- package/src/index.ts +0 -2
- package/packages/pi-stardeck/src/activity-age.ts +0 -184
- package/packages/status/src/gate-act.ts +0 -85
- 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(
|
|
@@ -242,7 +240,7 @@ function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
|
|
|
242
240
|
if (!title) return '';
|
|
243
241
|
const work = title.work;
|
|
244
242
|
if (!work) return '';
|
|
245
|
-
return deckWorkSegments(work,
|
|
243
|
+
return deckWorkSegments(work, width < 56)
|
|
246
244
|
.map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
|
|
247
245
|
.join('');
|
|
248
246
|
}
|
|
@@ -400,7 +398,7 @@ const COMPANION_ERROR_PATTERN = /(error|fail|down|unreachable|unhealthy)/iu;
|
|
|
400
398
|
type ChipSeverity = 'healthy' | 'degraded' | 'error' | 'off';
|
|
401
399
|
|
|
402
400
|
function companionSeverity(text: string | undefined): ChipSeverity {
|
|
403
|
-
// Error-grade words
|
|
401
|
+
// Error-grade words win over degrade-grade when both appear.
|
|
404
402
|
if (text === undefined) return 'off';
|
|
405
403
|
if (COMPANION_ERROR_PATTERN.test(text)) return 'error';
|
|
406
404
|
if (COMPANION_DEGRADE_PATTERN.test(text)) return 'degraded';
|
|
@@ -471,14 +469,9 @@ function identityCluster(
|
|
|
471
469
|
const BLOCKED_NOTICE = '\u26d4 blocked';
|
|
472
470
|
const APPROVAL_NOTICE = 'approval required';
|
|
473
471
|
|
|
474
|
-
/** Narrowest frame that hosts the id-bearing sentinel cluster (12 cells
|
|
475
|
-
* plus breathing room for the machine ladder). */
|
|
476
|
-
const SENTINEL_ID_MIN_WIDTH = 24;
|
|
477
|
-
const SENTINEL_BUG = '\u{F082D}';
|
|
478
|
-
|
|
479
472
|
function companionWarningTexts(state: HeaderDeckState): string[] {
|
|
480
473
|
// Same sources as the capability-health chips: the known companions'
|
|
481
|
-
// native-status texts,
|
|
474
|
+
// native-status texts, ordered after blocked/approval.
|
|
482
475
|
const texts = new Map<string, string>();
|
|
483
476
|
for (const item of state.piStatus?.nativeStatuses ?? []) {
|
|
484
477
|
if (texts.has(item.key)) continue;
|
|
@@ -499,7 +492,7 @@ function companionWarningTexts(state: HeaderDeckState): string[] {
|
|
|
499
492
|
|
|
500
493
|
/**
|
|
501
494
|
* Aggregate the deck's scattered warning signals into one notice slot
|
|
502
|
-
* (Task 3.1).
|
|
495
|
+
* (Task 3.1). Ordered per the spec's accepted mixed render: the companion
|
|
503
496
|
* group (blocked, approval, warning texts), then the diagnostics count —
|
|
504
497
|
* `·`-joined, empty when clean.
|
|
505
498
|
*/
|
|
@@ -519,24 +512,12 @@ export function collectDeckNotices(state: HeaderDeckState): string[] {
|
|
|
519
512
|
return waiver ? notices.filter((notice) => notice !== waiver.notice) : notices;
|
|
520
513
|
}
|
|
521
514
|
|
|
522
|
-
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.
|
|
523
519
|
const notices = collectDeckNotices(state);
|
|
524
|
-
|
|
525
|
-
// when the ladder is silent the dim ladybug holds the right end — the
|
|
526
|
-
// deck's signature glyph and a reserved one (safeText strips from
|
|
527
|
-
// published text), so the status row always speaks: ⚠ with notices,
|
|
528
|
-
// the quiet ladybug when all is well. Ladybug-session-id amendment
|
|
529
|
-
// (Human-directed 2026-10-20): a clean frame with a known session id
|
|
530
|
-
// wears it between two ladybugs — blue bugs, purple id — so the deck
|
|
531
|
-
// doubles as a track-and-restore handle; the plain dim bug stays when
|
|
532
|
-
// the id is unknown or the frame is too narrow for the cluster.
|
|
533
|
-
if (notices.length === 0) {
|
|
534
|
-
const id = state.sessionShortId;
|
|
535
|
-
if (id !== undefined && width >= SENTINEL_ID_MIN_WIDTH) {
|
|
536
|
-
return `${color(theme, 'accent', SENTINEL_BUG)} ${color(theme, 'thinkingHigh', id)} ${color(theme, 'accent', SENTINEL_BUG)}`;
|
|
537
|
-
}
|
|
538
|
-
return color(theme, 'dim', SENTINEL_BUG);
|
|
539
|
-
}
|
|
520
|
+
if (notices.length === 0) return '';
|
|
540
521
|
// Blocked carries the error severity it wore on the deleted lifecycle
|
|
541
522
|
// tile; every other notice renders at warning.
|
|
542
523
|
const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
|
|
@@ -615,7 +596,7 @@ function deckCornerRight(state: HeaderDeckState, theme: Theme, width: number): s
|
|
|
615
596
|
return width - visibleWidth(engine) - 1 >= 20 ? engine : '';
|
|
616
597
|
}
|
|
617
598
|
|
|
618
|
-
function
|
|
599
|
+
function machineCornerNotices(
|
|
619
600
|
state: HeaderDeckState,
|
|
620
601
|
theme: Theme,
|
|
621
602
|
width: number,
|
|
@@ -771,21 +752,23 @@ export function renderHeaderDeck(
|
|
|
771
752
|
boundedWidth,
|
|
772
753
|
)
|
|
773
754
|
: '';
|
|
774
|
-
// deck-reshape, swap amendment
|
|
775
|
-
// tail on the left
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
const
|
|
780
|
-
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(
|
|
781
766
|
state,
|
|
782
767
|
theme,
|
|
783
|
-
|
|
768
|
+
boundedWidth - visibleWidth(aimRight) - 1,
|
|
784
769
|
);
|
|
785
|
-
const engineRow =
|
|
786
|
-
|
|
787
|
-
? ''
|
|
788
|
-
: alignedSingleRow(machineRow, statusRight, boundedWidth);
|
|
770
|
+
const engineRow = alignedSingleRow(machineRow, aimRight, boundedWidth);
|
|
771
|
+
const warning = warningLine(state, theme);
|
|
789
772
|
|
|
790
773
|
// Band adjacency (Human-directed, accepted mockups; third 2.4
|
|
791
774
|
// amendment): the deck opens with a full ─ rule directly beneath the
|
|
@@ -813,6 +796,7 @@ export function renderHeaderDeck(
|
|
|
813
796
|
...(bandSpeaks ? [activityRow] : []),
|
|
814
797
|
...spanRows,
|
|
815
798
|
...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
|
|
799
|
+
...(warning.trim() !== '' ? [dottedRule, warning] : []),
|
|
816
800
|
];
|
|
817
801
|
return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
|
|
818
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,39 +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
|
-
const GATE_ACT_MAX = 180;
|
|
45
38
|
const TURN_TOKENS_MAX = 999_999_999;
|
|
46
39
|
const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
47
40
|
|
|
48
|
-
export type DeckTitleArtifactState = {
|
|
49
|
-
id: string;
|
|
50
|
-
done: boolean;
|
|
51
|
-
items?: { done: number; total: number };
|
|
52
|
-
};
|
|
53
|
-
|
|
54
41
|
export type DeckTitleWorkState = {
|
|
55
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;
|
|
56
47
|
task?: string;
|
|
57
|
-
|
|
58
|
-
artifacts?: DeckTitleArtifactState[];
|
|
59
|
-
/** The artifact the work is in — first not done, else last done. */
|
|
60
|
-
currentArtifact?: string;
|
|
61
|
-
/** Attention facts, derived from publisher-owned truth only. */
|
|
62
|
-
awaitingValidation?: boolean;
|
|
63
|
-
focusAt?: number;
|
|
64
|
-
focusStale?: boolean;
|
|
65
|
-
/** The gate's relayed act line — dim commentary in the optional slot. */
|
|
66
|
-
gateAct?: string;
|
|
48
|
+
nextStep?: string;
|
|
67
49
|
};
|
|
68
50
|
|
|
69
51
|
export type DeckTitleActivityState = {
|
|
@@ -103,15 +85,6 @@ function boundedText(
|
|
|
103
85
|
return points.join('');
|
|
104
86
|
}
|
|
105
87
|
|
|
106
|
-
function boundedTotal(value: unknown): number | null {
|
|
107
|
-
return typeof value === 'number' &&
|
|
108
|
-
Number.isInteger(value) &&
|
|
109
|
-
value >= 0 &&
|
|
110
|
-
value <= DECK_TITLE_TOTAL_MAX
|
|
111
|
-
? value
|
|
112
|
-
: null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
88
|
function parseTitleActivity(value: unknown): DeckTitleActivityState | null | undefined {
|
|
116
89
|
if (value === null) return null;
|
|
117
90
|
if (!isRecord(value)) return undefined;
|
|
@@ -137,69 +110,25 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
137
110
|
if (!isRecord(value)) return undefined;
|
|
138
111
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
139
112
|
if (!change) return undefined;
|
|
140
|
-
const
|
|
141
|
-
if (task === null) return undefined;
|
|
142
|
-
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
113
|
+
const artifactName = boundedText(value.artifactName, IDENTIFIER_MAX, {
|
|
143
114
|
required: false,
|
|
144
115
|
});
|
|
145
|
-
if (
|
|
146
|
-
const
|
|
116
|
+
if (artifactName === null) return undefined;
|
|
117
|
+
const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
|
|
147
118
|
required: false,
|
|
148
119
|
});
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (typeof entry.done !== 'boolean') return undefined;
|
|
161
|
-
const artifactDone = entry.done;
|
|
162
|
-
let items: { done: number; total: number } | undefined;
|
|
163
|
-
if ('items' in entry) {
|
|
164
|
-
if (!isRecord(entry.items)) return undefined;
|
|
165
|
-
const done = boundedTotal(entry.items.done);
|
|
166
|
-
const total = boundedTotal(entry.items.total);
|
|
167
|
-
if (done === null || total === null) return undefined;
|
|
168
|
-
items = { done, total };
|
|
169
|
-
}
|
|
170
|
-
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
171
|
-
}
|
|
172
|
-
artifacts = parsed;
|
|
173
|
-
}
|
|
174
|
-
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
175
|
-
return undefined;
|
|
176
|
-
}
|
|
177
|
-
let focusAt: number | undefined;
|
|
178
|
-
if ('focusAt' in value) {
|
|
179
|
-
if (
|
|
180
|
-
typeof value.focusAt !== 'number' ||
|
|
181
|
-
!Number.isSafeInteger(value.focusAt) ||
|
|
182
|
-
value.focusAt < 0
|
|
183
|
-
) {
|
|
184
|
-
return undefined;
|
|
185
|
-
}
|
|
186
|
-
focusAt = value.focusAt;
|
|
187
|
-
}
|
|
188
|
-
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
189
|
-
return undefined;
|
|
190
|
-
}
|
|
191
|
-
const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
|
|
192
|
-
if (gateAct === null) return undefined;
|
|
193
|
-
const extras = {
|
|
194
|
-
...(schemaName ? { schemaName } : {}),
|
|
195
|
-
...(artifacts ? { artifacts } : {}),
|
|
196
|
-
...(currentArtifact ? { currentArtifact } : {}),
|
|
197
|
-
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
198
|
-
...(focusAt !== undefined ? { focusAt } : {}),
|
|
199
|
-
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
200
|
-
...(gateAct ? { gateAct } : {}),
|
|
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 } : {}),
|
|
201
131
|
};
|
|
202
|
-
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
203
132
|
}
|
|
204
133
|
|
|
205
134
|
function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
|
|
@@ -271,87 +200,60 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
271
200
|
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
272
201
|
|
|
273
202
|
/**
|
|
274
|
-
* The Work label as colored segments — the
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* count survives longest. Separators are chrome with hard deck-wide colors —
|
|
286
|
-
* `›` accent like every minor separator.
|
|
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.
|
|
287
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
|
+
|
|
288
219
|
export function deckWorkSegments(
|
|
289
220
|
work: DeckTitleWorkState,
|
|
290
|
-
now: number = Date.now(),
|
|
291
221
|
compact = false,
|
|
292
222
|
): DeckWorkSegment[] {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
segments.push({
|
|
305
|
-
|
|
306
|
-
text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
|
|
307
|
-
color: 'dim',
|
|
308
|
-
});
|
|
309
|
-
} else {
|
|
310
|
-
const artifacts = work.artifacts;
|
|
311
|
-
const current = work.currentArtifact;
|
|
312
|
-
if (artifacts && artifacts.length > 0 && current) {
|
|
313
|
-
const done = artifacts.filter((artifact) => artifact.done).length;
|
|
314
|
-
const currentItems = artifacts.find(
|
|
315
|
-
(artifact) => artifact.id === current,
|
|
316
|
-
)?.items;
|
|
317
|
-
const itemsSettled =
|
|
318
|
-
currentItems === undefined ||
|
|
319
|
-
currentItems.total === 0 ||
|
|
320
|
-
currentItems.done >= currentItems.total;
|
|
321
|
-
// One hue per state: success only when the artifacts are done AND
|
|
322
|
-
// the current artifact's items are settled — otherwise work is open.
|
|
323
|
-
const complete = done === artifacts.length && itemsSettled;
|
|
324
|
-
segments.push(
|
|
325
|
-
{ text: ' › ', color: 'accent' },
|
|
326
|
-
{
|
|
327
|
-
text: `${current} ${done}/${artifacts.length}`,
|
|
328
|
-
color: complete ? 'success' : 'accent',
|
|
329
|
-
},
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
// The {optional} slot: the gate's relayed act — dim commentary that
|
|
334
|
-
// yields first on narrow frames.
|
|
335
|
-
if (work.gateAct) {
|
|
336
|
-
segments.push(
|
|
337
|
-
{ text: ' › ', color: 'accent' },
|
|
338
|
-
{ text: `gate: ${work.gateAct}`, color: 'dim' },
|
|
339
|
-
);
|
|
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.
|
|
229
|
+
const segments: DeckWorkSegment[] = [];
|
|
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;
|
|
340
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
|
+
});
|
|
341
246
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
: undefined;
|
|
345
|
-
if (items && items.total > 0) {
|
|
346
|
-
const complete = items.done >= items.total;
|
|
247
|
+
if (work.task) {
|
|
248
|
+
// A separator only follows a segment — a leading one dangles.
|
|
347
249
|
if (segments.length > 0) {
|
|
348
250
|
segments.push({ text: ' › ', color: 'accent' });
|
|
349
251
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
});
|
|
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' });
|
|
355
257
|
}
|
|
356
258
|
return segments;
|
|
357
259
|
}
|