@aemonge-dev/pi-stardeck 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/demo/pi-stardeck.tape +6 -3
- package/docs/architecture.md +2 -1
- package/package.json +2 -3
- package/packages/pi-stardeck/index.ts +0 -10
- package/packages/pi-stardeck/src/deck.ts +26 -42
- package/packages/pi-stardeck/src/title-state.ts +64 -162
- package/packages/status/README.md +20 -34
- package/packages/status/index.ts +26 -155
- package/packages/status/src/active-work.ts +0 -84
- package/packages/status/src/config.ts +1 -3
- package/packages/status/src/ledger.ts +1 -7
- package/packages/status/src/openspec.ts +2 -2
- package/packages/status/src/publisher.ts +8 -78
- package/packages/status/src/reducer.ts +2 -36
- package/packages/status/src/room-aim.ts +151 -0
- package/packages/status/src/title-state.ts +42 -148
- package/packages/status/src/types.ts +1 -5
- package/src/index.ts +0 -2
- package/packages/pi-stardeck/src/activity-age.ts +0 -184
- package/packages/status/src/gate-act.ts +0 -85
- package/packages/work-state/README.md +0 -33
- package/packages/work-state/index.ts +0 -160
- package/packages/work-state/package.json +0 -33
- package/packages/work-state/src/focus.ts +0 -100
|
@@ -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
|
-
|
|
235
|
-
|
|
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,
|
|
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
|
|
481
|
-
return normalizeInline(
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
2
|
-
import type {
|
|
1
|
+
import type { SessionSubject } from './active-work.ts';
|
|
2
|
+
import type { RoomAim } from './room-aim.ts';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Title-state contract
|
|
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
|
|
9
|
-
* tool executions, and `waiver` suppresses one exact notice text.
|
|
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
|
-
|
|
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
|
|
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 (
|
|
138
|
-
const
|
|
110
|
+
if (artifactName === null) return undefined;
|
|
111
|
+
const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
|
|
139
112
|
required: false,
|
|
140
113
|
});
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
|
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:
|
|
282
|
-
...
|
|
283
|
-
...(
|
|
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
|
|
289
|
-
* subject entry, the
|
|
290
|
-
* observed activity event.
|
|
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
|
-
|
|
295
|
-
|
|
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:
|
|
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;
|