@aemonge-dev/pi-stardeck 0.9.1 → 0.9.2

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.
@@ -44,7 +44,7 @@ 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, Goal, or Session Work state;
47
+ additive protocol-1 projection of explicit OpenSpec or Session Work state;
48
48
  suggestion is controlled vocabulary rather than generated prose. Active-run telemetry
49
49
  contains only bounded child and subagent counts derived from Pi lifecycle events. It
50
50
  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.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -41,7 +41,7 @@ The title has four styled groups: lifecycle, a structured active ancestry, focus
41
41
  optional focus detail. Each activity-path segment receives the blue minor `›`; the
42
42
  violet-pink major `⟩` divides runtime ancestry from the green narrative focus. Human
43
43
  prompt text is never converted into focus automatically. OpenSpec may add its exact Task
44
- after another green `›`. Focus selection prefers an exact OpenSpec Task, then Goal, then
44
+ after another green `›`. Focus selection prefers an exact OpenSpec Task, then
45
45
  Session Work. Active turns without a selection use the explicit `OpenSpec › no focus`,
46
46
  `no OpenSpec`, or `no focus` state supplied by the status producer; `Direct response` is
47
47
  never presented as a focus. Empty states are dim while real focus remains bold semantic
@@ -76,14 +76,14 @@ The additive header protocol projects selection independently from live work:
76
76
 
77
77
  ```ts
78
78
  selection: {
79
- source: 'openspec' | 'goal' | 'session-work';
79
+ source: 'openspec' | 'session-work';
80
80
  titles: string[]; // one or two bounded, normalized titles
81
81
  color: HeaderColor;
82
82
  } | null;
83
83
  suggestion: HeaderSuggestion | null; // controlled enum, never generated prose
84
84
  ```
85
85
 
86
- Selection priority remains exact OpenSpec Task, Goal, then Session Work. Older
86
+ Selection priority remains exact OpenSpec Task, then Session Work. Older
87
87
  protocol-1 events may use sanitized non-placeholder work titles as a legacy selection;
88
88
  explicit malformed selection is rejected rather than borrowing a fallback. The
89
89
  suggestion vocabulary is producer-owned, appears only in waiting, blocked, approval,
@@ -133,7 +133,7 @@ the otherwise missing project slot and yields before core project/work informati
133
133
  narrow terminals. Slash-prefixed Git branches collapse the prefix to its initial and cap
134
134
  display at 30 cells with `...` (`feat/persist-versioned-prompts-in-duckdb` →
135
135
  `f/persist-versioned-prompts...`). The right side shows compact OpenSpec Plan/Task
136
- progress, or the simple Goal, Focus, or `no OpenSpec` fallback when no OpenSpec
136
+ progress, or the simple Focus or `no OpenSpec` fallback when no OpenSpec
137
137
  portfolio is available. The footer reserves a four-cell visual split between alignment
138
138
  groups. OpenSpec uses icons instead of repeated labels: task-list `` for Plans and
139
139
  notebook `` for Tasks, with minor `›` between them. Their Nerd Font glyphs exactly
@@ -153,6 +153,13 @@ export function installEmptyDeckFooter(
153
153
  return () => ctx.ui.setFooter(undefined);
154
154
  }
155
155
 
