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