@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.
@@ -270,7 +270,7 @@ export function parseApplyInstructions(value: unknown): ApplyInstructionsResult
270
270
  complete: finiteNumber(value.progress.complete),
271
271
  }
272
272
  : undefined;
273
- // A blocked apply (mid-ladder, artifacts missing) reports zero progress;
273
+ // A blocked apply (part-way through, artifacts missing) reports zero progress;
274
274
  // those zeros must never shadow the honest `openspec list` counts.
275
275
  const progress =
276
276
  state !== 'blocked' && (reportedProgress?.total ?? 0) > 0
@@ -517,7 +517,6 @@ export async function collectOpenSpec(options: {
517
517
 
518
518
  let tasks: OpenSpecTask[] = [];
519
519
  let tasksPath: string | undefined;
520
- let instructionProgress: ApplyInstructionsResult['progress'];
521
520
  let hierarchy: OpenSpecHierarchyProgress | undefined;
522
521
  try {
523
522
  const instructions = parseApplyInstructions(
@@ -530,7 +529,6 @@ export async function collectOpenSpec(options: {
530
529
  );
531
530
  tasks = instructions.tasks;
532
531
  tasksPath = instructions.tasksPath;
533
- instructionProgress = instructions.progress;
534
532
 
535
533
  if (tasksPath) {
536
534
  try {
@@ -552,28 +550,38 @@ export async function collectOpenSpec(options: {
552
550
  // tile at 0/N (simplify-deck-defaults). CLI progress and list counts
553
551
  // remain fallbacks; the blocked-apply zero-guard stays for the
554
552
  // no-tasks case.
553
+ // File-truth precedence: checkbox tasks first, then done/total artifacts
554
+ // (file presence via `openspec status`) — CLI task opinion cannot count
555
+ // custom schemas and pins 0/N (truthful-rendered-state). With neither
556
+ // readable, counts stay absent — absence never becomes zero.
557
+ const artifactCounts = artifacts
558
+ ? {
559
+ done: artifacts.filter((artifact) => artifact.done).length,
560
+ total: artifacts.length,
561
+ }
562
+ : undefined;
555
563
  const completedTasks =
556
- tasks.length > 0
557
- ? tasks.filter((task) => task.done).length
558
- : Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
559
- const totalTasks =
560
- tasks.length > 0
561
- ? tasks.length
562
- : Math.max(0, Math.trunc(instructionProgress?.total ?? change.totalTasks));
564
+ tasks.length > 0 ? tasks.filter((task) => task.done).length : artifactCounts?.done;
565
+ const totalTasks = tasks.length > 0 ? tasks.length : artifactCounts?.total;
563
566
  const complete =
564
- change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
567
+ change.status === 'complete' ||
568
+ (totalTasks !== undefined && totalTasks > 0 && (completedTasks ?? 0) >= totalTasks);
565
569
 
566
570
  return {
567
571
  projectRoot,
568
572
  changeId: change.name,
569
573
  title,
570
574
  phase: complete ? 'complete' : change.status,
571
- completedTasks,
572
- totalTasks,
573
- progressPercent:
574
- totalTasks > 0
575
- ? Math.max(0, Math.min(100, Math.round((completedTasks / totalTasks) * 100)))
576
- : 0,
575
+ ...(completedTasks !== undefined ? { completedTasks } : {}),
576
+ ...(totalTasks !== undefined ? { totalTasks } : {}),
577
+ ...(totalTasks !== undefined && totalTasks > 0
578
+ ? {
579
+ progressPercent: Math.max(
580
+ 0,
581
+ Math.min(100, Math.round(((completedTasks ?? 0) / totalTasks) * 100)),
582
+ ),
583
+ }
584
+ : {}),
577
585
  pendingTasks: tasks.filter((task) => !task.done),
578
586
  allTasks: tasks,
579
587
  ...(tasksPath ? { tasksPath } : {}),
@@ -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,12 +82,14 @@ 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 {
102
- const progress = `${state.completedTasks}/${state.totalTasks}`;
85
+ export function formatOpenSpec(state: OpenSpecState, compact = false): string {
86
+ // File-truth counts only — absent counts render no progress prefix at
87
+ // all (absence never becomes zero, truthful-rendered-state).
88
+ const progress =
89
+ state.totalTasks !== undefined
90
+ ? `${state.completedTasks ?? 0}/${state.totalTasks}`
91
+ : '';
92
+ const lead = progress ? `${progress} ` : '';
103
93
  const title = compact
104
94
  ? compactChangeTitle(state.title, 26)
105
95
  : normalizeChangeTitle(state.title);
@@ -107,16 +97,9 @@ export function formatOpenSpec(
107
97
  ? ` stale ${ageText(state.meta.updatedAt, Date.now()) ?? ''}`.trimEnd()
108
98
  : '';
109
99
 
110
- if (state.phase === 'complete') return `${progress} ${title}${stale}`;
100
+ if (state.phase === 'complete') return `${lead}${title}${stale}`;
111
101
 
112
- const activeTask = focusedTaskId
113
- ? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
114
- : undefined;
115
- if (activeTask) {
116
- return `${progress} ▶ ${focusedTaskText(activeTask, compact)}${stale}`;
117
- }
118
-
119
- return `${progress} ${title}${stale}`;
102
+ return `${lead}${title}${stale}`;
120
103
  }
121
104
 
122
105
  function diagnosticsColor(state: DiagnosticsState, now: number): WidgetColor {
@@ -197,7 +180,6 @@ export function buildWidgetSnapshots(
197
180
  options: {
198
181
  compact?: boolean;
199
182
  now?: number;
200
- focusedTaskId?: string;
201
183
  openSpecFeedback?: OpenSpecFeedback;
202
184
  openSpecOverview?: OpenSpecOverview;
203
185
  openSpecProjectDetected?: boolean;
@@ -206,7 +188,6 @@ export function buildWidgetSnapshots(
206
188
  const compact = options.compact ?? false;
207
189
  const now = options.now ?? Date.now();
208
190
  const widgets: FancyFooterWidget[] = [];
209
-
210
191
  if (options.openSpecOverview || options.openSpecProjectDetected === false) {
211
192
  widgets.push({
212
193
  id: OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID,
@@ -225,29 +206,8 @@ export function buildWidgetSnapshots(
225
206
  });
226
207
  }
227
208
 
228
- if (options.openSpecOverview && options.focusedTaskId) {
229
- const overview = options.openSpecOverview;
230
- const complete =
231
- overview.totalPlans > 0 &&
232
- overview.completedPlans >= overview.totalPlans &&
233
- (overview.totalTasks === 0 || overview.completedTasks >= overview.totalTasks);
234
- const color: WidgetColor =
235
- overview.totalPlans === 0 ? 'dim' : complete ? 'success' : 'accent';
236
- widgets.push({
237
- id: OPEN_SPEC_PROGRESS_WIDGET_ID,
238
- label: 'OpenSpec progress',
239
- description: 'Aggregate completion across OpenSpec Plans and their Tasks',
240
- content: { type: 'text', text: formatOpenSpecOverview(overview) },
241
- icon: {
242
- glyphs: { nerd: '', emoji: '📋', unicode: '▤', ascii: 'P' },
243
- color,
244
- },
245
- style: { textColor: color },
246
- layout: { row: 0, position: 0, align: 'right', fill: 'none', minWidth: 8 },
247
- });
248
- }
249
- // Frozen contract: without an OpenSpec focus there is no progress surface —
250
- // 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.
251
211
 
252
212
  if (!snapshot.openspec && options.openSpecFeedback) {
253
213
  const unavailable = options.openSpecFeedback === 'unavailable';
@@ -297,7 +257,7 @@ export function buildWidgetSnapshots(
297
257
  description: 'Focused OpenSpec change, task progress, and pending work',
298
258
  content: {
299
259
  type: 'text',
300
- text: formatOpenSpec(snapshot.openspec, options.focusedTaskId ?? null, compact),
260
+ text: formatOpenSpec(snapshot.openspec, compact),
301
261
  },
302
262
  icon: {
303
263
  glyphs: {
@@ -471,34 +431,18 @@ export type HeaderStatusEvent = {
471
431
  progress: { icon: string; completed: number; total: number; color: WidgetColor }[];
472
432
  };
473
433
 
474
- function narrativeTaskTitle(title: string): string {
475
- return normalizeInline(title).replace(
476
- /^(?:Step|Task)\s+\d+(?:\.\d+)*(?:[.:])?\s+/iu,
477
- '',
478
- );
479
- }
480
-
481
- function selectedOpenSpecTitles(
482
- state: OpenSpecState,
483
- focusedTaskId: string | undefined,
484
- ): string[] {
485
- const activeTask = focusedTaskId
486
- ? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
487
- : undefined;
488
- return activeTask
489
- ? [normalizeInline(state.title), narrativeTaskTitle(activeTask.title)]
490
- : [normalizeInline(state.changeId), normalizeInline(focusedTaskId ?? 'no focus')];
434
+ function selectedOpenSpecTitles(state: OpenSpecState): string[] {
435
+ return [normalizeInline(state.changeId)];
491
436
  }
492
437
 
493
438
  export function headerSelection(options: {
494
439
  openSpec?: OpenSpecState | null;
495
- focusedTaskId?: string;
496
440
  subject?: SessionSubject;
497
441
  }): HeaderSelection | null {
498
442
  if (options.openSpec) {
499
443
  return {
500
444
  source: 'openspec',
501
- titles: selectedOpenSpecTitles(options.openSpec, options.focusedTaskId),
445
+ titles: selectedOpenSpecTitles(options.openSpec),
502
446
  color: openSpecColor(options.openSpec),
503
447
  };
504
448
  }
@@ -821,7 +765,6 @@ export function buildHeaderStatusEvent(
821
765
  widgets: readonly FancyFooterWidget[],
822
766
  options: {
823
767
  openSpec?: OpenSpecState | null;
824
- focusedTaskId?: string;
825
768
  subject?: SessionSubject;
826
769
  orchestration?: OrchestrationState | null;
827
770
  lifecycle?: HeaderLifecycle;
@@ -893,7 +836,7 @@ export function buildHeaderStatusEvent(
893
836
  const state = options.openSpec;
894
837
  work = {
895
838
  lifecycle,
896
- titles: selectedOpenSpecTitles(state, options.focusedTaskId),
839
+ titles: selectedOpenSpecTitles(state),
897
840
  color: openSpecColor(state),
898
841
  ...projectedActivity,
899
842
  };
@@ -917,16 +860,19 @@ export function buildHeaderStatusEvent(
917
860
  ...projectedActivity,
918
861
  };
919
862
  }
920
- const progress = options.openSpec?.totalTasks
921
- ? [
922
- {
923
- icon: '󰘬',
924
- completed: options.openSpec.completedTasks,
925
- total: options.openSpec.totalTasks,
926
- color: openSpecColor(options.openSpec),
927
- },
928
- ]
929
- : [];
863
+ const openSpec = options.openSpec;
864
+ const progress =
865
+ typeof openSpec?.totalTasks === 'number' &&
866
+ typeof openSpec.completedTasks === 'number'
867
+ ? [
868
+ {
869
+ icon: '󰘬',
870
+ completed: openSpec.completedTasks,
871
+ total: openSpec.totalTasks,
872
+ color: openSpecColor(openSpec),
873
+ },
874
+ ]
875
+ : [];
930
876
 
931
877
  return {
932
878
  protocol: 1,
@@ -970,13 +916,6 @@ export function buildHeaderStatusEvent(
970
916
  ...(options.openSpec?.hierarchy
971
917
  ? { tasks: options.openSpec.hierarchy.tasks }
972
918
  : {}),
973
- ...(options.openSpec?.hierarchy &&
974
- options.focusedTaskId &&
975
- options.openSpec.hierarchy.stepsByTask[options.focusedTaskId]
976
- ? {
977
- steps: options.openSpec.hierarchy.stepsByTask[options.focusedTaskId],
978
- }
979
- : {}),
980
919
  ...(orchestration?.completedFiles === undefined ||
981
920
  orchestration.totalFiles === undefined
982
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
+ }