156
+ /** Git-style short id of the running Pi session — 8 hex chars of the
157
+ * UUIDv7, undefined when the id does not match the expected shape. */
158
+ function shortSessionId(ctx: ExtensionContext): string | undefined {
159
+ const short = ctx.sessionManager.getSessionId().slice(0, 8);
160
+ return /^[0-9a-f]{8}$/u.test(short) ? short : undefined;
161
+ }
162
+
156
163
  export default function galacticaContextHeader(
157
164
  pi: ExtensionAPI,
158
165
  options: ContextHeaderOptions = {},
@@ -172,6 +179,7 @@ export default function galacticaContextHeader(
172
179
  let lastActivityAgeSignature = '';
173
180
  let activityAgeTimer: ReturnType<typeof setInterval> | undefined;
174
181
  let sessionGeneration = 0;
182
+ let sessionShortId: string | undefined;
175
183
  let activeCwd = '';
176
184
  let activeCtx: ExtensionContext | undefined;
177
185
  let gitRefreshInFlight = false;
@@ -493,6 +501,7 @@ export default function galacticaContextHeader(
493
501
  resourceTimer = undefined;
494
502
  ++sessionGeneration;
495
503
  activeCtx = ctx;
504
+ sessionShortId = shortSessionId(ctx);
496
505
  activeCwd = '';
497
506
  branch = '';
498
507
  gitAvailable = false;
@@ -578,6 +587,7 @@ export default function galacticaContextHeader(
578
587
  lsp: lspCapability,
579
588
  mcp: mcpCapability,
580
589
  mode: vimMode,
590
+ ...(sessionShortId !== undefined ? { sessionShortId } : {}),
581
591
  },
582
592
  width,
583
593
  theme,
@@ -2,7 +2,6 @@ import type { Theme } from '@earendil-works/pi-coding-agent';
2
2
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
3
3
 
4
4
  import type { LspCapability, McpCapability } from './capabilities.ts';
5
- import type { HeaderSelection } from './gauge.ts';
6
5
  import type {
7
6
  FooterTelemetrySnapshot,
8
7
  GitStatusSummary,
@@ -41,6 +40,8 @@ export interface HeaderDeckState {
41
40
  lsp: LspCapability | null;
42
41
  mcp: McpCapability | null;
43
42
  mode: VimMode;
43
+ /** Git-style first 8 chars of the Pi session id; worn by the sentinel. */
44
+ sessionShortId?: string;
44
45
  }
45
46
 
46
47
  const ANSI_ESCAPE = new RegExp(
@@ -167,8 +168,6 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
167
168
  // Line 2 anchor: the clock always leads the band with its icon; the
168
169
  // Subject follows when the title state carries one, and a fresh session
169
170
  // (subject null) leaves the clock leading alone — no dangling separator.
170
- // Task 3.1: the unfocused open-goal hint left this band for the notice
171
- // slot (design: "notices absorb the unfocused open-goal hint").
172
171
  const clock = `${color(theme, 'customMessageLabel', '󰠭')} ${timerTile(state, theme)}`;
173
172
  const title = state.header?.title ?? null;
174
173
  if (title) {
@@ -305,6 +304,7 @@ const ACTIVITY_LABELS: Record<HeaderActivity['kind'], string> = {
305
304
  regression: 'testing',
306
305
  verification: 'checking',
307
306
  'result-review': 'reviewing result',
307
+ answering: 'answering',
308
308
  recovery: 'recovering',
309
309
  'awaiting-validation': 'validation',
310
310
  'awaiting-input': 'input',
@@ -316,7 +316,10 @@ function activityLabel(state: HeaderDeckState): string {
316
316
  const pathLabel = safeText(pathActivity?.compact || pathActivity?.label || '');
317
317
  if (pathLabel) return pathLabel;
318
318
  const activity = state.header?.work?.activity;
319
- return activity ? ACTIVITY_LABELS[activity.kind] : '';
319
+ // `awaiting-*` kinds are the rest face — they never speak for the band
320
+ // (a focused session still retires; quiet-activity-row amendment).
321
+ if (!activity || activity.kind.startsWith('awaiting-')) return '';
322
+ return ACTIVITY_LABELS[activity.kind];
320
323
  }
321
324
 
322
325
  /** `editing deck.ts` / `reading schema.yaml` / `subagent: scout`. */
@@ -326,17 +329,45 @@ function titleActivityLabel(activity: { class: string; target: string }): string
326
329
  : `${activity.class} ${activity.target}`;
327
330
  }
328
331
 
329
- function activityTile(state: HeaderDeckState, theme: Theme): string {
330
- // Line 3 left: the observed activity owns the slot — `awaiting Human`
331
- // when idle, the legacy observed label for pre-title publishers.
332
+ /** The line-3 band speaks when either side carries a real signal:
333
+ * an observed activity (title-state or legacy) or a turn meter that
334
+ * cost tokens or time. Both idle (`awaiting Human` + `— · 0 tok` or
335
+ * no meter) is zero activity — the band and its seam retire
336
+ * (Human-directed 2026-10-20, quiet-activity-row). */
337
+ /** Live phase word from the header's kind-based activity projection —
338
+ * fills the gaps between tools (thinking, planning, checking) so the
339
+ * band never claims `awaiting Human` mid-turn. The `awaiting-*` kinds
340
+ * are the REST face (awaiting Human review/input after settle): they
341
+ * never speak for the band, or a focused session would never rest
342
+ * (quiet-activity-row amendment). */
343
+ function liveKindLabel(state: HeaderDeckState): string {
344
+ const activity = state.header?.activity ?? null;
345
+ if (!activity || activity.kind.startsWith('awaiting-')) return '';
346
+ return ACTIVITY_LABELS[activity.kind] ?? '';
347
+ }
348
+
349
+ /** The band's current left label: the precise title-state activity when a
350
+ * tool is observed, else the live phase word; empty when truly idle. */
351
+ function observedActivityLabel(state: HeaderDeckState): string {
332
352
  const title = state.header?.title ?? null;
333
353
  if (title) {
334
- return title.activity
335
- ? color(theme, 'text', titleActivityLabel(title.activity))
336
- : color(theme, 'dim', 'awaiting Human');
354
+ return title.activity ? titleActivityLabel(title.activity) : liveKindLabel(state);
337
355
  }
338
- const legacy = activityLabel(state);
339
- return legacy ? color(theme, 'text', legacy) : color(theme, 'dim', 'awaiting Human');
356
+ return activityLabel(state) || liveKindLabel(state);
357
+ }
358
+
359
+ function activityBandSpeaks(state: HeaderDeckState): boolean {
360
+ const activitySpeaks = observedActivityLabel(state) !== '';
361
+ const turn = state.header?.turn ?? null;
362
+ const costSpeaks = turn !== null && (turn.tokens > 0 || turn.elapsedMs > 0);
363
+ return activitySpeaks || costSpeaks;
364
+ }
365
+
366
+ function activityTile(state: HeaderDeckState, theme: Theme): string {
367
+ // Line 3 left: the observed activity owns the slot — the live phase word
368
+ // between tools, `awaiting Human` only when the session truly rests.
369
+ const label = observedActivityLabel(state);
370
+ return label ? color(theme, 'text', label) : color(theme, 'dim', 'awaiting Human');
340
371
  }
341
372
 
342
373
  function formatTurnElapsed(ms: number): string {
@@ -478,7 +509,7 @@ function identityCluster(
478
509
  // the bare→bare chip spacing).
479
510
  const texts = new Map<string, string>();
480
511
  for (const item of state.piStatus?.nativeStatuses ?? []) {
481
- if (item.key === 'goal' || texts.has(item.key)) continue;
512
+ if (texts.has(item.key)) continue;
482
513
  texts.set(item.key, item.text);
483
514
  }
484
515
  const parts: string[] = [];
@@ -512,12 +543,17 @@ function identityCluster(
512
543
  const BLOCKED_NOTICE = '\u26d4 blocked';
513
544
  const APPROVAL_NOTICE = 'approval required';
514
545
 
546
+ /** Narrowest frame that hosts the id-bearing sentinel cluster (12 cells
547
+ * plus breathing room for the machine ladder). */
548
+ const SENTINEL_ID_MIN_WIDTH = 24;
549
+ const SENTINEL_BUG = '\u{F082D}';
550
+
515
551
  function companionWarningTexts(state: HeaderDeckState): string[] {
516
552
  // Same sources as the capability-health chips: the known companions'
517
- // native-status texts, goal key filtered, ranked after blocked/approval.
553
+ // native-status texts, ranked after blocked/approval.
518
554
  const texts = new Map<string, string>();
519
555
  for (const item of state.piStatus?.nativeStatuses ?? []) {
520
- if (item.key === 'goal' || texts.has(item.key)) continue;
556
+ if (texts.has(item.key)) continue;
521
557
  texts.set(item.key, item.text);
522
558
  }
523
559
  const warnings: string[] = [];
@@ -535,17 +571,13 @@ function companionWarningTexts(state: HeaderDeckState): string[] {
535
571
 
536
572
  /**
537
573
  * Aggregate the deck's scattered warning signals into one notice slot
538
- * (Task 3.1). Ranked per the spec's accepted mixed render: the unfocused
539
- * open-goal hint, then the companion group (blocked, approval, warning
540
- * texts), then the diagnostics count — `·`-joined, empty when clean.
574
+ * (Task 3.1). Ranked per the spec's accepted mixed render: the companion
575
+ * group (blocked, approval, warning texts), then the diagnostics count —
576
+ * `·`-joined, empty when clean.
541
577
  */
542
578
  export function collectDeckNotices(state: HeaderDeckState): string[] {
543
579
  const header = state.header;
544
- const openGoals = header?.selection?.source === 'goal' ? 0 : (header?.openGoals ?? 0);
545
580
  const notices: string[] = [];
546
- if (openGoals > 0) {
547
- notices.push(`${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`);
548
- }
549
581
  if (header?.blocked) notices.push(BLOCKED_NOTICE);
550
582
  if (header?.approvalRequired) notices.push(APPROVAL_NOTICE);
551
583
  notices.push(...companionWarningTexts(state));
@@ -559,18 +591,28 @@ export function collectDeckNotices(state: HeaderDeckState): string[] {
559
591
  return waiver ? notices.filter((notice) => notice !== waiver.notice) : notices;
560
592
  }
561
593
 
562
- function noticeTile(state: HeaderDeckState, theme: Theme): string {
594
+ function noticeTile(state: HeaderDeckState, theme: Theme, width: number): string {
563
595
  const notices = collectDeckNotices(state);
564
596
  // Healthy sentinel (Human-directed 2026-10-19, reversing the retirement):
565
597
  // when the ladder is silent the dim ladybug holds the right end — the
566
598
  // deck's signature glyph and a reserved one (safeText strips 󰠭 from
567
599
  // published text), so the status row always speaks: ⚠ with notices,
568
- // the quiet ladybug when all is well.
569
- if (notices.length === 0) return color(theme, 'dim', '󰠭');
600
+ // the quiet ladybug when all is well. Ladybug-session-id amendment
601
+ // (Human-directed 2026-10-20): a clean frame with a known session id
602
+ // wears it between two ladybugs — blue bugs, purple id — so the deck
603
+ // doubles as a track-and-restore handle; the plain dim bug stays when
604
+ // the id is unknown or the frame is too narrow for the cluster.
605
+ if (notices.length === 0) {
606
+ const id = state.sessionShortId;
607
+ if (id !== undefined && width >= SENTINEL_ID_MIN_WIDTH) {
608
+ return `${color(theme, 'accent', SENTINEL_BUG)} ${color(theme, 'thinkingHigh', id)} ${color(theme, 'accent', SENTINEL_BUG)}`;
609
+ }
610
+ return color(theme, 'dim', SENTINEL_BUG);
611
+ }
570
612
  // Blocked carries the error severity it wore on the deleted lifecycle
571
613
  // tile; every other notice renders at warning.
572
614
  const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
573
- return color(theme, semantic, `⚠ ${notices.join(' · ')}`);
615
+ return color(theme, semantic, `\u26a0 ${notices.join(' · ')}`);
574
616
  }
575
617
 
576
618
  export function machineCornerSegments(
@@ -761,65 +803,12 @@ function statusAlertTile(
761
803
  return visibleWidth(count) < width ? count : '';
762
804
  }
763
805
 
764
- function formatGoalElapsed(seconds: number): string {
765
- if (seconds < 60) return `${Math.floor(seconds)}s`;
766
- const totalMinutes = Math.floor(seconds / 60);
767
- if (totalMinutes < 60) return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
768
- return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
769
- }
770
-
771
806
  function formatTokens(tokens: number): string {
772
807
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
773
808
  if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
774
809
  return `${Math.floor(tokens)}`;
775
810
  }
776
811
 
777
- const GOAL_STATUS_COLORS: Record<string, string> = {
778
- active: 'accent',
779
- complete: 'success',
780
- paused: 'warning',
781
- blocked: 'error',
782
- usage_limited: 'warning',
783
- budget_limited: 'warning',
784
- queued: 'dim',
785
- };
786
-
787
- function goalBadgeTile(
788
- goal: NonNullable<HeaderSelection['goal']>,
789
- theme: Theme,
790
- ): string {
791
- const semantic = GOAL_STATUS_COLORS[goal.status] ?? 'text';
792
- const elapsed =
793
- goal.elapsedSeconds !== undefined
794
- ? ` · ${formatGoalElapsed(goal.elapsedSeconds)}`
795
- : '';
796
- return `${color(theme, semantic, `● ${goal.status}`)}${color(theme, 'dim', elapsed)}`;
797
- }
798
-
799
- function goalTreeLines(
800
- goal: NonNullable<HeaderSelection['goal']>,
801
- width: number,
802
- theme: Theme,
803
- ): string[] {
804
- if (width < 56) return [];
805
- const rows: string[] = [];
806
- if (goal.tasksTotal !== undefined && goal.tasksDone !== undefined) {
807
- const active =
808
- goal.tasksActive !== undefined && goal.tasksActive > 0
809
- ? ` · ${goal.tasksActive} active`
810
- : '';
811
- rows.push(` ├ tasks ${goal.tasksDone}/${goal.tasksTotal} done${active}`);
812
- }
813
- if (goal.tokensUsed !== undefined) {
814
- const budget =
815
- goal.tokenBudget !== undefined && goal.tokenBudget > 0
816
- ? ` · budget ${Math.min(999, Math.round((goal.tokensUsed / goal.tokenBudget) * 100))}%`
817
- : '';
818
- rows.push(` └ usage ${formatTokens(goal.tokensUsed)} tokens${budget}`);
819
- }
820
- return rows.map((line) => color(theme, 'dim', line));
821
- }
822
-
823
812
  export function renderHeaderDeck(
824
813
  state: HeaderDeckState,
825
814
  width: number,
@@ -832,40 +821,34 @@ export function renderHeaderDeck(
832
821
  const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
833
822
  const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
834
823
  // Line 2 (four-line deck): the clock and Subject lead with Work on the
835
- // right end; goal progress rows keep their home beneath this line.
836
- // Narrow frames: the Work slot yields before the clock anchor does.
837
- const goalDetail =
838
- state.header?.selection?.source === 'goal'
839
- ? state.header.selection.goal
840
- : undefined;
841
- const titleRight =
842
- boundedWidth >= 40
843
- ? goalDetail
844
- ? goalBadgeTile(goalDetail, theme)
845
- : workTile(state, theme)
846
- : '';
824
+ // right end. Narrow frames: the Work slot yields before the clock anchor
825
+ // does.
826
+ const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
847
827
  const focusLines = titleBandLines(state, boundedWidth, theme);
848
828
  const titleRows = [
849
829
  alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
850
830
  ...focusLines.slice(1),
851
831
  ];
852
- const goalRows = goalDetail ? goalTreeLines(goalDetail, boundedWidth, theme) : [];
853
832
 
854
833
  // Line 3 (four-line deck): Activity left with the per-turn cost right —
855
834
  // the superseded lifecycle/suggestion/runs tiles are gone; Work,
856
835
  // Activity, and the spans rows are their replacements. The spans rows
857
836
  // keep their home directly beneath this line.
858
- const activityRow = alignedSingleRow(
859
- activityTile(state, theme),
860
- turnCostTile(state, theme),
861
- boundedWidth,
862
- );
837
+ const spanRows = spanTreeRows(state, boundedWidth, theme);
838
+ const bandSpeaks = activityBandSpeaks(state);
839
+ const activityRow = bandSpeaks
840
+ ? alignedSingleRow(
841
+ activityTile(state, theme),
842
+ turnCostTile(state, theme),
843
+ boundedWidth,
844
+ )
845
+ : '';
863
846
  // deck-reshape, swap amendment: the bottom row carries the MACHINE
864
847
  // tail on the left (yielding right-to-left into whatever width the
865
848
  // status leaves) and the status on the right — which always speaks
866
849
  // now: ⚠ notices or the dim ladybug sentinel when clean. The engine
867
850
  // group moved to line 1 right.
868
- const statusRight = noticeTile(state, theme);
851
+ const statusRight = noticeTile(state, theme, boundedWidth);
869
852
  const machineRow = machineCornerLadder(
870
853
  state,
871
854
  theme,
@@ -879,7 +862,9 @@ export function renderHeaderDeck(
879
862
  // Band adjacency (Human-directed, accepted mockups; third 2.4
880
863
  // amendment): the deck opens with a full ─ rule directly beneath the
881
864
  // line-1 strip; the internal seams stay dotted ┈ between lines 2 and
882
- // 3 and between line 3 (spans rows included) and the status row.
865
+ // 3 and between line 3 (spans rows included) and the status row —
866
+ // except the 2/3 seam retires with a silent, spanless line-3 band
867
+ // (quiet-activity-row): idle + zero cost never narrates itself.
883
868
  const identity = identityCluster(state, theme);
884
869
  const corner = deckCornerRight(state, theme, boundedWidth);
885
870
  const alert = statusAlertTile(
@@ -896,10 +881,9 @@ export function renderHeaderDeck(
896
881
  ),
897
882
  solidRule,
898
883
  ...titleRows,
899
- ...goalRows,
900
- dottedRule,
901
- activityRow,
902
- ...spanTreeRows(state, boundedWidth, theme),
884
+ ...(bandSpeaks || spanRows.length > 0 ? [dottedRule] : []),
885
+ ...(bandSpeaks ? [activityRow] : []),
886
+ ...spanRows,
903
887
  ...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
904
888
  ];
905
889
  return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));
@@ -62,6 +62,7 @@ export const HEADER_ACTIVITY_KINDS = [
62
62
  'regression',
63
63
  'verification',
64
64
  'result-review',
65
+ 'answering',
65
66
  'recovery',
66
67
  'awaiting-validation',
67
68
  'awaiting-input',
@@ -97,27 +98,15 @@ export type HeaderWork = {
97
98
 
98
99
  export const HEADER_SELECTION_SOURCES = [
99
100
  'openspec',
100
- 'goal',
101
101
  'session-work',
102
102
  'subject',
103
103
  'legacy',
104
104
  ] as const;
105
105
 
106
- export type HeaderSelectionGoalDetail = {
107
- status: string;
108
- elapsedSeconds?: number;
109
- tasksDone?: number;
110
- tasksActive?: number;
111
- tasksTotal?: number;
112
- tokensUsed?: number;
113
- tokenBudget?: number;
114
- };
115
-
116
106
  export type HeaderSelection = {
117
107
  source: (typeof HEADER_SELECTION_SOURCES)[number];
118
108
  titles: string[];
119
109
  color: HeaderColor;
120
- goal?: HeaderSelectionGoalDetail;
121
110
  };
122
111
 
123
112
  export const HEADER_SUGGESTIONS = [
@@ -131,7 +120,6 @@ export const HEADER_SUGGESTIONS = [
131
120
  export type HeaderSuggestion = (typeof HEADER_SUGGESTIONS)[number];
132
121
 
133
122
  export type HeaderSnapshot = {
134
- openGoals?: number;
135
123
  activity?: HeaderActivity | null;
136
124
  selection?: HeaderSelection | null;
137
125
  suggestion?: HeaderSuggestion | null;
@@ -954,46 +942,10 @@ function parseHeaderSelection(value: unknown): HeaderSelection | null {
954
942
  });
955
943
  if (titles.length !== candidate.titles.length) return null;
956
944
 
957
- let goal: HeaderSelectionGoalDetail | undefined;
958
- if (
959
- candidate.source === 'goal' &&
960
- candidate.goal &&
961
- typeof candidate.goal === 'object' &&
962
- !Array.isArray(candidate.goal)
963
- ) {
964
- const raw = candidate.goal as Record<string, unknown>;
965
- const boundedNumber = (input: unknown): number | undefined =>
966
- typeof input === 'number' &&
967
- Number.isFinite(input) &&
968
- input >= 0 &&
969
- input <= 9_999_999_999
970
- ? input
971
- : undefined;
972
- if (typeof raw.status === 'string' && raw.status.length <= 24) {
973
- const detail: HeaderSelectionGoalDetail = { status: raw.status };
974
- const elapsed = boundedNumber(raw.elapsedSeconds);
975
- if (elapsed !== undefined) detail.elapsedSeconds = elapsed;
976
- for (const [key, value] of [
977
- ['tasksDone', raw.tasksDone],
978
- ['tasksActive', raw.tasksActive],
979
- ['tasksTotal', raw.tasksTotal],
980
- ['tokenBudget', raw.tokenBudget],
981
- ] as const) {
982
- const parsed = boundedNumber(value);
983
- if (parsed !== undefined && parsed <= 9_999) {
984
- detail[key] = parsed;
985
- }
986
- }
987
- const tokensUsed = boundedNumber(raw.tokensUsed);
988
- if (tokensUsed !== undefined) detail.tokensUsed = tokensUsed;
989
- goal = detail;
990
- }
991
- }
992
945
  return {
993
946
  source: candidate.source as HeaderSelection['source'],
994
947
  titles,
995
948
  color: candidate.color,
996
- ...(goal !== undefined ? { goal } : {}),
997
949
  };
998
950
  }
999
951
 
@@ -1066,14 +1018,6 @@ export function parseHeaderSnapshot(raw: unknown): HeaderSnapshot | null {
1066
1018
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1067
1019
  const message = raw as Record<string, unknown>;
1068
1020
  if (message.protocol !== 1) return null;
1069
- const openGoals =
1070
- typeof message.openGoals === 'number' &&
1071
- Number.isInteger(message.openGoals) &&
1072
- message.openGoals >= 0 &&
1073
- message.openGoals <= 99
1074
- ? message.openGoals
1075
- : 0;
1076
-
1077
1021
  const standaloneActivity = parseHeaderActivity(message.activity) ?? null;
1078
1022
  let work: HeaderWork | null = null;
1079
1023
  if (
@@ -1190,7 +1134,6 @@ export function parseHeaderSnapshot(raw: unknown): HeaderSnapshot | null {
1190
1134
  const hasExplicitTurn = Object.prototype.hasOwnProperty.call(message, 'turn');
1191
1135
 
1192
1136
  return {
1193
- openGoals,
1194
1137
  activity: standaloneActivity,
1195
1138
  selection: hasExplicitSelection
1196
1139
  ? parseHeaderSelection(message.selection)
@@ -45,6 +45,7 @@ const ACTIVITY_LABELS: Record<HeaderActivity['kind'], ActivityLabels> = {
45
45
  full: 'reviewing check results',
46
46
  compact: 'reviewing results',
47
47
  },
48
+ answering: { full: 'answering the Human', compact: 'answering' },
48
49
  recovery: { full: 'repairing bounded failure', compact: 'repair' },
49
50
  'awaiting-validation': {
50
51
  full: 'Human validation',
@@ -6,7 +6,6 @@ const TRACKED_WIDGET_IDS = new Set([
6
6
  'galactica.openspec-progress',
7
7
  'galactica.openspec',
8
8
  'galactica.work',
9
- 'galactica.goal',
10
9
  'galactica.project-capabilities',
11
10
  'galactica.session-memory',
12
11
  'galactica.session-cpu',
@@ -214,7 +213,6 @@ function workWidget(widgets: ReadonlyMap<string, RailWidget>): RailWidget | unde
214
213
  for (const id of [
215
214
  'galactica.openspec-progress',
216
215
  'galactica.openspec',
217
- 'galactica.goal',
218
216
  'galactica.work',
219
217
  ]) {
220
218
  const widget = widgets.get(id);
@@ -7,7 +7,7 @@ editor widget itself.
7
7
  ## Architecture
8
8
 
9
9
  ```text
10
- Pi tool lifecycle + session/Goal metadata + OpenSpec CLI + aggregate JSON state
10
+ Pi tool lifecycle + session metadata + OpenSpec CLI + aggregate JSON state
11
11
  │
12
12
  ▼
13
13
  normalized immutable state
@@ -64,9 +64,9 @@ active OpenSpec footer by themselves.
64
64
 
65
65
  ## Session focus and terminal title
66
66
 
67
- A Pi session has zero or one explicit OpenSpec task focus, optional read-only Pi Goal
68
- state, and an independent ephemeral Session Work state. Display priority is OpenSpec
69
- Task → Goal → Session Work → safe direct fallback. OpenSpec focus clears active Session
67
+ A Pi session has zero or one explicit OpenSpec task focus and an independent
68
+ ephemeral Session Work state. Display priority is OpenSpec
69
+ Task → Session Work → safe direct fallback. OpenSpec focus clears active Session
70
70
  Work when acquired, preventing hidden or stale work from resurfacing later.
71
71
 
72
72
  Session Work is stored only as versioned custom entries on the active Pi session branch;
@@ -80,7 +80,7 @@ it never creates a repository status file. It supports this lifecycle:
80
80
  ```
81
81
 
82
82
  When no OpenSpec portfolio is available, the matching row-one footer fallback is the
83
- compact `▶ Focus` or `◇ Validate`; an active Goal uses `󰊕 Goal`, and an entirely empty
83
+ compact `▶ Focus` or `◇ Validate`; an entirely empty
84
84
  project uses ` no OpenSpec`. The detailed Session Work intent remains in the header
85
85
  instead of being duplicated in the footer. Active and validation states also replace the
86
86
  fallback terminal title. Reload, resume, and tree navigation restore the newest valid
@@ -97,25 +97,14 @@ publication.
97
97
 
98
98
  The header projection uses the rich lifecycle `understanding`, `working`, `waiting`,
99
99
  `assuring`, `learning`, `answering`, `blocked`, `listening`, or `aborted`. It separately
100
- publishes one explicit selection with OpenSpec Task → Goal → Session Work precedence,
100
+ publishes one explicit selection with OpenSpec Task → Session Work precedence,
101
101
  or `null` when no work is selected. A controlled suggestion enum communicates only
102
102
  Human-facing reporting cues such as `requesting-validation`,
103
103
  `awaiting-continuation`, or `requesting-redirection`; it never carries generated
104
104
  prose. Direct tool
105
- categories, read-only Pi Goal state, and known Taskflow phases move the lifecycle
106
- without exposing arguments or raw node prose. Goal state is restored from Pi's canonical
107
- `goal-state` session entries without enabling Goal RPC. An active Goal renders bounded
108
- automatic-turn progress from `pi-goal.json`; paused, blocked, limited, external-wait,
109
- and complete states render controlled labels. Complete projects as
110
- `awaiting Human validation` rather than Human acceptance. Raw Goal objectives, wait
111
- reasons, completion summaries, paths, and Goal IDs never enter the visible header. While
112
- a Goal exists, a session-scoped 500 ms in-memory branch monitor catches idle
113
- `/goal pause`, clear, and menu transitions; it performs no filesystem, process,
114
- provider, or network work and stops when Goal state is cleared. A stopped Goal remains
115
- visible while Pi is idle so its terminal reason is honest, but the next ordinary
116
- non-Goal turn automatically returns the title to current Ramona, OpenSpec, Session Work,
117
- or direct activity. This display transition never mutates Goal or focus state; a later
118
- Goal resume automatically reclaims the title. During a Taskflow tool run, structured
105
+ categories and known Taskflow phases move the lifecycle
106
+ without exposing arguments or raw node prose. Complete projects as
107
+ `awaiting Human validation` rather than Human acceptance. During a Taskflow tool run, structured
119
108
  streaming state temporarily replaces the normal focus with the running top-level phase
120
109
  ID, such as `working ⟩ work-fragment`; promoted child IDs and generated output remain
121
110
  hidden. Visible assistant text moves it to `answering`; settling with selected work
@@ -128,7 +117,7 @@ operation transitions to bounded recovery. This prevents a completed command fro
128
117
  looking permanently active while Pi prepares its next action. A Ctrl-C assistant stop
129
118
  reason moves it to `aborted` and remains visible until the next turn resets it to
130
119
  `understanding`. Legacy work titles still preserve the existing explicit fallback states for compatible
131
- consumers. The additive selection field contains only an exact OpenSpec Task, Goal, or
120
+ consumers. The additive selection field contains only an exact OpenSpec Task or
132
121
  Session Work; otherwise it is `null`, so the deck renders a stable dim `󰓾 —`. `Direct
133
122
  response` is never presented as selection and Human prompt text is never copied into it.
134
123
 
@@ -181,7 +170,7 @@ Aggregate portfolio progress is independent of focus and remains visible as:
181
170
  It occupies the right side of footer row one, opposite the full PWD and Git state. It
182
171
  updates from `openspec list --json`; task-list `` replaces `Plans` and notebook ``
183
172
  replaces `Tasks`. If no OpenSpec portfolio exists, the slot prefers compact active scope
184
- (`󰊕 Goal`, then `▶ Focus` or `◇ Validate`) before falling back to dim ` no OpenSpec`.
173
+ (`▶ Focus` or `◇ Validate`) before falling back to dim ` no OpenSpec`.
185
174
  Focused-change metadata remains separate and does not replace these totals. The
186
175
  no-argument form reports the current focus without changing it. The matching tool calls
187
176
  are:
@@ -220,7 +209,7 @@ legacy focused-status producer also gives quiet state feedback:
220
209
 
221
210
  - actionable task work exists: dim `○ OpenSpec · no focus`
222
211
  - no actionable task work exists: dim `○ OpenSpec · clear`
223
- - no `openspec/` project exists: row one shows Goal or Focus when active, otherwise dim
212
+ - no `openspec/` project exists: row one shows Focus when active, otherwise dim
224
213
  ` no OpenSpec`
225
214
  - OpenSpec status cannot be read: the last aggregate remains while focused status
226
215
  reports unavailable
@@ -282,7 +271,7 @@ A configured `orchestration.stateFile` takes precedence. Useful fields include `
282
271
  or node vocabulary is classified into an allowlisted title activity—understanding,
283
272
  inspection, synthesis, planning, skill proposal, mutation, implementation, change
284
273
  review, regression, verification, result review, recovery, waiting reason, or abort—and
285
- attached to the current OpenSpec, Goal, Session Work, or explicit empty focus. Task
274
+ attached to the current OpenSpec, Session Work, or explicit empty focus. Task
286
275
  text, paths, generated prose, and promoted child IDs never enter the lifecycle title;
287
276
  unknown vocabulary produces no activity. The bounded top-level phase ID is the only
288
277
  exact Taskflow identifier shown. Named Taskflow runs invoked by the assistant through