@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 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, Session Work, and diagnostic state;
32
- - **Header Deck** — current activity, focus, project state, and compact telemetry.
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
 
@@ -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 from the fresh install.
41
- Type "clear && pi --offline --no-session --no-context-files --tui-mode regular"
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.
@@ -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 OpenSpec or Session Work state;
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.13.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:work-state && npm run test:stardeck && npm run test:footer && npm run test:vim"
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
- // When the subject mirrors the focused change (the gate law's
195
- // "subject mirrors its title"), it renders success-green like the
196
- // PWD — the mirror visible at a glance.
197
- const work = title.work;
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, Date.now(), width < 56)
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 outrank degrade-grade when both appear.
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, ranked after blocked/approval.
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). Ranked per the spec's accepted mixed render: the companion
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 noticeTile(state: HeaderDeckState, theme: Theme, width: number): string {
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
- // Healthy sentinel (Human-directed 2026-10-19, reversing the retirement):
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 machineCornerLadder(
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: the bottom row carries the MACHINE
781
- // tail on the left (yielding right-to-left into whatever width the
782
- // status leaves) and the status on the right — which always speaks
783
- // now: ⚠ notices or the dim ladybug sentinel when clean. The engine
784
- // group moved to line 1 right.
785
- const statusRight = noticeTile(state, theme, boundedWidth);
786
- const machineRow = machineCornerLadder(
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
- statusRight === '' ? boundedWidth : boundedWidth - visibleWidth(statusRight) - 1,
768
+ boundedWidth - visibleWidth(aimRight) - 1,
790
769
  );
791
- const engineRow =
792
- statusRight === '' && machineRow === ''
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 v1
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
- schemaName?: string;
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 task = boundedText(value.task, TASK_ID_MAX, { required: false });
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 (schemaName === null) return undefined;
143
- const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
116
+ if (artifactName === null) return undefined;
117
+ const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
144
118
  required: false,
145
119
  });
146
- if (currentArtifact === null) return undefined;
147
- let artifacts: DeckTitleArtifactState[] | undefined;
148
- if ('artifacts' in value) {
149
- if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
150
- return undefined;
151
- }
152
- const parsed: DeckTitleArtifactState[] = [];
153
- for (const entry of value.artifacts) {
154
- if (!isRecord(entry)) return undefined;
155
- const id = boundedText(entry.id, TASK_ID_MAX);
156
- if (!id) return undefined;
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 truthful, de-duplicated tile:
269
- * `execute 4/4 › 󰄱 12/15` — current artifact + artifact progress + the
270
- * current artifact's file-truth item count. The change name lives on the
271
- * subject side and never repeats here. Attention is derived, never authored:
272
- * `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
273
- * (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
274
- * group. Unknown facts drop their segments — absent items never render as
275
- * zeros. Compact width: the attention/progress side yields first, the item
276
- * count survives longest. Separators are chrome with hard deck-wide colors —
277
- * `›` 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.
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
- if (work.awaitingValidation) {
285
- return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
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
- const stale = work.focusStale === true && work.focusAt !== undefined;
289
- if (!compact) {
290
- if (stale) {
291
- segments.push({
292
- text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
293
- color: 'dim',
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
- const items = work.currentArtifact
318
- ? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
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
- segments.push({
326
- text: `${complete ? '✔' : '󰄱'} ${items.done}/${items.total}`,
327
- color: complete ? 'success' : 'accent',
328
- ...(complete ? { bold: true } : {}),
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
  }