@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.
@@ -23,7 +23,6 @@ export const GALACTICA_HEADER_CHANNEL = 'galactica-status:header';
23
23
 
24
24
  const OPEN_SPEC_WIDGET_ID = 'galactica.openspec';
25
25
  const OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.openspec-scope-separator';
26
- const OPEN_SPEC_PROGRESS_WIDGET_ID = 'galactica.openspec-progress';
27
26
  const DIAGNOSTICS_WIDGET_ID = 'galactica.diagnostics';
28
27
  const ORCHESTRATION_WIDGET_ID = 'galactica.orchestration';
29
28
 
@@ -48,17 +47,6 @@ function compactChangeTitle(title: string, maximum: number): string {
48
47
  return shorten(normalizeChangeTitle(title), maximum);
49
48
  }
50
49
 
51
- function focusedTaskText(
52
- task: { id: string; title: string },
53
- compact: boolean,
54
- ): string {
55
- const title = normalizeInline(task.title);
56
- const step = title.match(/^(?:Step|Task)\s+(\d+(?:\.\d+)*)(?:[.:])?\s+(.+)$/iu);
57
- const id = step?.[1] ?? task.id;
58
- const body = step?.[2] ?? title;
59
- return `${id} ${compact ? shorten(body, 14) : body}`;
60
- }
61
-
62
50
  function formatDuration(ms: number | undefined): string | undefined {
63
51
  if (ms === undefined || ms < 0) return undefined;
64
52
  if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1_000))}s`;
@@ -94,11 +82,7 @@ export function formatOpenSpecOverview(overview: OpenSpecOverview): string {
94
82
  );
95
83
  }
96
84
 
97
- export function formatOpenSpec(
98
- state: OpenSpecState,
99
- focusedTaskId: string | null,
100
- compact = false,
101
- ): string {
85
+ export function formatOpenSpec(state: OpenSpecState, compact = false): string {
102
86
  // File-truth counts only — absent counts render no progress prefix at
103
87
  // all (absence never becomes zero, truthful-rendered-state).
104
88
  const progress =
@@ -115,13 +99,6 @@ export function formatOpenSpec(
115
99
 
116
100
  if (state.phase === 'complete') return `${lead}${title}${stale}`;
117
101
 
118
- const activeTask = focusedTaskId
119
- ? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
120
- : undefined;
121
- if (activeTask) {
122
- return `${lead}▶ ${focusedTaskText(activeTask, compact)}${stale}`;
123
- }
124
-
125
102
  return `${lead}${title}${stale}`;
126
103
  }
127
104
 
@@ -203,7 +180,6 @@ export function buildWidgetSnapshots(
203
180
  options: {
204
181
  compact?: boolean;
205
182
  now?: number;
206
- focusedTaskId?: string;
207
183
  openSpecFeedback?: OpenSpecFeedback;
208
184
  openSpecOverview?: OpenSpecOverview;
209
185
  openSpecProjectDetected?: boolean;
@@ -212,7 +188,6 @@ export function buildWidgetSnapshots(
212
188
  const compact = options.compact ?? false;
213
189
  const now = options.now ?? Date.now();
214
190
  const widgets: FancyFooterWidget[] = [];
215
-
216
191
  if (options.openSpecOverview || options.openSpecProjectDetected === false) {
217
192
  widgets.push({
218
193
  id: OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID,
@@ -231,29 +206,8 @@ export function buildWidgetSnapshots(
231
206
  });
232
207
  }
233
208
 
234
- if (options.openSpecOverview && options.focusedTaskId) {
235
- const overview = options.openSpecOverview;
236
- const complete =
237
- overview.totalPlans > 0 &&
238
- overview.completedPlans >= overview.totalPlans &&
239
- (overview.totalTasks === 0 || overview.completedTasks >= overview.totalTasks);
240
- const color: WidgetColor =
241
- overview.totalPlans === 0 ? 'dim' : complete ? 'success' : 'accent';
242
- widgets.push({
243
- id: OPEN_SPEC_PROGRESS_WIDGET_ID,
244
- label: 'OpenSpec progress',
245
- description: 'Aggregate completion across OpenSpec Plans and their Tasks',
246
- content: { type: 'text', text: formatOpenSpecOverview(overview) },
247
- icon: {
248
- glyphs: { nerd: '', emoji: '📋', unicode: '▤', ascii: 'P' },
249
- color,
250
- },
251
- style: { textColor: color },
252
- layout: { row: 0, position: 0, align: 'right', fill: 'none', minWidth: 8 },
253
- });
254
- }
255
- // Frozen contract: without an OpenSpec focus there is no progress surface —
256
- // no aggregate overview, no 'no OpenSpec' narration.
209
+ // Frozen contract: without a task focus there is no aggregate progress
210
+ // surface — no overview widget, no 'no OpenSpec' narration.
257
211
 
258
212
  if (!snapshot.openspec && options.openSpecFeedback) {
259
213
  const unavailable = options.openSpecFeedback === 'unavailable';
@@ -303,7 +257,7 @@ export function buildWidgetSnapshots(
303
257
  description: 'Focused OpenSpec change, task progress, and pending work',
304
258
  content: {
305
259
  type: 'text',
306
- text: formatOpenSpec(snapshot.openspec, options.focusedTaskId ?? null, compact),
260
+ text: formatOpenSpec(snapshot.openspec, compact),
307
261
  },
308
262
  icon: {
309
263
  glyphs: {
@@ -477,34 +431,18 @@ export type HeaderStatusEvent = {
477
431
  progress: { icon: string; completed: number; total: number; color: WidgetColor }[];
478
432
  };
479
433
 
480
- function narrativeTaskTitle(title: string): string {
481
- return normalizeInline(title).replace(
482
- /^(?:Step|Task)\s+\d+(?:\.\d+)*(?:[.:])?\s+/iu,
483
- '',
484
- );
485
- }
486
-
487
- function selectedOpenSpecTitles(
488
- state: OpenSpecState,
489
- focusedTaskId: string | undefined,
490
- ): string[] {
491
- const activeTask = focusedTaskId
492
- ? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
493
- : undefined;
494
- return activeTask
495
- ? [normalizeInline(state.title), narrativeTaskTitle(activeTask.title)]
496
- : [normalizeInline(state.changeId), normalizeInline(focusedTaskId ?? 'no focus')];
434
+ function selectedOpenSpecTitles(state: OpenSpecState): string[] {
435
+ return [normalizeInline(state.changeId)];
497
436
  }
498
437
 
499
438
  export function headerSelection(options: {
500
439
  openSpec?: OpenSpecState | null;
501
- focusedTaskId?: string;
502
440
  subject?: SessionSubject;
503
441
  }): HeaderSelection | null {
504
442
  if (options.openSpec) {
505
443
  return {
506
444
  source: 'openspec',
507
- titles: selectedOpenSpecTitles(options.openSpec, options.focusedTaskId),
445
+ titles: selectedOpenSpecTitles(options.openSpec),
508
446
  color: openSpecColor(options.openSpec),
509
447
  };
510
448
  }
@@ -827,7 +765,6 @@ export function buildHeaderStatusEvent(
827
765
  widgets: readonly FancyFooterWidget[],
828
766
  options: {
829
767
  openSpec?: OpenSpecState | null;
830
- focusedTaskId?: string;
831
768
  subject?: SessionSubject;
832
769
  orchestration?: OrchestrationState | null;
833
770
  lifecycle?: HeaderLifecycle;
@@ -899,7 +836,7 @@ export function buildHeaderStatusEvent(
899
836
  const state = options.openSpec;
900
837
  work = {
901
838
  lifecycle,
902
- titles: selectedOpenSpecTitles(state, options.focusedTaskId),
839
+ titles: selectedOpenSpecTitles(state),
903
840
  color: openSpecColor(state),
904
841
  ...projectedActivity,
905
842
  };
@@ -979,13 +916,6 @@ export function buildHeaderStatusEvent(
979
916
  ...(options.openSpec?.hierarchy
980
917
  ? { tasks: options.openSpec.hierarchy.tasks }
981
918
  : {}),
982
- ...(options.openSpec?.hierarchy &&
983
- options.focusedTaskId &&
984
- options.openSpec.hierarchy.stepsByTask[options.focusedTaskId]
985
- ? {
986
- steps: options.openSpec.hierarchy.stepsByTask[options.focusedTaskId],
987
- }
988
- : {}),
989
919
  ...(orchestration?.completedFiles === undefined ||
990
920
  orchestration.totalFiles === undefined
991
921
  ? {}
@@ -1,5 +1,5 @@
1
1
  import type { LedgerEvent } from './ledger.ts';
2
- import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
2
+ import type { SessionSubject } from './active-work.ts';
3
3
  import type {
4
4
  CockpitSnapshot,
5
5
  ProgressFact,
@@ -18,7 +18,6 @@ import type {
18
18
  */
19
19
 
20
20
  export type ReducerFacts = {
21
- openSpec: OpenSpecFocus;
22
21
  subject: SessionSubject;
23
22
  runs: ReadonlyMap<string, RunSpan>;
24
23
  progress: ProgressFact | null;
@@ -28,7 +27,6 @@ export const NO_SELECTION: SelectionState = { kind: 'none' };
28
27
 
29
28
  export function emptyFacts(): ReducerFacts {
30
29
  return {
31
- openSpec: { mode: 'none' },
32
30
  subject: { state: 'clear' },
33
31
  runs: new Map(),
34
32
  progress: null,
@@ -36,17 +34,7 @@ export function emptyFacts(): ReducerFacts {
36
34
  }
37
35
 
38
36
  /** The single selection resolver. Priority order is contract-frozen. */
39
- export function resolveSelection(facts: {
40
- openSpec: OpenSpecFocus;
41
- subject: SessionSubject;
42
- }): SelectionState {
43
- if (facts.openSpec.mode === 'task') {
44
- return {
45
- kind: 'openspec-task',
46
- change: facts.openSpec.change,
47
- task: facts.openSpec.taskId,
48
- };
49
- }
37
+ export function resolveSelection(facts: { subject: SessionSubject }): SelectionState {
50
38
  if (facts.subject.state === 'set') {
51
39
  return { kind: 'subject', title: facts.subject.title };
52
40
  }
@@ -61,8 +49,6 @@ function applyStageRule(span: RunSpan, stage: StageValue, declared: boolean): Ru
61
49
 
62
50
  export function applyEvent(facts: ReducerFacts, event: LedgerEvent): ReducerFacts {
63
51
  switch (event.type) {
64
- case 'focus/openspec':
65
- return { ...facts, openSpec: event.focus };
66
52
  case 'focus/subject':
67
53
  return { ...facts, subject: event.subject };
68
54
  case 'run/start': {
@@ -129,23 +115,3 @@ export function rootRun(snapshot: CockpitSnapshot): RunSpan | undefined {
129
115
  export function derivedStage(snapshot: CockpitSnapshot): StageValue {
130
116
  return rootRun(snapshot)?.stage ?? 'listening';
131
117
  }
132
-
133
- /**
134
- * Derived terminal title. `title = f(selection, stage, progress)` — never stored,
135
- * never updated independently, never a placeholder.
136
- */
137
- export function deriveTitle(snapshot: CockpitSnapshot): string {
138
- const selection = snapshot.selection;
139
- let detail: string;
140
- switch (selection.kind) {
141
- case 'openspec-task':
142
- detail = `${selection.change} › ${selection.task}`;
143
- break;
144
- case 'subject':
145
- detail = selection.title;
146
- break;
147
- default:
148
- detail = '';
149
- }
150
- return detail ? `π ${detail}` : 'π';
151
- }
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ /**
5
+ * The room aim — `openspec/.focus`, the Human's standing word for the
6
+ * room. Render law (observe-room-aim 2026-11-11): the deck reads it
7
+ * as-is and prints it as-is — a printer, not a translator. Five flat
8
+ * YAML fields; a missing field drops its segment and the separator that
9
+ * led it; absence never becomes zero; `change` is the keystone — no
10
+ * change, no aim.
11
+ */
12
+ export type RoomAim = {
13
+ change: string;
14
+ artifactName?: string;
15
+ artifactIdx?: string;
16
+ task?: string;
17
+ nextStep?: string;
18
+ /** The aim file's mtime — cache key only, never rendered. */
19
+ mtimeMs: number;
20
+ };
21
+
22
+ const CHANGE_MAX = 120;
23
+ const ARTIFACT_NAME_MAX = 120;
24
+ const COUNT_MAX = 32;
25
+ const NEXT_STEP_MAX = 180;
26
+ const WALK_LIMIT = 40;
27
+ const ABSENT_TTL_MS = 30_000;
28
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
29
+
30
+ const AIM_FIELDS = new Set([
31
+ 'change',
32
+ 'artifact-name',
33
+ 'artifact-idx',
34
+ 'task',
35
+ 'next-step',
36
+ ]);
37
+
38
+ /** Collapse control characters to spaces and trim — the one cleanliness a
39
+ * terminal printer owes; honest content passes untouched. A symmetric
40
+ * YAML quote pair is syntax, not content: `"…"` and '…' unwrap before the
41
+ * emptiness check, so `task: ""` drops its segment like any missing
42
+ * field. */
43
+ function printable(value: string): string {
44
+ const text = value.replace(CONTROL_CHARACTERS, ' ').trim();
45
+ if (
46
+ text.length >= 2 &&
47
+ ((text.startsWith('"') && text.endsWith('"')) ||
48
+ (text.startsWith("'") && text.endsWith("'")))
49
+ ) {
50
+ return text.slice(1, -1).trim();
51
+ }
52
+ return text;
53
+ }
54
+
55
+ /** Over-bound or empty values drop their field — absence, never a
56
+ * mid-token ellipsis; the Human re-aims rather than the deck inventing. */
57
+ function field(value: string | undefined, maximum: number): string | undefined {
58
+ if (value === undefined) return undefined;
59
+ const text = printable(value);
60
+ if (text.length === 0 || Array.from(text).length > maximum) return undefined;
61
+ return text;
62
+ }
63
+
64
+ /**
65
+ * Parse the aim text into its five fields. Garbage lines are ignored;
66
+ * duplicate keys — last one wins; empty or comment-only values count as
67
+ * missing. An aim without `change` is not an aim.
68
+ */
69
+ export function parseRoomAim(text: string, mtimeMs: number): RoomAim | undefined {
70
+ const values = new Map<string, string>();
71
+ for (const line of text.split('\n')) {
72
+ const trimmed = line.trim();
73
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
74
+ const colon = trimmed.indexOf(':');
75
+ if (colon <= 0) continue;
76
+ const key = trimmed.slice(0, colon).trim();
77
+ if (!AIM_FIELDS.has(key)) continue;
78
+ values.set(key, trimmed.slice(colon + 1).trim());
79
+ }
80
+ const change = field(values.get('change'), CHANGE_MAX);
81
+ if (!change) return undefined;
82
+ const artifactName = field(values.get('artifact-name'), ARTIFACT_NAME_MAX);
83
+ const artifactIdx = field(values.get('artifact-idx'), COUNT_MAX);
84
+ const task = field(values.get('task'), COUNT_MAX);
85
+ const nextStep = field(values.get('next-step'), NEXT_STEP_MAX);
86
+ return {
87
+ change,
88
+ ...(artifactName ? { artifactName } : {}),
89
+ ...(artifactIdx ? { artifactIdx } : {}),
90
+ ...(task ? { task } : {}),
91
+ ...(nextStep ? { nextStep } : {}),
92
+ mtimeMs,
93
+ };
94
+ }
95
+
96
+ function findOpenspecRoot(cwd: string): string | undefined {
97
+ let dir = resolve(cwd);
98
+ for (let step = 0; step < WALK_LIMIT; step += 1) {
99
+ if (existsSync(join(dir, 'openspec'))) return join(dir, 'openspec');
100
+ const parent = dirname(dir);
101
+ if (parent === dir) return undefined;
102
+ dir = parent;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ type Cache =
108
+ | { path: string; mtimeMs: number; aim: RoomAim | undefined }
109
+ | { path: string; absentAt: number };
110
+
111
+ let cache: Cache | undefined;
112
+
113
+ /**
114
+ * Read the room aim for `cwd`. One `stat` per refresh — the file is only
115
+ * re-read when the mtime moves; an absent file is remembered for
116
+ * ABSENT_TTL_MS so a missing aim costs no per-render stat storm.
117
+ */
118
+ export function readRoomAim(
119
+ cwd: string,
120
+ now: number = Date.now(),
121
+ ): RoomAim | undefined {
122
+ const root = findOpenspecRoot(cwd);
123
+ if (!root) return undefined;
124
+ const file = join(root, '.focus');
125
+ let mtimeMs: number;
126
+ try {
127
+ mtimeMs = statSync(file).mtimeMs;
128
+ } catch {
129
+ if (
130
+ cache &&
131
+ 'absentAt' in cache &&
132
+ cache.path === file &&
133
+ now - cache.absentAt < ABSENT_TTL_MS
134
+ ) {
135
+ return undefined;
136
+ }
137
+ cache = { path: file, absentAt: now };
138
+ return undefined;
139
+ }
140
+ if (cache && 'aim' in cache && cache.path === file && cache.mtimeMs === mtimeMs) {
141
+ return cache.aim;
142
+ }
143
+ let aim: RoomAim | undefined;
144
+ try {
145
+ aim = parseRoomAim(readFileSync(file, 'utf8'), mtimeMs);
146
+ } catch {
147
+ aim = undefined;
148
+ }
149
+ cache = { path: file, mtimeMs, aim };
150
+ return aim;
151
+ }
@@ -1,13 +1,13 @@
1
- import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
2
- import type { OpenSpecState } from './types.ts';
1
+ import type { SessionSubject } from './active-work.ts';
2
+ import type { RoomAim } from './room-aim.ts';
3
3
 
4
4
  /**
5
- * Title-state contract v1 (`deck-title-state`).
5
+ * Title-state contract v2 (`deck-title-state`).
6
6
  *
7
7
  * One authoritative object for the deck's upper half: `subject` is authored
8
- * once, `work` is derived from OpenSpec focus, `activity` is observed from
9
- * tool executions, and `waiver` suppresses one exact notice text. Rendering
10
- * code may never write any slot; it only parses defensively.
8
+ * once, `work` is the room aim's verbatim fields, `activity` is observed
9
+ * from tool executions, and `waiver` suppresses one exact notice text.
10
+ * Rendering code may never write any slot; it only parses defensively.
11
11
  */
12
12
 
13
13
  export const TITLE_STATE_VERSION = 2 as const;
@@ -28,37 +28,21 @@ export type TitleActivityClass = (typeof TITLE_ACTIVITY_CLASSES)[number];
28
28
  * render-time crop instead of pre-cutting subagent/bash labels). */
29
29
  export const TITLE_TARGET_MAX = 120;
30
30
 
31
- /** Task/step totals bound from the accepted schema. */
32
- export const TITLE_TOTAL_MAX = 999;
33
-
34
31
  const SUBJECT_MAX = 180;
35
32
  const IDENTIFIER_MAX = 120;
36
33
  const TASK_ID_MAX = 32;
37
34
  const SPAN_ID_MAX = 64;
38
35
  const WAIVER_ID_MAX = 64;
39
36
  const NOTICE_MAX = 180;
40
- const ARTIFACTS_MAX = 16;
41
- const GATE_ACT_MAX = 180;
42
-
43
- export type TitleArtifactState = {
44
- id: string;
45
- done: boolean;
46
- items?: { done: number; total: number };
47
- };
48
37
 
49
38
  export type TitleWorkState = {
50
39
  change: string;
40
+ /** Room-aim verbatim fields (observe-room-aim render law): printed
41
+ * as-is — the deck is a printer, not a translator. */
42
+ artifactName?: string;
43
+ artifactIdx?: string;
51
44
  task?: string;
52
- schemaName?: string;
53
- artifacts?: TitleArtifactState[];
54
- /** The artifact the work is in — first not done, else last done. */
55
- currentArtifact?: string;
56
- /** Attention facts, derived from publisher-owned truth only. */
57
- awaitingValidation?: boolean;
58
- focusAt?: number;
59
- focusStale?: boolean;
60
- /** The gate's relayed act line — dim commentary in the optional slot. */
61
- gateAct?: string;
45
+ nextStep?: string;
62
46
  };
63
47
 
64
48
  export type TitleActivityState = {
@@ -95,15 +79,6 @@ function boundedText(
95
79
  return points.join('');
96
80
  }
97
81
 
98
- function boundedTotal(value: unknown): number | null {
99
- return typeof value === 'number' &&
100
- Number.isInteger(value) &&
101
- value >= 0 &&
102
- value <= TITLE_TOTAL_MAX
103
- ? value
104
- : null;
105
- }
106
-
107
82
  function parseTitleActivity(value: unknown): TitleActivityState | null | undefined {
108
83
  if (value === null) return null;
109
84
  if (!isRecord(value)) return undefined;
@@ -129,69 +104,25 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
129
104
  if (!isRecord(value)) return undefined;
130
105
  const change = boundedText(value.change, IDENTIFIER_MAX);
131
106
  if (!change) return undefined;
132
- const task = boundedText(value.task, TASK_ID_MAX, { required: false });
133
- if (task === null) return undefined;
134
- const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
107
+ const artifactName = boundedText(value.artifactName, IDENTIFIER_MAX, {
135
108
  required: false,
136
109
  });
137
- if (schemaName === null) return undefined;
138
- const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
110
+ if (artifactName === null) return undefined;
111
+ const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
139
112
  required: false,
140
113
  });
141
- if (currentArtifact === null) return undefined;
142
- let artifacts: TitleArtifactState[] | undefined;
143
- if ('artifacts' in value) {
144
- if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
145
- return undefined;
146
- }
147
- const parsed: TitleArtifactState[] = [];
148
- for (const entry of value.artifacts) {
149
- if (!isRecord(entry)) return undefined;
150
- const id = boundedText(entry.id, TASK_ID_MAX);
151
- if (!id) return undefined;
152
- if (typeof entry.done !== 'boolean') return undefined;
153
- const artifactDone = entry.done;
154
- let items: { done: number; total: number } | undefined;
155
- if ('items' in entry) {
156
- if (!isRecord(entry.items)) return undefined;
157
- const done = boundedTotal(entry.items.done);
158
- const total = boundedTotal(entry.items.total);
159
- if (done === null || total === null) return undefined;
160
- items = { done, total };
161
- }
162
- parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
163
- }
164
- artifacts = parsed;
165
- }
166
- if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
167
- return undefined;
168
- }
169
- let focusAt: number | undefined;
170
- if ('focusAt' in value) {
171
- if (
172
- typeof value.focusAt !== 'number' ||
173
- !Number.isSafeInteger(value.focusAt) ||
174
- value.focusAt < 0
175
- ) {
176
- return undefined;
177
- }
178
- focusAt = value.focusAt;
179
- }
180
- if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
181
- return undefined;
182
- }
183
- const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
184
- if (gateAct === null) return undefined;
185
- const extras = {
186
- ...(schemaName ? { schemaName } : {}),
187
- ...(artifacts ? { artifacts } : {}),
188
- ...(currentArtifact ? { currentArtifact } : {}),
189
- ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
190
- ...(focusAt !== undefined ? { focusAt } : {}),
191
- ...(value.focusStale === true ? { focusStale: true } : {}),
192
- ...(gateAct ? { gateAct } : {}),
114
+ if (artifactIdx === null) return undefined;
115
+ const task = boundedText(value.task, TASK_ID_MAX, { required: false });
116
+ if (task === null) return undefined;
117
+ const nextStep = boundedText(value.nextStep, NOTICE_MAX, { required: false });
118
+ if (nextStep === null) return undefined;
119
+ return {
120
+ change,
121
+ ...(artifactName ? { artifactName } : {}),
122
+ ...(artifactIdx ? { artifactIdx } : {}),
123
+ ...(task ? { task } : {}),
124
+ ...(nextStep ? { nextStep } : {}),
193
125
  };
194
- return task === undefined ? { change, ...extras } : { change, task, ...extras };
195
126
  }
196
127
 
197
128
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -205,8 +136,8 @@ function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
205
136
 
206
137
  /**
207
138
  * Defensive parser for incoming title-state payloads. Unknown versions,
208
- * malformed shapes, and out-of-bounds values (target > 120 characters,
209
- * totals > 999) reject the whole object with `null`; it never throws.
139
+ * malformed shapes, and out-of-bounds values (target > 120 characters)
140
+ * reject the whole object with `null`; it never throws.
210
141
  */
211
142
  export function parseTitleState(value: unknown): TitleState | null {
212
143
  try {
@@ -240,72 +171,35 @@ export function shortenTitleTarget(value: string): string {
240
171
  }
241
172
 
242
173
  /**
243
- * Derive the `work` slot from the OpenSpec focus and the focused snapshot.
244
- * A task focus owns its change; without a focus a passively collected change
245
- * still reports artifact facts, just without a task id. Counts live only on
246
- * artifact `items` — file truth; absent counts are absent, never zeros.
174
+ * The aimed work slot — the room aim's five fields, verbatim (render
175
+ * law). `change` is the keystone; every other field drops when missing,
176
+ * and the deck prints what remains exactly as the file wrote it.
247
177
  */
248
- export function deriveTitleWork(
249
- focus: OpenSpecFocus,
250
- openspec: OpenSpecState | null | undefined,
251
- options: { focusAt?: number; gateAct?: string } = {},
252
- ): TitleWorkState | null {
253
- // Artifact facts come straight from the collector; nothing is invented
254
- // here — absent facts simply drop out of the contract.
255
- const extras = (state: OpenSpecState | undefined) => {
256
- if (!state) return {};
257
- return {
258
- ...(state.schemaName ? { schemaName: state.schemaName } : {}),
259
- ...(state.artifacts ? { artifacts: state.artifacts } : {}),
260
- ...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
261
- };
262
- };
263
- if (focus.mode === 'task') {
264
- const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
265
- const stale =
266
- options.focusAt !== undefined &&
267
- state?.changeModifiedAt !== undefined &&
268
- state.changeModifiedAt > options.focusAt;
269
- return {
270
- change: focus.change,
271
- task: focus.taskId,
272
- ...extras(state),
273
- ...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
274
- ...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
275
- ...(stale ? { focusStale: true } : {}),
276
- ...(options.gateAct ? { gateAct: options.gateAct } : {}),
277
- };
278
- }
279
- if (!openspec) return null;
178
+ export function aimTitleWork(aim: RoomAim): TitleWorkState {
280
179
  return {
281
- change: openspec.changeId,
282
- ...extras(openspec),
283
- ...(options.gateAct ? { gateAct: options.gateAct } : {}),
180
+ change: aim.change,
181
+ ...(aim.artifactName ? { artifactName: aim.artifactName } : {}),
182
+ ...(aim.artifactIdx ? { artifactIdx: aim.artifactIdx } : {}),
183
+ ...(aim.task ? { task: aim.task } : {}),
184
+ ...(aim.nextStep ? { nextStep: aim.nextStep } : {}),
284
185
  };
285
186
  }
286
187
 
287
188
  /**
288
- * Build the v1 title state from publisher-owned facts: the authored session
289
- * subject entry, the OpenSpec focus (plus its snapshot), and the newest
290
- * observed activity event. The waiver slot stays null until the notice slice.
189
+ * Build the title state from publisher-owned facts: the authored session
190
+ * subject entry, the room aim's verbatim work fields, and the newest
191
+ * observed activity event.
291
192
  */
292
193
  export function buildTitleState(options: {
293
194
  subject: SessionSubject;
294
- focus: OpenSpecFocus;
295
- openspec?: OpenSpecState | null;
195
+ /** The room aim — when present it owns the work slot outright. */
196
+ aim?: RoomAim | null;
296
197
  activity?: TitleActivityState | null;
297
- /** Publisher-owned timestamp of the current focus fact. */
298
- focusAt?: number;
299
- /** The gate's relayed act line for the focused change, if resolved. */
300
- gateAct?: string;
301
198
  }): TitleState {
302
199
  return {
303
200
  v: TITLE_STATE_VERSION,
304
201
  subject: options.subject.state === 'set' ? options.subject.title : null,
305
- work: deriveTitleWork(options.focus, options.openspec, {
306
- focusAt: options.focusAt,
307
- ...(options.gateAct ? { gateAct: options.gateAct } : {}),
308
- }),
202
+ work: options.aim ? aimTitleWork(options.aim) : null,
309
203
  activity: options.activity ?? null,
310
204
  waiver: null,
311
205
  };
@@ -21,7 +21,6 @@ export interface GalacticaStatusConfig {
21
21
  enabled: boolean;
22
22
  openspec: {
23
23
  enabled: boolean;
24
- change: string | null;
25
24
  };
26
25
  diagnostics: {
27
26
  enabled: boolean;
@@ -208,10 +207,7 @@ export type StageValue =
208
207
  | 'listening'
209
208
  | 'aborted';
210
209
 
211
- export type SelectionState =
212
- | { kind: 'openspec-task'; change: string; task: string }
213
- | { kind: 'subject'; title: string }
214
- | { kind: 'none' };
210
+ export type SelectionState = { kind: 'subject'; title: string } | { kind: 'none' };
215
211
 
216
212
  export type RunSpan = {
217
213
  id: string;