@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 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.14.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(
@@ -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, Date.now(), width < 56)
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 outrank degrade-grade when both appear.
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, ranked after blocked/approval.
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). Ranked per the spec's accepted mixed render: the companion
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 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.
523
519
  const notices = collectDeckNotices(state);
524
- // Healthy sentinel (Human-directed 2026-10-19, reversing the retirement):
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 machineCornerLadder(
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: the bottom row carries the MACHINE
775
- // tail on the left (yielding right-to-left into whatever width the
776
- // status leaves) and the status on the right — which always speaks
777
- // now: ⚠ notices or the dim ladybug sentinel when clean. The engine
778
- // group moved to line 1 right.
779
- const statusRight = noticeTile(state, theme, boundedWidth);
780
- 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(
781
766
  state,
782
767
  theme,
783
- statusRight === '' ? boundedWidth : boundedWidth - visibleWidth(statusRight) - 1,
768
+ boundedWidth - visibleWidth(aimRight) - 1,
784
769
  );
785
- const engineRow =
786
- statusRight === '' && machineRow === ''
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 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,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
- schemaName?: string;
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 task = boundedText(value.task, TASK_ID_MAX, { required: false });
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 (schemaName === null) return undefined;
146
- const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
116
+ if (artifactName === null) return undefined;
117
+ const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
147
118
  required: false,
148
119
  });
149
- if (currentArtifact === null) return undefined;
150
- let artifacts: DeckTitleArtifactState[] | undefined;
151
- if ('artifacts' in value) {
152
- if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
153
- return undefined;
154
- }
155
- const parsed: DeckTitleArtifactState[] = [];
156
- for (const entry of value.artifacts) {
157
- if (!isRecord(entry)) return undefined;
158
- const id = boundedText(entry.id, TASK_ID_MAX);
159
- if (!id) return undefined;
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 truthful, de-duplicated tile
275
- * under the layout law (truthful-rendered-state 2026-11-10):
276
- * `<slug> › <artifact> <done/total> › gate: <act> › 󰄱 <items>` — the
277
- * slug leads because openspec identity renders exclusively on the right
278
- * (the left band is Human-owned title), then current artifact + artifact
279
- * progress, the gate's relayed act dim in the optional slot, and the
280
- * current artifact's file-truth item count. Attention is derived, never authored:
281
- * `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
282
- * (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
283
- * group. Unknown facts drop their segments — absent items never render as
284
- * zeros. Compact width: the attention/progress side yields first, the item
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
- if (work.awaitingValidation) {
294
- return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
295
- }
296
- // The slug leads the right side — openspec identity renders exclusively
297
- // here (Human layout law 2026-11-10); the left band is Human-owned.
298
- const segments: DeckWorkSegment[] = [
299
- { text: work.change, color: 'success', bold: true },
300
- ];
301
- const stale = work.focusStale === true && work.focusAt !== undefined;
302
- if (!compact) {
303
- if (stale) {
304
- segments.push({ text: ' › ', color: 'accent' });
305
- segments.push({
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
- const items = work.currentArtifact
343
- ? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
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
- segments.push({
351
- text: `${complete ? '✔' : '󰄱'} ${items.done}/${items.total}`,
352
- color: complete ? 'success' : 'accent',
353
- ...(complete ? { bold: true } : {}),
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
  }