@aemonge-dev/pi-stardeck 0.12.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 +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/title-state.ts +124 -58
- package/packages/status/index.ts +7 -0
- package/packages/status/src/active-work.ts +4 -1
- package/packages/status/src/openspec.ts +158 -18
- package/packages/status/src/title-state.ts +85 -52
- package/packages/status/src/types.ts +15 -1
package/package.json
CHANGED
|
@@ -66,7 +66,7 @@ function standardActivityAge(ageMs: number): string {
|
|
|
66
66
|
return `${Math.min(99, Math.floor(hours / 24))}d`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function compactActivityAge(ageMs: number): string {
|
|
69
|
+
export function compactActivityAge(ageMs: number): string {
|
|
70
70
|
const age = boundedAge(ageMs);
|
|
71
71
|
const seconds = Math.floor(age / 1_000);
|
|
72
72
|
if (seconds < 100) return `${seconds}s`;
|
|
@@ -237,15 +237,18 @@ function alignedSingleRow(left: string, right: string, width: number): string {
|
|
|
237
237
|
return `${fittedLeft}${' '.repeat(gap)}${right}`;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
function workTile(state: HeaderDeckState, theme: Theme): string {
|
|
240
|
+
function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
|
|
241
241
|
// Line 2 right: Work — derived from OpenSpec focus, never authored here.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
242
|
+
// Width policy: the full tile from 56; 40–55 the attention/progress side
|
|
243
|
+
// yields first and the item count survives longest; below 40 the slot
|
|
244
|
+
// stays empty. A work slot without facts renders nothing — absent facts
|
|
245
|
+
// remove their slots, never sentinel dashes or narration.
|
|
246
|
+
if (width < 40) return '';
|
|
244
247
|
const title = state.header?.title ?? null;
|
|
245
248
|
if (!title) return '';
|
|
246
249
|
const work = title.work;
|
|
247
|
-
if (!work) return
|
|
248
|
-
return deckWorkSegments(work)
|
|
250
|
+
if (!work) return '';
|
|
251
|
+
return deckWorkSegments(work, Date.now(), width < 56)
|
|
249
252
|
.map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
|
|
250
253
|
.join('');
|
|
251
254
|
}
|
|
@@ -754,7 +757,7 @@ export function renderHeaderDeck(
|
|
|
754
757
|
// Line 2 (four-line deck): the Subject leads with Work on the right end
|
|
755
758
|
// (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
|
|
756
759
|
// Work slot yields before the Subject does.
|
|
757
|
-
const titleRight =
|
|
760
|
+
const titleRight = workTile(state, theme, boundedWidth);
|
|
758
761
|
const focusLines = titleBandLines(state, boundedWidth, theme);
|
|
759
762
|
const titleRows = [
|
|
760
763
|
alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* the legacy selection/activity rendering.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
import { compactActivityAge } from './activity-age.ts';
|
|
16
|
+
|
|
17
|
+
export const DECK_TITLE_STATE_VERSION = 2 as const;
|
|
16
18
|
|
|
17
19
|
export const DECK_TITLE_ACTIVITY_CLASSES = [
|
|
18
20
|
'editing',
|
|
@@ -38,16 +40,27 @@ const TASK_ID_MAX = 32;
|
|
|
38
40
|
const SPAN_ID_MAX = 64;
|
|
39
41
|
const WAIVER_ID_MAX = 64;
|
|
40
42
|
const NOTICE_MAX = 180;
|
|
43
|
+
const ARTIFACTS_MAX = 16;
|
|
41
44
|
const TURN_TOKENS_MAX = 999_999_999;
|
|
42
45
|
const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
43
46
|
|
|
47
|
+
export type DeckTitleArtifactState = {
|
|
48
|
+
id: string;
|
|
49
|
+
done: boolean;
|
|
50
|
+
items?: { done: number; total: number };
|
|
51
|
+
};
|
|
52
|
+
|
|
44
53
|
export type DeckTitleWorkState = {
|
|
45
54
|
change: string;
|
|
46
55
|
task?: string;
|
|
47
|
-
done?: number;
|
|
48
|
-
total?: number;
|
|
49
56
|
schemaName?: string;
|
|
50
|
-
|
|
57
|
+
artifacts?: DeckTitleArtifactState[];
|
|
58
|
+
/** The artifact the work is in — first not done, else last done. */
|
|
59
|
+
currentArtifact?: string;
|
|
60
|
+
/** Attention facts, derived from publisher-owned truth only. */
|
|
61
|
+
awaitingValidation?: boolean;
|
|
62
|
+
focusAt?: number;
|
|
63
|
+
focusStale?: boolean;
|
|
51
64
|
};
|
|
52
65
|
|
|
53
66
|
export type DeckTitleActivityState = {
|
|
@@ -121,46 +134,66 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
121
134
|
if (!isRecord(value)) return undefined;
|
|
122
135
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
123
136
|
if (!change) return undefined;
|
|
124
|
-
// Counts are optional as a pair: absent means unknown (never fabricated
|
|
125
|
-
// zeros); present-but-invalid rejects the object.
|
|
126
|
-
const hasCounts = 'done' in value && 'total' in value;
|
|
127
|
-
if ('done' in value !== 'total' in value) return undefined;
|
|
128
|
-
const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
|
|
129
|
-
const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
|
|
130
|
-
if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
137
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
134
138
|
if (task === null) return undefined;
|
|
135
139
|
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
136
140
|
required: false,
|
|
137
141
|
});
|
|
138
142
|
if (schemaName === null) return undefined;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
|
|
144
|
+
required: false,
|
|
145
|
+
});
|
|
146
|
+
if (currentArtifact === null) return undefined;
|
|
147
|
+
let artifacts: DeckTitleArtifactState[] | undefined;
|
|
148
|
+
if ('artifacts' in value) {
|
|
149
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const parsed: DeckTitleArtifactState[] = [];
|
|
153
|
+
for (const entry of value.artifacts) {
|
|
154
|
+
if (!isRecord(entry)) return undefined;
|
|
155
|
+
const id = boundedText(entry.id, TASK_ID_MAX);
|
|
156
|
+
if (!id) return undefined;
|
|
157
|
+
if (typeof entry.done !== 'boolean') return undefined;
|
|
158
|
+
const artifactDone = entry.done;
|
|
159
|
+
let items: { done: number; total: number } | undefined;
|
|
160
|
+
if ('items' in entry) {
|
|
161
|
+
if (!isRecord(entry.items)) return undefined;
|
|
162
|
+
const done = boundedTotal(entry.items.done);
|
|
163
|
+
const total = boundedTotal(entry.items.total);
|
|
164
|
+
if (done === null || total === null) return undefined;
|
|
165
|
+
items = { done, total };
|
|
166
|
+
}
|
|
167
|
+
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
168
|
+
}
|
|
169
|
+
artifacts = parsed;
|
|
170
|
+
}
|
|
171
|
+
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
let focusAt: number | undefined;
|
|
175
|
+
if ('focusAt' in value) {
|
|
176
|
+
if (
|
|
177
|
+
typeof value.focusAt !== 'number' ||
|
|
178
|
+
!Number.isSafeInteger(value.focusAt) ||
|
|
179
|
+
value.focusAt < 0
|
|
180
|
+
) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
focusAt = value.focusAt;
|
|
184
|
+
}
|
|
185
|
+
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
186
|
+
return undefined;
|
|
152
187
|
}
|
|
153
|
-
const counts =
|
|
154
|
-
typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
|
|
155
|
-
? { done: boundedDone, total: boundedTotalValue }
|
|
156
|
-
: {};
|
|
157
188
|
const extras = {
|
|
158
189
|
...(schemaName ? { schemaName } : {}),
|
|
159
|
-
...(
|
|
190
|
+
...(artifacts ? { artifacts } : {}),
|
|
191
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
192
|
+
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
193
|
+
...(focusAt !== undefined ? { focusAt } : {}),
|
|
194
|
+
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
160
195
|
};
|
|
161
|
-
return task === undefined
|
|
162
|
-
? { change, ...counts, ...extras }
|
|
163
|
-
: { change, task, ...counts, ...extras };
|
|
196
|
+
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
164
197
|
}
|
|
165
198
|
|
|
166
199
|
function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
|
|
@@ -232,33 +265,66 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
232
265
|
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
233
266
|
|
|
234
267
|
/**
|
|
235
|
-
* The Work label as colored segments — the
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
268
|
+
* The Work label as colored segments — the truthful, de-duplicated tile:
|
|
269
|
+
* `execute 4/4 › 12/15` — current artifact + artifact progress + the
|
|
270
|
+
* current artifact's file-truth item count. The change name lives on the
|
|
271
|
+
* subject side and never repeats here. Attention is derived, never authored:
|
|
272
|
+
* `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
|
|
273
|
+
* (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
|
|
274
|
+
* group. Unknown facts drop their segments — absent items never render as
|
|
275
|
+
* zeros. Compact width: the attention/progress side yields first, the item
|
|
276
|
+
* count survives longest. Separators are chrome with hard deck-wide colors —
|
|
277
|
+
* `›` accent like every minor separator.
|
|
242
278
|
*/
|
|
243
|
-
export function deckWorkSegments(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
export function deckWorkSegments(
|
|
280
|
+
work: DeckTitleWorkState,
|
|
281
|
+
now: number = Date.now(),
|
|
282
|
+
compact = false,
|
|
283
|
+
): DeckWorkSegment[] {
|
|
284
|
+
if (work.awaitingValidation) {
|
|
285
|
+
return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
|
|
286
|
+
}
|
|
287
|
+
const segments: DeckWorkSegment[] = [];
|
|
288
|
+
const stale = work.focusStale === true && work.focusAt !== undefined;
|
|
289
|
+
if (!compact) {
|
|
290
|
+
if (stale) {
|
|
291
|
+
segments.push({
|
|
292
|
+
text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
|
|
293
|
+
color: 'dim',
|
|
294
|
+
});
|
|
295
|
+
} else {
|
|
296
|
+
const artifacts = work.artifacts;
|
|
297
|
+
const current = work.currentArtifact;
|
|
298
|
+
if (artifacts && artifacts.length > 0 && current) {
|
|
299
|
+
const done = artifacts.filter((artifact) => artifact.done).length;
|
|
300
|
+
const currentItems = artifacts.find(
|
|
301
|
+
(artifact) => artifact.id === current,
|
|
302
|
+
)?.items;
|
|
303
|
+
const itemsSettled =
|
|
304
|
+
currentItems === undefined ||
|
|
305
|
+
currentItems.total === 0 ||
|
|
306
|
+
currentItems.done >= currentItems.total;
|
|
307
|
+
// One hue per state: success only when the artifacts are done AND
|
|
308
|
+
// the current artifact's items are settled — otherwise work is open.
|
|
309
|
+
const complete = done === artifacts.length && itemsSettled;
|
|
310
|
+
segments.push({
|
|
311
|
+
text: `${current} ${done}/${artifacts.length}`,
|
|
312
|
+
color: complete ? 'success' : 'accent',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
256
316
|
}
|
|
257
|
-
|
|
258
|
-
|
|
317
|
+
const items = work.currentArtifact
|
|
318
|
+
? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
|
|
319
|
+
: undefined;
|
|
320
|
+
if (items && items.total > 0) {
|
|
321
|
+
const complete = items.done >= items.total;
|
|
322
|
+
if (segments.length > 0) {
|
|
323
|
+
segments.push({ text: ' › ', color: 'accent' });
|
|
324
|
+
}
|
|
259
325
|
segments.push({
|
|
260
|
-
text: `${complete ? '✔' : ''} ${
|
|
261
|
-
color:
|
|
326
|
+
text: `${complete ? '✔' : ''} ${items.done}/${items.total}`,
|
|
327
|
+
color: complete ? 'success' : 'accent',
|
|
262
328
|
...(complete ? { bold: true } : {}),
|
|
263
329
|
});
|
|
264
330
|
}
|
package/packages/status/index.ts
CHANGED
|
@@ -171,6 +171,10 @@ class GalacticaStatusRuntime {
|
|
|
171
171
|
private openSpecOverview: OpenSpecOverview | undefined;
|
|
172
172
|
private openSpecProjectDetected: boolean | undefined;
|
|
173
173
|
private focus: OpenSpecFocus;
|
|
174
|
+
/** Publisher-owned timestamp of the current focus fact — seeded at
|
|
175
|
+
* construction, refreshed on every accepted focus change; staleness
|
|
176
|
+
* compares it against the change directory's file truth. */
|
|
177
|
+
private focusAt: number | undefined;
|
|
174
178
|
private subject: SessionSubject;
|
|
175
179
|
private sessionName: string | undefined;
|
|
176
180
|
private diagnosticsPath: string | undefined;
|
|
@@ -209,6 +213,7 @@ class GalacticaStatusRuntime {
|
|
|
209
213
|
this.focus = focus;
|
|
210
214
|
this.subject = subject;
|
|
211
215
|
this.sessionName = sessionName;
|
|
216
|
+
this.focusAt = Date.now();
|
|
212
217
|
const restoredAwaitingValidation =
|
|
213
218
|
focus.mode === 'task' && focus.awaitingValidation;
|
|
214
219
|
this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
|
|
@@ -654,6 +659,7 @@ class GalacticaStatusRuntime {
|
|
|
654
659
|
focus: facts.openSpec,
|
|
655
660
|
openspec: snapshot.openspec,
|
|
656
661
|
activity: this.titleActivity.current(),
|
|
662
|
+
focusAt: this.focusAt,
|
|
657
663
|
});
|
|
658
664
|
}
|
|
659
665
|
|
|
@@ -690,6 +696,7 @@ class GalacticaStatusRuntime {
|
|
|
690
696
|
setFocus(focus: OpenSpecFocus): void {
|
|
691
697
|
if (sameOpenSpecFocus(this.focus, focus)) return;
|
|
692
698
|
this.focus = focus;
|
|
699
|
+
this.focusAt = Date.now();
|
|
693
700
|
this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
|
|
694
701
|
this.store.clearOpenSpec();
|
|
695
702
|
if (focus.mode === 'task' && focus.awaitingValidation) {
|
|
@@ -186,5 +186,8 @@ export function formatActiveWorkTitle(options: {
|
|
|
186
186
|
'pi';
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
|
|
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);
|
|
190
193
|
}
|
|
@@ -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,
|
|
@@ -10,7 +10,7 @@ import type { OpenSpecState } from './types.ts';
|
|
|
10
10
|
* code may never write any slot; it only parses defensively.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const TITLE_STATE_VERSION =
|
|
13
|
+
export const TITLE_STATE_VERSION = 2 as const;
|
|
14
14
|
|
|
15
15
|
export const TITLE_ACTIVITY_CLASSES = [
|
|
16
16
|
'editing',
|
|
@@ -37,14 +37,25 @@ const TASK_ID_MAX = 32;
|
|
|
37
37
|
const SPAN_ID_MAX = 64;
|
|
38
38
|
const WAIVER_ID_MAX = 64;
|
|
39
39
|
const NOTICE_MAX = 180;
|
|
40
|
+
const ARTIFACTS_MAX = 16;
|
|
41
|
+
|
|
42
|
+
export type TitleArtifactState = {
|
|
43
|
+
id: string;
|
|
44
|
+
done: boolean;
|
|
45
|
+
items?: { done: number; total: number };
|
|
46
|
+
};
|
|
40
47
|
|
|
41
48
|
export type TitleWorkState = {
|
|
42
49
|
change: string;
|
|
43
50
|
task?: string;
|
|
44
|
-
done?: number;
|
|
45
|
-
total?: number;
|
|
46
51
|
schemaName?: string;
|
|
47
|
-
|
|
52
|
+
artifacts?: TitleArtifactState[];
|
|
53
|
+
/** The artifact the work is in — first not done, else last done. */
|
|
54
|
+
currentArtifact?: string;
|
|
55
|
+
/** Attention facts, derived from publisher-owned truth only. */
|
|
56
|
+
awaitingValidation?: boolean;
|
|
57
|
+
focusAt?: number;
|
|
58
|
+
focusStale?: boolean;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
export type TitleActivityState = {
|
|
@@ -115,46 +126,66 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
115
126
|
if (!isRecord(value)) return undefined;
|
|
116
127
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
117
128
|
if (!change) return undefined;
|
|
118
|
-
// Counts are optional as a pair: absent means unknown (never fabricated
|
|
119
|
-
// zeros); present-but-invalid rejects the object.
|
|
120
|
-
const hasCounts = 'done' in value && 'total' in value;
|
|
121
|
-
if ('done' in value !== 'total' in value) return undefined;
|
|
122
|
-
const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
|
|
123
|
-
const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
|
|
124
|
-
if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
129
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
128
130
|
if (task === null) return undefined;
|
|
129
131
|
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
130
132
|
required: false,
|
|
131
133
|
});
|
|
132
134
|
if (schemaName === null) return undefined;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
|
|
136
|
+
required: false,
|
|
137
|
+
});
|
|
138
|
+
if (currentArtifact === null) return undefined;
|
|
139
|
+
let artifacts: TitleArtifactState[] | undefined;
|
|
140
|
+
if ('artifacts' in value) {
|
|
141
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const parsed: TitleArtifactState[] = [];
|
|
145
|
+
for (const entry of value.artifacts) {
|
|
146
|
+
if (!isRecord(entry)) return undefined;
|
|
147
|
+
const id = boundedText(entry.id, TASK_ID_MAX);
|
|
148
|
+
if (!id) return undefined;
|
|
149
|
+
if (typeof entry.done !== 'boolean') return undefined;
|
|
150
|
+
const artifactDone = entry.done;
|
|
151
|
+
let items: { done: number; total: number } | undefined;
|
|
152
|
+
if ('items' in entry) {
|
|
153
|
+
if (!isRecord(entry.items)) return undefined;
|
|
154
|
+
const done = boundedTotal(entry.items.done);
|
|
155
|
+
const total = boundedTotal(entry.items.total);
|
|
156
|
+
if (done === null || total === null) return undefined;
|
|
157
|
+
items = { done, total };
|
|
158
|
+
}
|
|
159
|
+
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
160
|
+
}
|
|
161
|
+
artifacts = parsed;
|
|
162
|
+
}
|
|
163
|
+
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
let focusAt: number | undefined;
|
|
167
|
+
if ('focusAt' in value) {
|
|
168
|
+
if (
|
|
169
|
+
typeof value.focusAt !== 'number' ||
|
|
170
|
+
!Number.isSafeInteger(value.focusAt) ||
|
|
171
|
+
value.focusAt < 0
|
|
172
|
+
) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
focusAt = value.focusAt;
|
|
176
|
+
}
|
|
177
|
+
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
178
|
+
return undefined;
|
|
146
179
|
}
|
|
147
|
-
const counts =
|
|
148
|
-
typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
|
|
149
|
-
? { done: boundedDone, total: boundedTotalValue }
|
|
150
|
-
: {};
|
|
151
180
|
const extras = {
|
|
152
181
|
...(schemaName ? { schemaName } : {}),
|
|
153
|
-
...(
|
|
182
|
+
...(artifacts ? { artifacts } : {}),
|
|
183
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
184
|
+
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
185
|
+
...(focusAt !== undefined ? { focusAt } : {}),
|
|
186
|
+
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
154
187
|
};
|
|
155
|
-
return task === undefined
|
|
156
|
-
? { change, ...counts, ...extras }
|
|
157
|
-
: { change, task, ...counts, ...extras };
|
|
188
|
+
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
158
189
|
}
|
|
159
190
|
|
|
160
191
|
function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
@@ -202,46 +233,44 @@ export function shortenTitleTarget(value: string): string {
|
|
|
202
233
|
return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
|
|
203
234
|
}
|
|
204
235
|
|
|
205
|
-
function boundedCount(value: number | undefined, fallback = 0): number {
|
|
206
|
-
const candidate = value === undefined || !Number.isFinite(value) ? fallback : value;
|
|
207
|
-
return Math.max(0, Math.min(TITLE_TOTAL_MAX, Math.round(candidate)));
|
|
208
|
-
}
|
|
209
|
-
|
|
210
236
|
/**
|
|
211
237
|
* Derive the `work` slot from the OpenSpec focus and the focused snapshot.
|
|
212
238
|
* A task focus owns its change; without a focus a passively collected change
|
|
213
|
-
* still reports
|
|
239
|
+
* still reports artifact facts, just without a task id. Counts live only on
|
|
240
|
+
* artifact `items` — file truth; absent counts are absent, never zeros.
|
|
214
241
|
*/
|
|
215
242
|
export function deriveTitleWork(
|
|
216
243
|
focus: OpenSpecFocus,
|
|
217
244
|
openspec: OpenSpecState | null | undefined,
|
|
245
|
+
options: { focusAt?: number } = {},
|
|
218
246
|
): TitleWorkState | null {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
|
|
222
|
-
});
|
|
223
|
-
// Human steering 2026-10-23: no invented phase words — the ladder's
|
|
224
|
-
// rung name comes straight from OpenSpec.
|
|
247
|
+
// Artifact facts come straight from the collector; nothing is invented
|
|
248
|
+
// here — absent facts simply drop out of the contract.
|
|
225
249
|
const extras = (state: OpenSpecState | undefined) => {
|
|
226
250
|
if (!state) return {};
|
|
227
251
|
return {
|
|
228
252
|
...(state.schemaName ? { schemaName: state.schemaName } : {}),
|
|
229
|
-
...(state.
|
|
253
|
+
...(state.artifacts ? { artifacts: state.artifacts } : {}),
|
|
254
|
+
...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
|
|
230
255
|
};
|
|
231
256
|
};
|
|
232
257
|
if (focus.mode === 'task') {
|
|
233
258
|
const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
|
|
234
|
-
const
|
|
259
|
+
const stale =
|
|
260
|
+
options.focusAt !== undefined &&
|
|
261
|
+
state?.changeModifiedAt !== undefined &&
|
|
262
|
+
state.changeModifiedAt > options.focusAt;
|
|
235
263
|
return {
|
|
236
264
|
change: focus.change,
|
|
237
265
|
task: focus.taskId,
|
|
238
|
-
...(taskCounts ?? {}),
|
|
239
266
|
...extras(state),
|
|
267
|
+
...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
|
|
268
|
+
...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
|
|
269
|
+
...(stale ? { focusStale: true } : {}),
|
|
240
270
|
};
|
|
241
271
|
}
|
|
242
272
|
if (!openspec) return null;
|
|
243
|
-
|
|
244
|
-
return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
|
|
273
|
+
return { change: openspec.changeId, ...extras(openspec) };
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
/**
|
|
@@ -254,11 +283,15 @@ export function buildTitleState(options: {
|
|
|
254
283
|
focus: OpenSpecFocus;
|
|
255
284
|
openspec?: OpenSpecState | null;
|
|
256
285
|
activity?: TitleActivityState | null;
|
|
286
|
+
/** Publisher-owned timestamp of the current focus fact. */
|
|
287
|
+
focusAt?: number;
|
|
257
288
|
}): TitleState {
|
|
258
289
|
return {
|
|
259
290
|
v: TITLE_STATE_VERSION,
|
|
260
291
|
subject: options.subject.state === 'set' ? options.subject.title : null,
|
|
261
|
-
work: deriveTitleWork(options.focus, options.openspec
|
|
292
|
+
work: deriveTitleWork(options.focus, options.openspec, {
|
|
293
|
+
focusAt: options.focusAt,
|
|
294
|
+
}),
|
|
262
295
|
activity: options.activity ?? null,
|
|
263
296
|
waiver: null,
|
|
264
297
|
};
|
|
@@ -60,6 +60,15 @@ export interface OpenSpecTask {
|
|
|
60
60
|
done: boolean;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** One OpenSpec artifact: lifecycle from `openspec status`, item counts
|
|
64
|
+
* from the artifact's own file. Absent items mean unreadable or empty —
|
|
65
|
+
* never zeros. */
|
|
66
|
+
export interface OpenSpecArtifact {
|
|
67
|
+
id: string;
|
|
68
|
+
done: boolean;
|
|
69
|
+
items?: { done: number; total: number };
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
export interface OpenSpecOverview {
|
|
64
73
|
projectRoot: string;
|
|
65
74
|
actionableChanges: number;
|
|
@@ -90,7 +99,12 @@ export interface OpenSpecState {
|
|
|
90
99
|
tasksPath?: string;
|
|
91
100
|
hierarchy?: OpenSpecHierarchyProgress;
|
|
92
101
|
schemaName?: string;
|
|
93
|
-
|
|
102
|
+
artifacts?: OpenSpecArtifact[];
|
|
103
|
+
/** The artifact the work is currently in: first not yet done, falling
|
|
104
|
+
* back to the last done artifact once everything is complete. */
|
|
105
|
+
currentArtifact?: string;
|
|
106
|
+
/** Newest file mtime inside the change directory, when statable. */
|
|
107
|
+
changeModifiedAt?: number;
|
|
94
108
|
meta: SourceMeta;
|
|
95
109
|
}
|
|
96
110
|
|