@aemonge-dev/pi-stardeck 0.9.0 → 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.
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/packages/pi-stardeck/README.md +4 -4
- package/packages/pi-stardeck/index.ts +10 -0
- package/packages/pi-stardeck/src/deck.ts +94 -105
- package/packages/pi-stardeck/src/gauge.ts +1 -58
- package/packages/pi-stardeck/src/title.ts +1 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +0 -2
- package/packages/status/README.md +13 -24
- package/packages/status/index.ts +17 -144
- package/packages/status/src/active-work.ts +0 -9
- package/packages/status/src/ledger.ts +0 -3
- package/packages/status/src/publisher.ts +8 -173
- package/packages/status/src/reducer.ts +0 -21
- package/packages/status/src/title-activity.ts +18 -2
- package/packages/status/src/types.ts +0 -7
- package/packages/status/src/goal-block.ts +0 -377
- package/packages/status/src/goal.ts +0 -170
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
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' | '
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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 (
|
|
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,
|
|
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 (
|
|
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
|
|
539
|
-
*
|
|
540
|
-
*
|
|
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,13 +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):
|
|
597
|
+
// when the ladder is silent the dim ladybug holds the right end — the
|
|
598
|
+
// deck's signature glyph and a reserved one (safeText strips from
|
|
599
|
+
// published text), so the status row always speaks: ⚠ with notices,
|
|
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
|
+
}
|
|
565
612
|
// Blocked carries the error severity it wore on the deleted lifecycle
|
|
566
613
|
// tile; every other notice renders at warning.
|
|
567
614
|
const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
|
|
568
|
-
return color(theme, semantic,
|
|
615
|
+
return color(theme, semantic, `\u26a0 ${notices.join(' · ')}`);
|
|
569
616
|
}
|
|
570
617
|
|
|
571
618
|
export function machineCornerSegments(
|
|
@@ -756,65 +803,12 @@ function statusAlertTile(
|
|
|
756
803
|
return visibleWidth(count) < width ? count : '';
|
|
757
804
|
}
|
|
758
805
|
|
|
759
|
-
function formatGoalElapsed(seconds: number): string {
|
|
760
|
-
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
761
|
-
const totalMinutes = Math.floor(seconds / 60);
|
|
762
|
-
if (totalMinutes < 60) return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
|
|
763
|
-
return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
806
|
function formatTokens(tokens: number): string {
|
|
767
807
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
768
808
|
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
769
809
|
return `${Math.floor(tokens)}`;
|
|
770
810
|
}
|
|
771
811
|
|
|
772
|
-
const GOAL_STATUS_COLORS: Record<string, string> = {
|
|
773
|
-
active: 'accent',
|
|
774
|
-
complete: 'success',
|
|
775
|
-
paused: 'warning',
|
|
776
|
-
blocked: 'error',
|
|
777
|
-
usage_limited: 'warning',
|
|
778
|
-
budget_limited: 'warning',
|
|
779
|
-
queued: 'dim',
|
|
780
|
-
};
|
|
781
|
-
|
|
782
|
-
function goalBadgeTile(
|
|
783
|
-
goal: NonNullable<HeaderSelection['goal']>,
|
|
784
|
-
theme: Theme,
|
|
785
|
-
): string {
|
|
786
|
-
const semantic = GOAL_STATUS_COLORS[goal.status] ?? 'text';
|
|
787
|
-
const elapsed =
|
|
788
|
-
goal.elapsedSeconds !== undefined
|
|
789
|
-
? ` · ${formatGoalElapsed(goal.elapsedSeconds)}`
|
|
790
|
-
: '';
|
|
791
|
-
return `${color(theme, semantic, `● ${goal.status}`)}${color(theme, 'dim', elapsed)}`;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function goalTreeLines(
|
|
795
|
-
goal: NonNullable<HeaderSelection['goal']>,
|
|
796
|
-
width: number,
|
|
797
|
-
theme: Theme,
|
|
798
|
-
): string[] {
|
|
799
|
-
if (width < 56) return [];
|
|
800
|
-
const rows: string[] = [];
|
|
801
|
-
if (goal.tasksTotal !== undefined && goal.tasksDone !== undefined) {
|
|
802
|
-
const active =
|
|
803
|
-
goal.tasksActive !== undefined && goal.tasksActive > 0
|
|
804
|
-
? ` · ${goal.tasksActive} active`
|
|
805
|
-
: '';
|
|
806
|
-
rows.push(` ├ tasks ${goal.tasksDone}/${goal.tasksTotal} done${active}`);
|
|
807
|
-
}
|
|
808
|
-
if (goal.tokensUsed !== undefined) {
|
|
809
|
-
const budget =
|
|
810
|
-
goal.tokenBudget !== undefined && goal.tokenBudget > 0
|
|
811
|
-
? ` · budget ${Math.min(999, Math.round((goal.tokensUsed / goal.tokenBudget) * 100))}%`
|
|
812
|
-
: '';
|
|
813
|
-
rows.push(` └ usage ${formatTokens(goal.tokensUsed)} tokens${budget}`);
|
|
814
|
-
}
|
|
815
|
-
return rows.map((line) => color(theme, 'dim', line));
|
|
816
|
-
}
|
|
817
|
-
|
|
818
812
|
export function renderHeaderDeck(
|
|
819
813
|
state: HeaderDeckState,
|
|
820
814
|
width: number,
|
|
@@ -827,40 +821,34 @@ export function renderHeaderDeck(
|
|
|
827
821
|
const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
|
|
828
822
|
const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
|
|
829
823
|
// Line 2 (four-line deck): the clock and Subject lead with Work on the
|
|
830
|
-
// right end
|
|
831
|
-
//
|
|
832
|
-
const
|
|
833
|
-
state.header?.selection?.source === 'goal'
|
|
834
|
-
? state.header.selection.goal
|
|
835
|
-
: undefined;
|
|
836
|
-
const titleRight =
|
|
837
|
-
boundedWidth >= 40
|
|
838
|
-
? goalDetail
|
|
839
|
-
? goalBadgeTile(goalDetail, theme)
|
|
840
|
-
: workTile(state, theme)
|
|
841
|
-
: '';
|
|
824
|
+
// right end. Narrow frames: the Work slot yields before the clock anchor
|
|
825
|
+
// does.
|
|
826
|
+
const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
|
|
842
827
|
const focusLines = titleBandLines(state, boundedWidth, theme);
|
|
843
828
|
const titleRows = [
|
|
844
829
|
alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
|
|
845
830
|
...focusLines.slice(1),
|
|
846
831
|
];
|
|
847
|
-
const goalRows = goalDetail ? goalTreeLines(goalDetail, boundedWidth, theme) : [];
|
|
848
832
|
|
|
849
833
|
// Line 3 (four-line deck): Activity left with the per-turn cost right —
|
|
850
834
|
// the superseded lifecycle/suggestion/runs tiles are gone; Work,
|
|
851
835
|
// Activity, and the spans rows are their replacements. The spans rows
|
|
852
836
|
// keep their home directly beneath this line.
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
: '';
|
|
858
846
|
// deck-reshape, swap amendment: the bottom row carries the MACHINE
|
|
859
847
|
// tail on the left (yielding right-to-left into whatever width the
|
|
860
|
-
// status leaves) and the status on the right —
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
const statusRight = noticeTile(state, theme);
|
|
848
|
+
// status leaves) and the status on the right — which always speaks
|
|
849
|
+
// now: ⚠ notices or the dim ladybug sentinel when clean. The engine
|
|
850
|
+
// group moved to line 1 right.
|
|
851
|
+
const statusRight = noticeTile(state, theme, boundedWidth);
|
|
864
852
|
const machineRow = machineCornerLadder(
|
|
865
853
|
state,
|
|
866
854
|
theme,
|
|
@@ -874,7 +862,9 @@ export function renderHeaderDeck(
|
|
|
874
862
|
// Band adjacency (Human-directed, accepted mockups; third 2.4
|
|
875
863
|
// amendment): the deck opens with a full ─ rule directly beneath the
|
|
876
864
|
// line-1 strip; the internal seams stay dotted ┈ between lines 2 and
|
|
877
|
-
// 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.
|
|
878
868
|
const identity = identityCluster(state, theme);
|
|
879
869
|
const corner = deckCornerRight(state, theme, boundedWidth);
|
|
880
870
|
const alert = statusAlertTile(
|
|
@@ -891,10 +881,9 @@ export function renderHeaderDeck(
|
|
|
891
881
|
),
|
|
892
882
|
solidRule,
|
|
893
883
|
...titleRows,
|
|
894
|
-
...
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
...spanTreeRows(state, boundedWidth, theme),
|
|
884
|
+
...(bandSpeaks || spanRows.length > 0 ? [dottedRule] : []),
|
|
885
|
+
...(bandSpeaks ? [activityRow] : []),
|
|
886
|
+
...spanRows,
|
|
898
887
|
...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
|
|
899
888
|
];
|
|
900
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
|
|
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
|
|
68
|
-
|
|
69
|
-
Task →
|
|
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
|
|
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 →
|
|
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
|
|
106
|
-
without exposing arguments or raw node prose.
|
|
107
|
-
`
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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,
|
|
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
|