@aemonge-dev/pi-stardeck 0.11.0 → 0.13.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/package.json +3 -2
- package/packages/pi-stardeck/README.md +1 -1
- package/packages/pi-stardeck/src/activity-age.ts +1 -1
- package/packages/pi-stardeck/src/deck.ts +9 -6
- package/packages/pi-stardeck/src/gauge.ts +1 -6
- package/packages/pi-stardeck/src/title-state.ts +124 -58
- package/packages/status/README.md +4 -4
- package/packages/status/index.ts +33 -338
- package/packages/status/src/active-work.ts +6 -71
- package/packages/status/src/ledger.ts +1 -3
- package/packages/status/src/openspec.ts +158 -18
- package/packages/status/src/publisher.ts +6 -76
- package/packages/status/src/reducer.ts +1 -19
- package/packages/status/src/title-state.ts +85 -52
- package/packages/status/src/types.ts +15 -2
- package/packages/work-state/README.md +33 -0
- package/packages/work-state/index.ts +160 -0
- package/packages/work-state/package.json +33 -0
- package/packages/work-state/src/focus.ts +100 -0
- package/src/index.ts +2 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
|
|
3
3
|
export const OPEN_SPEC_FOCUS_ENTRY_TYPE = 'galactica-status.openspec-focus.v1';
|
|
4
|
-
export const SESSION_WORK_FOCUS_ENTRY_TYPE = 'galactica-status.session-work.v1';
|
|
5
4
|
export const SESSION_SUBJECT_ENTRY_TYPE = 'galactica-status.session-subject.v1';
|
|
6
5
|
|
|
7
6
|
export type TaskOpenSpecFocus = {
|
|
@@ -15,11 +14,6 @@ export type OpenSpecFocus = { mode: 'none' } | TaskOpenSpecFocus;
|
|
|
15
14
|
|
|
16
15
|
export const NO_OPEN_SPEC_FOCUS: OpenSpecFocus = { mode: 'none' };
|
|
17
16
|
|
|
18
|
-
export type SessionWorkFocus =
|
|
19
|
-
{ state: 'clear' } | { state: 'active' | 'validation'; intent: string };
|
|
20
|
-
|
|
21
|
-
export const NO_SESSION_WORK_FOCUS: SessionWorkFocus = { state: 'clear' };
|
|
22
|
-
|
|
23
17
|
export type SessionSubject = { state: 'clear' } | { state: 'set'; title: string };
|
|
24
18
|
|
|
25
19
|
export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
|
|
@@ -27,7 +21,6 @@ export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
|
|
|
27
21
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
28
22
|
const MAX_TITLE_POINTS = 180;
|
|
29
23
|
const MAX_TASK_TITLE_POINTS = 72;
|
|
30
|
-
const MAX_WORK_INTENT_POINTS = 160;
|
|
31
24
|
const MAX_SUBJECT_POINTS = 120;
|
|
32
25
|
|
|
33
26
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -73,25 +66,6 @@ export function parseOpenSpecFocus(value: unknown): OpenSpecFocus | undefined {
|
|
|
73
66
|
return taskFocus(value.change, value.taskId, value.awaitingValidation);
|
|
74
67
|
}
|
|
75
68
|
|
|
76
|
-
export function sessionWorkFocus(
|
|
77
|
-
state: unknown,
|
|
78
|
-
intent?: unknown,
|
|
79
|
-
): SessionWorkFocus | undefined {
|
|
80
|
-
if (state === 'clear') return NO_SESSION_WORK_FOCUS;
|
|
81
|
-
if (state !== 'active' && state !== 'validation') return undefined;
|
|
82
|
-
const normalizedIntent = explicitId(intent);
|
|
83
|
-
if (!normalizedIntent) return undefined;
|
|
84
|
-
return {
|
|
85
|
-
state,
|
|
86
|
-
intent: shorten(normalizedIntent, MAX_WORK_INTENT_POINTS),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function parseSessionWorkFocus(value: unknown): SessionWorkFocus | undefined {
|
|
91
|
-
if (!isRecord(value)) return undefined;
|
|
92
|
-
return sessionWorkFocus(value.state, value.intent);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
69
|
export function sessionSubject(
|
|
96
70
|
state: unknown,
|
|
97
71
|
title?: unknown,
|
|
@@ -111,18 +85,6 @@ export function parseSessionSubject(value: unknown): SessionSubject | undefined
|
|
|
111
85
|
return sessionSubject(value.state, value.title);
|
|
112
86
|
}
|
|
113
87
|
|
|
114
|
-
/** Derive a session title from a raw user prompt — the extension-side
|
|
115
|
-
* fallback that makes the agent's first-turn `title` call optional
|
|
116
|
-
* polish instead of a load-bearing bet (every agent skipped it). The
|
|
117
|
-
* same sanitizer bounds it; slash commands are commands, not subjects:
|
|
118
|
-
* they never title. */
|
|
119
|
-
export function titleFromPrompt(prompt: unknown): SessionSubject | undefined {
|
|
120
|
-
if (typeof prompt !== 'string') return undefined;
|
|
121
|
-
const normalized = normalizeTitlePart(prompt);
|
|
122
|
-
if (!normalized || normalized.startsWith('/')) return undefined;
|
|
123
|
-
return sessionSubject('set', normalized);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
88
|
export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus {
|
|
127
89
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
128
90
|
const entry = entries[index];
|
|
@@ -139,22 +101,6 @@ export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus
|
|
|
139
101
|
return NO_OPEN_SPEC_FOCUS;
|
|
140
102
|
}
|
|
141
103
|
|
|
142
|
-
export function restoreSessionWorkFocus(entries: readonly unknown[]): SessionWorkFocus {
|
|
143
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
144
|
-
const entry = entries[index];
|
|
145
|
-
if (
|
|
146
|
-
!isRecord(entry) ||
|
|
147
|
-
entry.type !== 'custom' ||
|
|
148
|
-
entry.customType !== SESSION_WORK_FOCUS_ENTRY_TYPE
|
|
149
|
-
) {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const focus = parseSessionWorkFocus(entry.data);
|
|
153
|
-
if (focus) return focus;
|
|
154
|
-
}
|
|
155
|
-
return NO_SESSION_WORK_FOCUS;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
104
|
export function restoreSessionSubject(entries: readonly unknown[]): SessionSubject {
|
|
159
105
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
160
106
|
const entry = entries[index];
|
|
@@ -204,16 +150,6 @@ export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): bo
|
|
|
204
150
|
);
|
|
205
151
|
}
|
|
206
152
|
|
|
207
|
-
export function sameSessionWorkFocus(
|
|
208
|
-
left: SessionWorkFocus,
|
|
209
|
-
right: SessionWorkFocus,
|
|
210
|
-
): boolean {
|
|
211
|
-
if (left.state !== right.state) return false;
|
|
212
|
-
return (
|
|
213
|
-
left.state === 'clear' || (right.state !== 'clear' && left.intent === right.intent)
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
153
|
export function sameSessionSubject(
|
|
218
154
|
left: SessionSubject,
|
|
219
155
|
right: SessionSubject,
|
|
@@ -226,7 +162,6 @@ export function sameSessionSubject(
|
|
|
226
162
|
|
|
227
163
|
export function formatActiveWorkTitle(options: {
|
|
228
164
|
focus: OpenSpecFocus;
|
|
229
|
-
workFocus?: SessionWorkFocus;
|
|
230
165
|
subject?: SessionSubject;
|
|
231
166
|
taskTitle?: string;
|
|
232
167
|
sessionName?: string;
|
|
@@ -239,11 +174,8 @@ export function formatActiveWorkTitle(options: {
|
|
|
239
174
|
? shorten(normalizeTitlePart(options.taskTitle), MAX_TASK_TITLE_POINTS)
|
|
240
175
|
: '';
|
|
241
176
|
detail = `${options.focus.change} › ${options.focus.taskId}${taskTitle ? ` ${taskTitle}` : ''}`;
|
|
242
|
-
} else if (options.
|
|
243
|
-
detail =
|
|
244
|
-
options.workFocus.state === 'validation'
|
|
245
|
-
? `Validate › ${options.workFocus.intent}`
|
|
246
|
-
: options.workFocus.intent;
|
|
177
|
+
} else if (normalizeTitlePart(options.sessionName ?? '')) {
|
|
178
|
+
detail = normalizeTitlePart(options.sessionName ?? '');
|
|
247
179
|
} else if (options.subject && options.subject.state === 'set') {
|
|
248
180
|
detail = options.subject.title;
|
|
249
181
|
} else {
|
|
@@ -254,5 +186,8 @@ export function formatActiveWorkTitle(options: {
|
|
|
254
186
|
'pi';
|
|
255
187
|
}
|
|
256
188
|
|
|
257
|
-
|
|
189
|
+
// Bare text — line 2 carries no chrome (frozen amendment): the session
|
|
190
|
+
// name, change, and task stand naked, and pi-title's naming is quoted,
|
|
191
|
+
// never re-decorated.
|
|
192
|
+
return shorten(normalizeTitlePart(detail), MAX_TITLE_POINTS);
|
|
258
193
|
}
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
RunSpan,
|
|
6
6
|
StageValue,
|
|
7
7
|
} from './types.ts';
|
|
8
|
-
import type { OpenSpecFocus, SessionSubject
|
|
8
|
+
import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Append-only event ledger for the cockpit status core.
|
|
@@ -18,7 +18,6 @@ import type { OpenSpecFocus, SessionSubject, SessionWorkFocus } from './active-w
|
|
|
18
18
|
|
|
19
19
|
export const LEDGER_EVENT_TYPES = [
|
|
20
20
|
'focus/openspec',
|
|
21
|
-
'focus/work',
|
|
22
21
|
'focus/subject',
|
|
23
22
|
'run/start',
|
|
24
23
|
'run/stage',
|
|
@@ -37,7 +36,6 @@ export type LedgerEvent =
|
|
|
37
36
|
at: number;
|
|
38
37
|
focus: OpenSpecFocus;
|
|
39
38
|
}
|
|
40
|
-
| { type: 'focus/work'; at: number; work: SessionWorkFocus }
|
|
41
39
|
| { type: 'focus/subject'; at: number; subject: SessionSubject }
|
|
42
40
|
| { type: 'run/start'; at: number; span: RunSpan }
|
|
43
41
|
| { type: 'run/stage'; at: number; id: string; stage: StageValue; declared: boolean }
|
|
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
|
|
|
2
2
|
import { resolve, sep } from 'node:path';
|
|
3
3
|
import type {
|
|
4
4
|
CommandRunner,
|
|
5
|
+
OpenSpecArtifact,
|
|
5
6
|
OpenSpecHierarchyProgress,
|
|
6
7
|
OpenSpecOverview,
|
|
7
8
|
OpenSpecState,
|
|
@@ -27,6 +28,19 @@ interface ApplyInstructionsResult {
|
|
|
27
28
|
tasksPath?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** Parsed `openspec status` artifacts plus their resolved files. Paths stay
|
|
32
|
+
* internal: they feed the file-truth item counter, never the contract. */
|
|
33
|
+
interface ParsedArtifact extends OpenSpecArtifact {
|
|
34
|
+
resolvedPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedOpenSpecStatus {
|
|
38
|
+
schemaName?: string;
|
|
39
|
+
artifacts?: ParsedArtifact[];
|
|
40
|
+
currentArtifact?: string;
|
|
41
|
+
changeRoot?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
45
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
46
|
}
|
|
@@ -117,6 +131,51 @@ export function parseTaskMarkdown(content: string): OpenSpecTask[] {
|
|
|
117
131
|
return tasks;
|
|
118
132
|
}
|
|
119
133
|
|
|
134
|
+
/** File-truth checkbox counting: `{ done, total }` over `- [x]` / `- [ ]`
|
|
135
|
+
* items. Zero checkboxes is absence, not zero — `undefined` emits only
|
|
136
|
+
* when a readable source file actually carries countable items. */
|
|
137
|
+
export function parseCheckboxCounts(
|
|
138
|
+
content: string,
|
|
139
|
+
): { done: number; total: number } | undefined {
|
|
140
|
+
const pattern = /^\s*-\s*\[([ xX])\]/gmu;
|
|
141
|
+
let total = 0;
|
|
142
|
+
let done = 0;
|
|
143
|
+
let match: RegExpExecArray | null;
|
|
144
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
145
|
+
total += 1;
|
|
146
|
+
if ((match[1] ?? '').toLowerCase() === 'x') done += 1;
|
|
147
|
+
}
|
|
148
|
+
return total > 0 ? { done, total } : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** mtime-keyed parse cache: a file is re-read only after its mtime moves.
|
|
152
|
+
* Unreadable files stay uncached so the next refresh retries honestly. */
|
|
153
|
+
const artifactItemsCache = new Map<
|
|
154
|
+
string,
|
|
155
|
+
{ mtimeMs: number; counts: { done: number; total: number } | undefined }
|
|
156
|
+
>();
|
|
157
|
+
|
|
158
|
+
export async function readArtifactItems(
|
|
159
|
+
path: string,
|
|
160
|
+
): Promise<{ done: number; total: number } | undefined> {
|
|
161
|
+
let mtimeMs: number;
|
|
162
|
+
try {
|
|
163
|
+
mtimeMs = (await fs.stat(path)).mtimeMs;
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
const cached = artifactItemsCache.get(path);
|
|
168
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.counts;
|
|
169
|
+
let counts: { done: number; total: number } | undefined;
|
|
170
|
+
try {
|
|
171
|
+
counts = parseCheckboxCounts(await fs.readFile(path, 'utf8'));
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
artifactItemsCache.set(path, { mtimeMs, counts });
|
|
176
|
+
return counts;
|
|
177
|
+
}
|
|
178
|
+
|
|
120
179
|
export function parseTaskHierarchy(
|
|
121
180
|
content: string,
|
|
122
181
|
): OpenSpecHierarchyProgress | undefined {
|
|
@@ -234,31 +293,60 @@ function chooseChange(
|
|
|
234
293
|
return selected;
|
|
235
294
|
}
|
|
236
295
|
|
|
237
|
-
function parseOpenSpecStatus(value: unknown): {
|
|
238
|
-
schemaName?: string;
|
|
239
|
-
ladder?: { done: number; total: number; rung?: string };
|
|
240
|
-
} {
|
|
296
|
+
function parseOpenSpecStatus(value: unknown): ParsedOpenSpecStatus {
|
|
241
297
|
if (!isRecord(value)) return {};
|
|
242
298
|
const schemaName =
|
|
243
299
|
typeof value.schemaName === 'string' && value.schemaName.trim()
|
|
244
300
|
? value.schemaName.trim()
|
|
245
301
|
: undefined;
|
|
246
|
-
|
|
302
|
+
const changeRoot =
|
|
303
|
+
typeof value.changeRoot === 'string' && value.changeRoot.trim()
|
|
304
|
+
? value.changeRoot.trim()
|
|
305
|
+
: undefined;
|
|
306
|
+
let artifacts: ParsedArtifact[] | undefined;
|
|
307
|
+
let currentArtifact: string | undefined;
|
|
247
308
|
if (Array.isArray(value.artifacts) && value.artifacts.length > 0) {
|
|
248
|
-
|
|
249
|
-
let
|
|
309
|
+
const parsed: ParsedArtifact[] = [];
|
|
310
|
+
let lastDone: string | undefined;
|
|
250
311
|
for (const artifact of value.artifacts) {
|
|
251
312
|
if (!isRecord(artifact)) continue;
|
|
252
|
-
const id =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
313
|
+
const id =
|
|
314
|
+
typeof artifact.id === 'string' && artifact.id.trim()
|
|
315
|
+
? artifact.id.trim()
|
|
316
|
+
: undefined;
|
|
317
|
+
if (!id) continue;
|
|
318
|
+
const done = artifact.status === 'done';
|
|
319
|
+
if (done) lastDone = id;
|
|
320
|
+
// The artifacts array carries a relative outputPath; the item counter
|
|
321
|
+
// resolves it against the change root — contained paths only.
|
|
322
|
+
let resolvedPath: string | undefined;
|
|
323
|
+
const outputPath =
|
|
324
|
+
typeof artifact.outputPath === 'string' && artifact.outputPath.trim()
|
|
325
|
+
? artifact.outputPath.trim()
|
|
326
|
+
: undefined;
|
|
327
|
+
if (changeRoot && outputPath) {
|
|
328
|
+
const root = resolve(changeRoot);
|
|
329
|
+
const candidate = resolve(root, outputPath);
|
|
330
|
+
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
331
|
+
resolvedPath = candidate;
|
|
332
|
+
}
|
|
257
333
|
}
|
|
334
|
+
parsed.push({ id, done, ...(resolvedPath ? { resolvedPath } : {}) });
|
|
335
|
+
}
|
|
336
|
+
if (parsed.length > 0) {
|
|
337
|
+
// The current artifact is the one the work is in: the first not yet
|
|
338
|
+
// done; once everything is complete, the last done artifact.
|
|
339
|
+
const inProgress = parsed.find((artifact) => !artifact.done);
|
|
340
|
+
currentArtifact = inProgress ? inProgress.id : lastDone;
|
|
341
|
+
artifacts = parsed;
|
|
258
342
|
}
|
|
259
|
-
ladder = { done, total: value.artifacts.length, ...(rung ? { rung } : {}) };
|
|
260
343
|
}
|
|
261
|
-
return {
|
|
344
|
+
return {
|
|
345
|
+
...(schemaName ? { schemaName } : {}),
|
|
346
|
+
...(artifacts ? { artifacts } : {}),
|
|
347
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
348
|
+
...(changeRoot ? { changeRoot } : {}),
|
|
349
|
+
};
|
|
262
350
|
}
|
|
263
351
|
|
|
264
352
|
async function runJson(
|
|
@@ -295,6 +383,37 @@ async function isIdeaChange(projectRoot: string, changeName: string): Promise<bo
|
|
|
295
383
|
}
|
|
296
384
|
}
|
|
297
385
|
|
|
386
|
+
/** Newest mtime over the change directory's direct files — the file-truth
|
|
387
|
+
* "the change moved on" timestamp attention staleness compares against.
|
|
388
|
+
* Change directories are flat; absent or unreadable dirs stay undefined. */
|
|
389
|
+
async function changeDirectoryMtime(
|
|
390
|
+
projectRoot: string,
|
|
391
|
+
changeName: string,
|
|
392
|
+
): Promise<number | undefined> {
|
|
393
|
+
const changesRoot = resolve(projectRoot, 'openspec', 'changes');
|
|
394
|
+
const changeRoot = resolve(changesRoot, changeName);
|
|
395
|
+
if (changeRoot !== changesRoot && !changeRoot.startsWith(`${changesRoot}${sep}`)) {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
let entries;
|
|
399
|
+
try {
|
|
400
|
+
entries = await fs.readdir(changeRoot, { withFileTypes: true });
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
let newest: number | undefined;
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (!entry.isFile()) continue;
|
|
407
|
+
try {
|
|
408
|
+
const mtimeMs = (await fs.stat(resolve(changeRoot, entry.name))).mtimeMs;
|
|
409
|
+
newest = newest === undefined ? mtimeMs : Math.max(newest, mtimeMs);
|
|
410
|
+
} catch {
|
|
411
|
+
// A file vanishing between readdir and stat is honest absence.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return newest;
|
|
415
|
+
}
|
|
416
|
+
|
|
298
417
|
export async function collectOpenSpecOverview(options: {
|
|
299
418
|
cwd: string;
|
|
300
419
|
runner: CommandRunner;
|
|
@@ -361,7 +480,8 @@ export async function collectOpenSpec(options: {
|
|
|
361
480
|
}
|
|
362
481
|
|
|
363
482
|
let schemaName: string | undefined;
|
|
364
|
-
let
|
|
483
|
+
let artifacts: OpenSpecArtifact[] | undefined;
|
|
484
|
+
let currentArtifact: string | undefined;
|
|
365
485
|
try {
|
|
366
486
|
const status = parseOpenSpecStatus(
|
|
367
487
|
await runJson(
|
|
@@ -372,10 +492,28 @@ export async function collectOpenSpec(options: {
|
|
|
372
492
|
),
|
|
373
493
|
);
|
|
374
494
|
schemaName = status.schemaName;
|
|
375
|
-
|
|
495
|
+
currentArtifact = status.currentArtifact;
|
|
496
|
+
if (status.artifacts) {
|
|
497
|
+
// Item counts come from the artifact FILES, not CLI opinion: the CLI
|
|
498
|
+
// cannot count custom-schema items (taskTrackingConfigured: false),
|
|
499
|
+
// and only readable files emit counts — absence never becomes zero.
|
|
500
|
+
artifacts = await Promise.all(
|
|
501
|
+
status.artifacts.map(async (artifact) => {
|
|
502
|
+
const items = artifact.resolvedPath
|
|
503
|
+
? await readArtifactItems(artifact.resolvedPath)
|
|
504
|
+
: undefined;
|
|
505
|
+
return {
|
|
506
|
+
id: artifact.id,
|
|
507
|
+
done: artifact.done,
|
|
508
|
+
...(items ? { items } : {}),
|
|
509
|
+
};
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
376
513
|
} catch {
|
|
377
|
-
//
|
|
514
|
+
// Artifact facts are optional; the list counts remain valid without them.
|
|
378
515
|
}
|
|
516
|
+
const changeModifiedAt = await changeDirectoryMtime(projectRoot, change.name);
|
|
379
517
|
|
|
380
518
|
let tasks: OpenSpecTask[] = [];
|
|
381
519
|
let tasksPath: string | undefined;
|
|
@@ -441,7 +579,9 @@ export async function collectOpenSpec(options: {
|
|
|
441
579
|
...(tasksPath ? { tasksPath } : {}),
|
|
442
580
|
...(hierarchy ? { hierarchy } : {}),
|
|
443
581
|
...(schemaName ? { schemaName } : {}),
|
|
444
|
-
...(
|
|
582
|
+
...(artifacts ? { artifacts } : {}),
|
|
583
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
584
|
+
...(changeModifiedAt !== undefined ? { changeModifiedAt } : {}),
|
|
445
585
|
meta: {
|
|
446
586
|
source: 'openspec-cli',
|
|
447
587
|
refreshedAt: now,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SessionSubject
|
|
1
|
+
import type { SessionSubject } from './active-work.ts';
|
|
2
2
|
import type { ActiveRunSpanView, ActiveRuntimeRuns } from './runtime-runs.ts';
|
|
3
3
|
import type { TitleState } from './title-state.ts';
|
|
4
4
|
import type { TurnCostSnapshot } from './turn-cost.ts';
|
|
@@ -24,7 +24,6 @@ export const GALACTICA_HEADER_CHANNEL = 'galactica-status:header';
|
|
|
24
24
|
const OPEN_SPEC_WIDGET_ID = 'galactica.openspec';
|
|
25
25
|
const OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.openspec-scope-separator';
|
|
26
26
|
const OPEN_SPEC_PROGRESS_WIDGET_ID = 'galactica.openspec-progress';
|
|
27
|
-
const SESSION_WORK_WIDGET_ID = 'galactica.work';
|
|
28
27
|
const DIAGNOSTICS_WIDGET_ID = 'galactica.diagnostics';
|
|
29
28
|
const ORCHESTRATION_WIDGET_ID = 'galactica.orchestration';
|
|
30
29
|
|
|
@@ -88,13 +87,6 @@ function openSpecColor(state: OpenSpecState): WidgetColor {
|
|
|
88
87
|
return 'accent';
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
export function formatSessionWork(focus: SessionWorkFocus): string {
|
|
92
|
-
if (focus.state === 'clear') return 'Work · clear';
|
|
93
|
-
return focus.state === 'validation'
|
|
94
|
-
? `Validate · ${focus.intent}`
|
|
95
|
-
: `Work · ${focus.intent}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
90
|
export function formatOpenSpecOverview(overview: OpenSpecOverview): string {
|
|
99
91
|
return (
|
|
100
92
|
`${overview.completedPlans}/${overview.totalPlans}` +
|
|
@@ -209,21 +201,13 @@ export function buildWidgetSnapshots(
|
|
|
209
201
|
openSpecFeedback?: OpenSpecFeedback;
|
|
210
202
|
openSpecOverview?: OpenSpecOverview;
|
|
211
203
|
openSpecProjectDetected?: boolean;
|
|
212
|
-
workFocus?: SessionWorkFocus;
|
|
213
204
|
} = {},
|
|
214
205
|
): FancyFooterWidget[] {
|
|
215
206
|
const compact = options.compact ?? false;
|
|
216
207
|
const now = options.now ?? Date.now();
|
|
217
208
|
const widgets: FancyFooterWidget[] = [];
|
|
218
209
|
|
|
219
|
-
|
|
220
|
-
!options.openSpecOverview &&
|
|
221
|
-
Boolean(options.workFocus && options.workFocus.state !== 'clear');
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
options.openSpecOverview ||
|
|
225
|
-
(options.openSpecProjectDetected === false && !hasActiveFallback)
|
|
226
|
-
) {
|
|
210
|
+
if (options.openSpecOverview || options.openSpecProjectDetected === false) {
|
|
227
211
|
widgets.push({
|
|
228
212
|
id: OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID,
|
|
229
213
|
label: 'OpenSpec scope separator',
|
|
@@ -265,41 +249,7 @@ export function buildWidgetSnapshots(
|
|
|
265
249
|
// Frozen contract: without an OpenSpec focus there is no progress surface —
|
|
266
250
|
// no aggregate overview, no 'no OpenSpec' narration.
|
|
267
251
|
|
|
268
|
-
if (
|
|
269
|
-
!snapshot.openspec &&
|
|
270
|
-
!options.openSpecOverview &&
|
|
271
|
-
options.workFocus &&
|
|
272
|
-
options.workFocus.state !== 'clear'
|
|
273
|
-
) {
|
|
274
|
-
const color: WidgetColor =
|
|
275
|
-
options.workFocus.state === 'validation' ? 'warning' : 'accent';
|
|
276
|
-
const glyph =
|
|
277
|
-
options.workFocus.state === 'validation'
|
|
278
|
-
? { nerd: '◇⠀', emoji: '◇⠀', unicode: '◇⠀', ascii: '? ' }
|
|
279
|
-
: { nerd: '▶⠀', emoji: '▶⠀', unicode: '▶⠀', ascii: '> ' };
|
|
280
|
-
widgets.push({
|
|
281
|
-
id: SESSION_WORK_WIDGET_ID,
|
|
282
|
-
label: options.workFocus.state === 'validation' ? 'Validate' : 'Focus',
|
|
283
|
-
description:
|
|
284
|
-
options.workFocus.state === 'validation'
|
|
285
|
-
? 'Session focus awaiting Human validation; its title remains in the header'
|
|
286
|
-
: 'Active Session Work focus; its title remains in the header',
|
|
287
|
-
content: {
|
|
288
|
-
type: 'text',
|
|
289
|
-
text: options.workFocus.state === 'validation' ? 'Validate' : 'Focus',
|
|
290
|
-
},
|
|
291
|
-
icon: { glyphs: glyph, color },
|
|
292
|
-
style: { textColor: color },
|
|
293
|
-
layout: {
|
|
294
|
-
row: 0,
|
|
295
|
-
position: 0,
|
|
296
|
-
align: 'right',
|
|
297
|
-
fill: 'none',
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!snapshot.openspec && !options.workFocus && options.openSpecFeedback) {
|
|
252
|
+
if (!snapshot.openspec && options.openSpecFeedback) {
|
|
303
253
|
const unavailable = options.openSpecFeedback === 'unavailable';
|
|
304
254
|
const color: WidgetColor = unavailable ? 'warning' : 'dim';
|
|
305
255
|
const stateText =
|
|
@@ -466,11 +416,7 @@ export const HEADER_LIFECYCLES = [
|
|
|
466
416
|
|
|
467
417
|
export type HeaderLifecycle = (typeof HEADER_LIFECYCLES)[number];
|
|
468
418
|
|
|
469
|
-
export const HEADER_SELECTION_SOURCES = [
|
|
470
|
-
'openspec',
|
|
471
|
-
'session-work',
|
|
472
|
-
'subject',
|
|
473
|
-
] as const;
|
|
419
|
+
export const HEADER_SELECTION_SOURCES = ['openspec', 'subject'] as const;
|
|
474
420
|
|
|
475
421
|
export type HeaderSelection = {
|
|
476
422
|
source: (typeof HEADER_SELECTION_SOURCES)[number];
|
|
@@ -547,7 +493,6 @@ function selectedOpenSpecTitles(
|
|
|
547
493
|
export function headerSelection(options: {
|
|
548
494
|
openSpec?: OpenSpecState | null;
|
|
549
495
|
focusedTaskId?: string;
|
|
550
|
-
workFocus?: SessionWorkFocus;
|
|
551
496
|
subject?: SessionSubject;
|
|
552
497
|
}): HeaderSelection | null {
|
|
553
498
|
if (options.openSpec) {
|
|
@@ -557,13 +502,6 @@ export function headerSelection(options: {
|
|
|
557
502
|
color: openSpecColor(options.openSpec),
|
|
558
503
|
};
|
|
559
504
|
}
|
|
560
|
-
if (options.workFocus && options.workFocus.state !== 'clear') {
|
|
561
|
-
return {
|
|
562
|
-
source: 'session-work',
|
|
563
|
-
titles: [normalizeInline(options.workFocus.intent)],
|
|
564
|
-
color: options.workFocus.state === 'validation' ? 'warning' : 'accent',
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
505
|
if (options.subject && options.subject.state === 'set') {
|
|
568
506
|
return {
|
|
569
507
|
source: 'subject',
|
|
@@ -884,7 +822,6 @@ export function buildHeaderStatusEvent(
|
|
|
884
822
|
options: {
|
|
885
823
|
openSpec?: OpenSpecState | null;
|
|
886
824
|
focusedTaskId?: string;
|
|
887
|
-
workFocus?: SessionWorkFocus;
|
|
888
825
|
subject?: SessionSubject;
|
|
889
826
|
orchestration?: OrchestrationState | null;
|
|
890
827
|
lifecycle?: HeaderLifecycle;
|
|
@@ -932,7 +869,7 @@ export function buildHeaderStatusEvent(
|
|
|
932
869
|
orchestrationLifecycle ??
|
|
933
870
|
options.lifecycle ??
|
|
934
871
|
headerLifecycleForActivity(activity) ??
|
|
935
|
-
|
|
872
|
+
'listening';
|
|
936
873
|
const lifecycle: HeaderLifecycle = blocked
|
|
937
874
|
? 'blocked'
|
|
938
875
|
: options.lifecycle === 'aborted'
|
|
@@ -942,7 +879,7 @@ export function buildHeaderStatusEvent(
|
|
|
942
879
|
const projectedActivity =
|
|
943
880
|
activityPath !== undefined ? { activityPath } : activity ? { activity } : {};
|
|
944
881
|
const selection = headerSelection(options);
|
|
945
|
-
const approvalRequired =
|
|
882
|
+
const approvalRequired = false;
|
|
946
883
|
const suggestion = headerSuggestion({
|
|
947
884
|
lifecycle,
|
|
948
885
|
activity,
|
|
@@ -960,13 +897,6 @@ export function buildHeaderStatusEvent(
|
|
|
960
897
|
color: openSpecColor(state),
|
|
961
898
|
...projectedActivity,
|
|
962
899
|
};
|
|
963
|
-
} else if (options.workFocus && options.workFocus.state !== 'clear') {
|
|
964
|
-
work = {
|
|
965
|
-
lifecycle,
|
|
966
|
-
titles: [normalizeInline(options.workFocus.intent)],
|
|
967
|
-
color: options.workFocus.state === 'validation' ? 'warning' : 'accent',
|
|
968
|
-
...projectedActivity,
|
|
969
|
-
};
|
|
970
900
|
} else if (options.orchestration) {
|
|
971
901
|
work = {
|
|
972
902
|
lifecycle,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LedgerEvent } from './ledger.ts';
|
|
2
|
-
import type { OpenSpecFocus, SessionSubject
|
|
2
|
+
import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
|
|
3
3
|
import type {
|
|
4
4
|
CockpitSnapshot,
|
|
5
5
|
ProgressFact,
|
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
|
|
20
20
|
export type ReducerFacts = {
|
|
21
21
|
openSpec: OpenSpecFocus;
|
|
22
|
-
work: SessionWorkFocus;
|
|
23
22
|
subject: SessionSubject;
|
|
24
23
|
runs: ReadonlyMap<string, RunSpan>;
|
|
25
24
|
progress: ProgressFact | null;
|
|
@@ -30,7 +29,6 @@ export const NO_SELECTION: SelectionState = { kind: 'none' };
|
|
|
30
29
|
export function emptyFacts(): ReducerFacts {
|
|
31
30
|
return {
|
|
32
31
|
openSpec: { mode: 'none' },
|
|
33
|
-
work: { state: 'clear' },
|
|
34
32
|
subject: { state: 'clear' },
|
|
35
33
|
runs: new Map(),
|
|
36
34
|
progress: null,
|
|
@@ -40,7 +38,6 @@ export function emptyFacts(): ReducerFacts {
|
|
|
40
38
|
/** The single selection resolver. Priority order is contract-frozen. */
|
|
41
39
|
export function resolveSelection(facts: {
|
|
42
40
|
openSpec: OpenSpecFocus;
|
|
43
|
-
work: SessionWorkFocus;
|
|
44
41
|
subject: SessionSubject;
|
|
45
42
|
}): SelectionState {
|
|
46
43
|
if (facts.openSpec.mode === 'task') {
|
|
@@ -50,13 +47,6 @@ export function resolveSelection(facts: {
|
|
|
50
47
|
task: facts.openSpec.taskId,
|
|
51
48
|
};
|
|
52
49
|
}
|
|
53
|
-
if (facts.work.state !== 'clear') {
|
|
54
|
-
return {
|
|
55
|
-
kind: 'session-work',
|
|
56
|
-
intent: facts.work.intent,
|
|
57
|
-
phase: facts.work.state === 'validation' ? 'validation' : 'active',
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
50
|
if (facts.subject.state === 'set') {
|
|
61
51
|
return { kind: 'subject', title: facts.subject.title };
|
|
62
52
|
}
|
|
@@ -73,8 +63,6 @@ export function applyEvent(facts: ReducerFacts, event: LedgerEvent): ReducerFact
|
|
|
73
63
|
switch (event.type) {
|
|
74
64
|
case 'focus/openspec':
|
|
75
65
|
return { ...facts, openSpec: event.focus };
|
|
76
|
-
case 'focus/work':
|
|
77
|
-
return { ...facts, work: event.work };
|
|
78
66
|
case 'focus/subject':
|
|
79
67
|
return { ...facts, subject: event.subject };
|
|
80
68
|
case 'run/start': {
|
|
@@ -153,12 +141,6 @@ export function deriveTitle(snapshot: CockpitSnapshot): string {
|
|
|
153
141
|
case 'openspec-task':
|
|
154
142
|
detail = `${selection.change} › ${selection.task}`;
|
|
155
143
|
break;
|
|
156
|
-
case 'session-work':
|
|
157
|
-
detail =
|
|
158
|
-
selection.phase === 'validation'
|
|
159
|
-
? `Validate › ${selection.intent}`
|
|
160
|
-
: selection.intent;
|
|
161
|
-
break;
|
|
162
144
|
case 'subject':
|
|
163
145
|
detail = selection.title;
|
|
164
146
|
break;
|