@aemonge-dev/pi-stardeck 0.12.0 → 0.14.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 +13 -16
- package/packages/pi-stardeck/src/title-state.ts +149 -58
- package/packages/status/index.ts +35 -14
- package/packages/status/src/active-work.ts +16 -22
- package/packages/status/src/gate-act.ts +85 -0
- package/packages/status/src/openspec.ts +182 -34
- package/packages/status/src/publisher.ts +23 -14
- package/packages/status/src/title-state.ts +99 -52
- package/packages/status/src/types.ts +21 -4
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`;
|
|
@@ -163,10 +163,6 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
|
|
|
163
163
|
return lines.slice(0, maximumLines);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function mirrorKey(value: string): string {
|
|
167
|
-
return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
166
|
function bandWrap(
|
|
171
167
|
content: string,
|
|
172
168
|
width: number,
|
|
@@ -191,12 +187,10 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
|
|
|
191
187
|
// moment` — never an empty band while the session talks.
|
|
192
188
|
return bandWrap('— no subject', width, theme, 'dim');
|
|
193
189
|
}
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
const mirrorsFocus = work !== null && mirrorKey(subject) === mirrorKey(work.change);
|
|
199
|
-
return bandWrap(subject, width, theme, mirrorsFocus ? 'success' : 'accent');
|
|
190
|
+
// Human layout law 2026-11-10: the left band is Human-owned title —
|
|
191
|
+
// it can never mirror openspec, so the mirror coloring is gone and
|
|
192
|
+
// the band renders accent unconditionally.
|
|
193
|
+
return bandWrap(subject, width, theme, 'accent');
|
|
200
194
|
}
|
|
201
195
|
// Pre-title publishers: the legacy selection keeps the band alive.
|
|
202
196
|
const selection = state.header?.selection;
|
|
@@ -237,15 +231,18 @@ function alignedSingleRow(left: string, right: string, width: number): string {
|
|
|
237
231
|
return `${fittedLeft}${' '.repeat(gap)}${right}`;
|
|
238
232
|
}
|
|
239
233
|
|
|
240
|
-
function workTile(state: HeaderDeckState, theme: Theme): string {
|
|
234
|
+
function workTile(state: HeaderDeckState, theme: Theme, width: number): string {
|
|
241
235
|
// Line 2 right: Work — derived from OpenSpec focus, never authored here.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
236
|
+
// Width policy: the full tile from 56; 40–55 the attention/progress side
|
|
237
|
+
// yields first and the item count survives longest; below 40 the slot
|
|
238
|
+
// stays empty. A work slot without facts renders nothing — absent facts
|
|
239
|
+
// remove their slots, never sentinel dashes or narration.
|
|
240
|
+
if (width < 40) return '';
|
|
244
241
|
const title = state.header?.title ?? null;
|
|
245
242
|
if (!title) return '';
|
|
246
243
|
const work = title.work;
|
|
247
|
-
if (!work) return
|
|
248
|
-
return deckWorkSegments(work)
|
|
244
|
+
if (!work) return '';
|
|
245
|
+
return deckWorkSegments(work, Date.now(), width < 56)
|
|
249
246
|
.map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
|
|
250
247
|
.join('');
|
|
251
248
|
}
|
|
@@ -754,7 +751,7 @@ export function renderHeaderDeck(
|
|
|
754
751
|
// Line 2 (four-line deck): the Subject leads with Work on the right end
|
|
755
752
|
// (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
|
|
756
753
|
// Work slot yields before the Subject does.
|
|
757
|
-
const titleRight =
|
|
754
|
+
const titleRight = workTile(state, theme, boundedWidth);
|
|
758
755
|
const focusLines = titleBandLines(state, boundedWidth, theme);
|
|
759
756
|
const titleRows = [
|
|
760
757
|
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,30 @@ 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;
|
|
44
|
+
const GATE_ACT_MAX = 180;
|
|
41
45
|
const TURN_TOKENS_MAX = 999_999_999;
|
|
42
46
|
const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
43
47
|
|
|
48
|
+
export type DeckTitleArtifactState = {
|
|
49
|
+
id: string;
|
|
50
|
+
done: boolean;
|
|
51
|
+
items?: { done: number; total: number };
|
|
52
|
+
};
|
|
53
|
+
|
|
44
54
|
export type DeckTitleWorkState = {
|
|
45
55
|
change: string;
|
|
46
56
|
task?: string;
|
|
47
|
-
done?: number;
|
|
48
|
-
total?: number;
|
|
49
57
|
schemaName?: string;
|
|
50
|
-
|
|
58
|
+
artifacts?: DeckTitleArtifactState[];
|
|
59
|
+
/** The artifact the work is in — first not done, else last done. */
|
|
60
|
+
currentArtifact?: string;
|
|
61
|
+
/** Attention facts, derived from publisher-owned truth only. */
|
|
62
|
+
awaitingValidation?: boolean;
|
|
63
|
+
focusAt?: number;
|
|
64
|
+
focusStale?: boolean;
|
|
65
|
+
/** The gate's relayed act line — dim commentary in the optional slot. */
|
|
66
|
+
gateAct?: string;
|
|
51
67
|
};
|
|
52
68
|
|
|
53
69
|
export type DeckTitleActivityState = {
|
|
@@ -121,46 +137,69 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
121
137
|
if (!isRecord(value)) return undefined;
|
|
122
138
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
123
139
|
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
140
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
134
141
|
if (task === null) return undefined;
|
|
135
142
|
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
136
143
|
required: false,
|
|
137
144
|
});
|
|
138
145
|
if (schemaName === null) return undefined;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
|
|
147
|
+
required: false,
|
|
148
|
+
});
|
|
149
|
+
if (currentArtifact === null) return undefined;
|
|
150
|
+
let artifacts: DeckTitleArtifactState[] | undefined;
|
|
151
|
+
if ('artifacts' in value) {
|
|
152
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const parsed: DeckTitleArtifactState[] = [];
|
|
156
|
+
for (const entry of value.artifacts) {
|
|
157
|
+
if (!isRecord(entry)) return undefined;
|
|
158
|
+
const id = boundedText(entry.id, TASK_ID_MAX);
|
|
159
|
+
if (!id) return undefined;
|
|
160
|
+
if (typeof entry.done !== 'boolean') return undefined;
|
|
161
|
+
const artifactDone = entry.done;
|
|
162
|
+
let items: { done: number; total: number } | undefined;
|
|
163
|
+
if ('items' in entry) {
|
|
164
|
+
if (!isRecord(entry.items)) return undefined;
|
|
165
|
+
const done = boundedTotal(entry.items.done);
|
|
166
|
+
const total = boundedTotal(entry.items.total);
|
|
167
|
+
if (done === null || total === null) return undefined;
|
|
168
|
+
items = { done, total };
|
|
169
|
+
}
|
|
170
|
+
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
171
|
+
}
|
|
172
|
+
artifacts = parsed;
|
|
173
|
+
}
|
|
174
|
+
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
175
|
+
return undefined;
|
|
152
176
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
let focusAt: number | undefined;
|
|
178
|
+
if ('focusAt' in value) {
|
|
179
|
+
if (
|
|
180
|
+
typeof value.focusAt !== 'number' ||
|
|
181
|
+
!Number.isSafeInteger(value.focusAt) ||
|
|
182
|
+
value.focusAt < 0
|
|
183
|
+
) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
focusAt = value.focusAt;
|
|
187
|
+
}
|
|
188
|
+
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
|
|
192
|
+
if (gateAct === null) return undefined;
|
|
157
193
|
const extras = {
|
|
158
194
|
...(schemaName ? { schemaName } : {}),
|
|
159
|
-
...(
|
|
195
|
+
...(artifacts ? { artifacts } : {}),
|
|
196
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
197
|
+
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
198
|
+
...(focusAt !== undefined ? { focusAt } : {}),
|
|
199
|
+
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
200
|
+
...(gateAct ? { gateAct } : {}),
|
|
160
201
|
};
|
|
161
|
-
return task === undefined
|
|
162
|
-
? { change, ...counts, ...extras }
|
|
163
|
-
: { change, task, ...counts, ...extras };
|
|
202
|
+
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
164
203
|
}
|
|
165
204
|
|
|
166
205
|
function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
|
|
@@ -232,33 +271,85 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
232
271
|
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
233
272
|
|
|
234
273
|
/**
|
|
235
|
-
* The Work label as colored segments — the
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
274
|
+
* The Work label as colored segments — the truthful, de-duplicated tile
|
|
275
|
+
* under the layout law (truthful-rendered-state 2026-11-10):
|
|
276
|
+
* `<slug> › <artifact> <done/total> › gate: <act> › <items>` — the
|
|
277
|
+
* slug leads because openspec identity renders exclusively on the right
|
|
278
|
+
* (the left band is Human-owned title), then current artifact + artifact
|
|
279
|
+
* progress, the gate's relayed act dim in the optional slot, and the
|
|
280
|
+
* current artifact's file-truth item count. Attention is derived, never authored:
|
|
281
|
+
* `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
|
|
282
|
+
* (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
|
|
283
|
+
* group. Unknown facts drop their segments — absent items never render as
|
|
284
|
+
* zeros. Compact width: the attention/progress side yields first, the item
|
|
285
|
+
* count survives longest. Separators are chrome with hard deck-wide colors —
|
|
286
|
+
* `›` accent like every minor separator.
|
|
242
287
|
*/
|
|
243
|
-
export function deckWorkSegments(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const segments: DeckWorkSegment[] = [{ text: work.change, color: base }];
|
|
251
|
-
const ladder = work.ladder;
|
|
252
|
-
if (ladder) {
|
|
253
|
-
segments.push({ text: ' ⟩ ', color: 'thinkingHigh', bold: true });
|
|
254
|
-
const group = `${ladder.done}/${ladder.total}${ladder.rung ? ` ${ladder.rung}` : ''}`;
|
|
255
|
-
segments.push({ text: group, color: base });
|
|
288
|
+
export function deckWorkSegments(
|
|
289
|
+
work: DeckTitleWorkState,
|
|
290
|
+
now: number = Date.now(),
|
|
291
|
+
compact = false,
|
|
292
|
+
): DeckWorkSegment[] {
|
|
293
|
+
if (work.awaitingValidation) {
|
|
294
|
+
return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
|
|
256
295
|
}
|
|
257
|
-
|
|
258
|
-
|
|
296
|
+
// The slug leads the right side — openspec identity renders exclusively
|
|
297
|
+
// here (Human layout law 2026-11-10); the left band is Human-owned.
|
|
298
|
+
const segments: DeckWorkSegment[] = [
|
|
299
|
+
{ text: work.change, color: 'success', bold: true },
|
|
300
|
+
];
|
|
301
|
+
const stale = work.focusStale === true && work.focusAt !== undefined;
|
|
302
|
+
if (!compact) {
|
|
303
|
+
if (stale) {
|
|
304
|
+
segments.push({ text: ' › ', color: 'accent' });
|
|
305
|
+
segments.push({
|
|
306
|
+
text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
|
|
307
|
+
color: 'dim',
|
|
308
|
+
});
|
|
309
|
+
} else {
|
|
310
|
+
const artifacts = work.artifacts;
|
|
311
|
+
const current = work.currentArtifact;
|
|
312
|
+
if (artifacts && artifacts.length > 0 && current) {
|
|
313
|
+
const done = artifacts.filter((artifact) => artifact.done).length;
|
|
314
|
+
const currentItems = artifacts.find(
|
|
315
|
+
(artifact) => artifact.id === current,
|
|
316
|
+
)?.items;
|
|
317
|
+
const itemsSettled =
|
|
318
|
+
currentItems === undefined ||
|
|
319
|
+
currentItems.total === 0 ||
|
|
320
|
+
currentItems.done >= currentItems.total;
|
|
321
|
+
// One hue per state: success only when the artifacts are done AND
|
|
322
|
+
// the current artifact's items are settled — otherwise work is open.
|
|
323
|
+
const complete = done === artifacts.length && itemsSettled;
|
|
324
|
+
segments.push(
|
|
325
|
+
{ text: ' › ', color: 'accent' },
|
|
326
|
+
{
|
|
327
|
+
text: `${current} ${done}/${artifacts.length}`,
|
|
328
|
+
color: complete ? 'success' : 'accent',
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// The {optional} slot: the gate's relayed act — dim commentary that
|
|
334
|
+
// yields first on narrow frames.
|
|
335
|
+
if (work.gateAct) {
|
|
336
|
+
segments.push(
|
|
337
|
+
{ text: ' › ', color: 'accent' },
|
|
338
|
+
{ text: `gate: ${work.gateAct}`, color: 'dim' },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const items = work.currentArtifact
|
|
343
|
+
? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
|
|
344
|
+
: undefined;
|
|
345
|
+
if (items && items.total > 0) {
|
|
346
|
+
const complete = items.done >= items.total;
|
|
347
|
+
if (segments.length > 0) {
|
|
348
|
+
segments.push({ text: ' › ', color: 'accent' });
|
|
349
|
+
}
|
|
259
350
|
segments.push({
|
|
260
|
-
text: `${complete ? '✔' : ''} ${
|
|
261
|
-
color:
|
|
351
|
+
text: `${complete ? '✔' : ''} ${items.done}/${items.total}`,
|
|
352
|
+
color: complete ? 'success' : 'accent',
|
|
262
353
|
...(complete ? { bold: true } : {}),
|
|
263
354
|
});
|
|
264
355
|
}
|
package/packages/status/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './src/active-work.ts';
|
|
18
18
|
import { collectDiagnostics } from './src/diagnostics.ts';
|
|
19
19
|
import { loadConfig } from './src/config.ts';
|
|
20
|
+
import { resolveGateAct } from './src/gate-act.ts';
|
|
20
21
|
import { collectOpenSpec, collectOpenSpecOverview } from './src/openspec.ts';
|
|
21
22
|
import { collectOrchestration } from './src/orchestration.ts';
|
|
22
23
|
import {
|
|
@@ -171,6 +172,14 @@ class GalacticaStatusRuntime {
|
|
|
171
172
|
private openSpecOverview: OpenSpecOverview | undefined;
|
|
172
173
|
private openSpecProjectDetected: boolean | undefined;
|
|
173
174
|
private focus: OpenSpecFocus;
|
|
175
|
+
/** Publisher-owned timestamp of the current focus fact — seeded at
|
|
176
|
+
* construction, refreshed on every accepted focus change; staleness
|
|
177
|
+
* compares it against the change directory's file truth. */
|
|
178
|
+
private focusAt: number | undefined;
|
|
179
|
+
/** The gate's relayed act line for the focused change — commentary
|
|
180
|
+
* beside the declared focus, never a replacement; cleared whenever
|
|
181
|
+
* the focus moves so stale gate truth never survives a switch. */
|
|
182
|
+
private gateAct: { change: string; line?: string } | undefined;
|
|
174
183
|
private subject: SessionSubject;
|
|
175
184
|
private sessionName: string | undefined;
|
|
176
185
|
private diagnosticsPath: string | undefined;
|
|
@@ -209,6 +218,7 @@ class GalacticaStatusRuntime {
|
|
|
209
218
|
this.focus = focus;
|
|
210
219
|
this.subject = subject;
|
|
211
220
|
this.sessionName = sessionName;
|
|
221
|
+
this.focusAt = Date.now();
|
|
212
222
|
const restoredAwaitingValidation =
|
|
213
223
|
focus.mode === 'task' && focus.awaitingValidation;
|
|
214
224
|
this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
|
|
@@ -454,6 +464,19 @@ class GalacticaStatusRuntime {
|
|
|
454
464
|
) {
|
|
455
465
|
return;
|
|
456
466
|
}
|
|
467
|
+
// The gate act is focus-owned commentary: resolved only for a task
|
|
468
|
+
// focus inside an OpenSpec project, after the commit guard, so a
|
|
469
|
+
// superseded refresh never spends a probe on stale truth.
|
|
470
|
+
if (focus.mode === 'task' && hasOpenSpecProject) {
|
|
471
|
+
this.gateAct = {
|
|
472
|
+
change: focus.change,
|
|
473
|
+
line: await resolveGateAct({
|
|
474
|
+
change: focus.change,
|
|
475
|
+
projectRoot,
|
|
476
|
+
runner: this.runner,
|
|
477
|
+
}),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
457
480
|
const previous = this.store.get();
|
|
458
481
|
this.updateRefreshHealth(
|
|
459
482
|
'openspec',
|
|
@@ -628,23 +651,15 @@ class GalacticaStatusRuntime {
|
|
|
628
651
|
|
|
629
652
|
private currentTitleState(snapshot = this.store.get()): TitleState {
|
|
630
653
|
const facts = this.cockpitFacts;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
? (snapshot.openspec.allTasks ?? snapshot.openspec.pendingTasks).find(
|
|
638
|
-
(task) => task.id === focusedTaskId,
|
|
639
|
-
)?.title
|
|
654
|
+
// The left title is Human-owned — session name, subject, directory —
|
|
655
|
+
// never openspec text (truthful-rendered-state law 2026-11-10); the
|
|
656
|
+
// gate act rides the work slot for the right-side tile.
|
|
657
|
+
const gateAct =
|
|
658
|
+
facts.openSpec.mode === 'task' && this.gateAct?.change === facts.openSpec.change
|
|
659
|
+
? this.gateAct.line
|
|
640
660
|
: undefined;
|
|
641
|
-
// The band chip renders the fallback chain — task focus, native session
|
|
642
|
-
// name, legacy subject, directory. The terminal titlebar is pi-title's
|
|
643
|
-
// alone; this package never paints it (pure-renderer-split Slice 2).
|
|
644
661
|
const subjectTitle = formatActiveWorkTitle({
|
|
645
|
-
focus: facts.openSpec,
|
|
646
662
|
subject: facts.subject,
|
|
647
|
-
...(taskTitle ? { taskTitle } : {}),
|
|
648
663
|
...(this.sessionName ? { sessionName: this.sessionName } : {}),
|
|
649
664
|
projectRoot: snapshot.projectRoot,
|
|
650
665
|
cwd: snapshot.cwd,
|
|
@@ -654,6 +669,8 @@ class GalacticaStatusRuntime {
|
|
|
654
669
|
focus: facts.openSpec,
|
|
655
670
|
openspec: snapshot.openspec,
|
|
656
671
|
activity: this.titleActivity.current(),
|
|
672
|
+
focusAt: this.focusAt,
|
|
673
|
+
...(gateAct ? { gateAct } : {}),
|
|
657
674
|
});
|
|
658
675
|
}
|
|
659
676
|
|
|
@@ -690,6 +707,10 @@ class GalacticaStatusRuntime {
|
|
|
690
707
|
setFocus(focus: OpenSpecFocus): void {
|
|
691
708
|
if (sameOpenSpecFocus(this.focus, focus)) return;
|
|
692
709
|
this.focus = focus;
|
|
710
|
+
this.focusAt = Date.now();
|
|
711
|
+
if (focus.mode !== 'task' || this.gateAct?.change !== focus.change) {
|
|
712
|
+
this.gateAct = undefined;
|
|
713
|
+
}
|
|
693
714
|
this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
|
|
694
715
|
this.store.clearOpenSpec();
|
|
695
716
|
if (focus.mode === 'task' && focus.awaitingValidation) {
|
|
@@ -20,7 +20,6 @@ export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
|
|
|
20
20
|
|
|
21
21
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
22
22
|
const MAX_TITLE_POINTS = 180;
|
|
23
|
-
const MAX_TASK_TITLE_POINTS = 72;
|
|
24
23
|
const MAX_SUBJECT_POINTS = 120;
|
|
25
24
|
|
|
26
25
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -160,31 +159,26 @@ export function sameSessionSubject(
|
|
|
160
159
|
);
|
|
161
160
|
}
|
|
162
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The Human-owned left title: session name, subject, then the directory
|
|
164
|
+
* fallbacks. Layout law 2026-11-10 (truthful-rendered-state): openspec
|
|
165
|
+
* identity renders exclusively on the right work tile — this chain never
|
|
166
|
+
* carries change, task, or gate text.
|
|
167
|
+
*/
|
|
163
168
|
export function formatActiveWorkTitle(options: {
|
|
164
|
-
focus: OpenSpecFocus;
|
|
165
169
|
subject?: SessionSubject;
|
|
166
|
-
taskTitle?: string;
|
|
167
170
|
sessionName?: string;
|
|
168
171
|
projectRoot?: string;
|
|
169
172
|
cwd: string;
|
|
170
173
|
}): string {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
} else {
|
|
182
|
-
detail =
|
|
183
|
-
normalizeTitlePart(options.sessionName ?? '') ||
|
|
184
|
-
normalizeTitlePart(basename(options.projectRoot ?? '')) ||
|
|
185
|
-
normalizeTitlePart(basename(options.cwd)) ||
|
|
186
|
-
'pi';
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return shorten(`π ${normalizeTitlePart(detail)}`, MAX_TITLE_POINTS);
|
|
174
|
+
const detail =
|
|
175
|
+
normalizeTitlePart(options.sessionName ?? '') ||
|
|
176
|
+
(options.subject && options.subject.state === 'set' ? options.subject.title : '') ||
|
|
177
|
+
normalizeTitlePart(basename(options.projectRoot ?? '')) ||
|
|
178
|
+
normalizeTitlePart(basename(options.cwd)) ||
|
|
179
|
+
'pi';
|
|
180
|
+
|
|
181
|
+
// Bare text — line 2 carries no chrome (frozen amendment): the title
|
|
182
|
+
// stands naked, and pi-title's naming is quoted, never re-decorated.
|
|
183
|
+
return shorten(detail, MAX_TITLE_POINTS);
|
|
190
184
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import type { CommandRunner } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/** Relay budget: the gate's act line is cockpit commentary — a slow or
|
|
6
|
+
* absent gate must cost less than a refresh, never block one. */
|
|
7
|
+
const GATE_PROBE_TIMEOUT_MS = 2_000;
|
|
8
|
+
/** Re-probe cadence when the meta file cannot be statted at all — time
|
|
9
|
+
* is the only cache key left, so it steps lightly. */
|
|
10
|
+
const ABSENT_META_TTL_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
13
|
+
|
|
14
|
+
interface GateActCacheEntry {
|
|
15
|
+
mtimeMs?: number;
|
|
16
|
+
at: number;
|
|
17
|
+
line: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** mtime-keyed relay cache: one probe per meta mtime, negatives cached
|
|
21
|
+
* exactly like hits — a missing or refusing gate must never become a
|
|
22
|
+
* per-render spawn storm. Keyed by contained change root, not slug. */
|
|
23
|
+
const gateActCache = new Map<string, GateActCacheEntry>();
|
|
24
|
+
|
|
25
|
+
function containedChangeRoot(projectRoot: string, change: string): string | undefined {
|
|
26
|
+
const changesRoot = resolve(projectRoot, 'openspec', 'changes');
|
|
27
|
+
const changeRoot = resolve(changesRoot, change);
|
|
28
|
+
if (changeRoot === changesRoot || !changeRoot.startsWith(`${changesRoot}${sep}`)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return changeRoot;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The gate's teaching line for one change, relayed verbatim:
|
|
36
|
+
* `pios-gate <slug>` — the aim — owns truth and words; this module owns
|
|
37
|
+
* only placement and cost. Any non-zero exit (unknown changes error),
|
|
38
|
+
* timeout kill, spawn failure, or empty output yields `undefined` —
|
|
39
|
+
* declared focus renders alone, quietly. The meta file is never parsed:
|
|
40
|
+
* its mtime only keys the cache.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveGateAct(options: {
|
|
43
|
+
change: string;
|
|
44
|
+
projectRoot: string;
|
|
45
|
+
runner: CommandRunner;
|
|
46
|
+
now?: number;
|
|
47
|
+
}): Promise<string | undefined> {
|
|
48
|
+
const changeRoot = containedChangeRoot(options.projectRoot, options.change);
|
|
49
|
+
if (!changeRoot) return undefined;
|
|
50
|
+
const now = options.now ?? Date.now();
|
|
51
|
+
|
|
52
|
+
let mtimeMs: number | undefined;
|
|
53
|
+
try {
|
|
54
|
+
mtimeMs = (await fs.stat(resolve(changeRoot, '.openspec.yaml'))).mtimeMs;
|
|
55
|
+
} catch {
|
|
56
|
+
mtimeMs = undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cached = gateActCache.get(changeRoot);
|
|
60
|
+
if (
|
|
61
|
+
cached &&
|
|
62
|
+
(cached.mtimeMs !== undefined
|
|
63
|
+
? cached.mtimeMs === mtimeMs
|
|
64
|
+
: mtimeMs === undefined && now - cached.at < ABSENT_META_TTL_MS)
|
|
65
|
+
) {
|
|
66
|
+
return cached.line;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let line: string | undefined;
|
|
70
|
+
try {
|
|
71
|
+
const result = await options.runner('pios-gate', [options.change], {
|
|
72
|
+
cwd: options.projectRoot,
|
|
73
|
+
timeoutMs: GATE_PROBE_TIMEOUT_MS,
|
|
74
|
+
});
|
|
75
|
+
if (result.code === 0 && !result.killed) {
|
|
76
|
+
const firstLine = String(result.stdout).split('\n', 1)[0] ?? '';
|
|
77
|
+
const cleaned = firstLine.replace(CONTROL_CHARACTERS, ' ').trim();
|
|
78
|
+
if (cleaned) line = cleaned;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Missing binary, spawn refusal, whatever went wrong: no gate line.
|
|
82
|
+
}
|
|
83
|
+
gateActCache.set(changeRoot, { mtimeMs, at: now, line });
|
|
84
|
+
return line;
|
|
85
|
+
}
|
|
@@ -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,14 +492,31 @@ 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;
|
|
382
|
-
let instructionProgress: ApplyInstructionsResult['progress'];
|
|
383
520
|
let hierarchy: OpenSpecHierarchyProgress | undefined;
|
|
384
521
|
try {
|
|
385
522
|
const instructions = parseApplyInstructions(
|
|
@@ -392,7 +529,6 @@ export async function collectOpenSpec(options: {
|
|
|
392
529
|
);
|
|
393
530
|
tasks = instructions.tasks;
|
|
394
531
|
tasksPath = instructions.tasksPath;
|
|
395
|
-
instructionProgress = instructions.progress;
|
|
396
532
|
|
|
397
533
|
if (tasksPath) {
|
|
398
534
|
try {
|
|
@@ -414,34 +550,46 @@ export async function collectOpenSpec(options: {
|
|
|
414
550
|
// tile at 0/N (simplify-deck-defaults). CLI progress and list counts
|
|
415
551
|
// remain fallbacks; the blocked-apply zero-guard stays for the
|
|
416
552
|
// no-tasks case.
|
|
553
|
+
// File-truth ladder: 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;
|
|
417
563
|
const completedTasks =
|
|
418
|
-
tasks.length > 0
|
|
419
|
-
|
|
420
|
-
: Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
|
|
421
|
-
const totalTasks =
|
|
422
|
-
tasks.length > 0
|
|
423
|
-
? tasks.length
|
|
424
|
-
: 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;
|
|
425
566
|
const complete =
|
|
426
|
-
change.status === 'complete' ||
|
|
567
|
+
change.status === 'complete' ||
|
|
568
|
+
(totalTasks !== undefined && totalTasks > 0 && (completedTasks ?? 0) >= totalTasks);
|
|
427
569
|
|
|
428
570
|
return {
|
|
429
571
|
projectRoot,
|
|
430
572
|
changeId: change.name,
|
|
431
573
|
title,
|
|
432
574
|
phase: complete ? 'complete' : change.status,
|
|
433
|
-
completedTasks,
|
|
434
|
-
totalTasks,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
: {}),
|
|
439
585
|
pendingTasks: tasks.filter((task) => !task.done),
|
|
440
586
|
allTasks: tasks,
|
|
441
587
|
...(tasksPath ? { tasksPath } : {}),
|
|
442
588
|
...(hierarchy ? { hierarchy } : {}),
|
|
443
589
|
...(schemaName ? { schemaName } : {}),
|
|
444
|
-
...(
|
|
590
|
+
...(artifacts ? { artifacts } : {}),
|
|
591
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
592
|
+
...(changeModifiedAt !== undefined ? { changeModifiedAt } : {}),
|
|
445
593
|
meta: {
|
|
446
594
|
source: 'openspec-cli',
|
|
447
595
|
refreshedAt: now,
|
|
@@ -99,7 +99,13 @@ export function formatOpenSpec(
|
|
|
99
99
|
focusedTaskId: string | null,
|
|
100
100
|
compact = false,
|
|
101
101
|
): string {
|
|
102
|
-
|
|
102
|
+
// File-truth counts only — absent counts render no progress prefix at
|
|
103
|
+
// all (absence never becomes zero, truthful-rendered-state).
|
|
104
|
+
const progress =
|
|
105
|
+
state.totalTasks !== undefined
|
|
106
|
+
? `${state.completedTasks ?? 0}/${state.totalTasks}`
|
|
107
|
+
: '';
|
|
108
|
+
const lead = progress ? `${progress} ` : '';
|
|
103
109
|
const title = compact
|
|
104
110
|
? compactChangeTitle(state.title, 26)
|
|
105
111
|
: normalizeChangeTitle(state.title);
|
|
@@ -107,16 +113,16 @@ export function formatOpenSpec(
|
|
|
107
113
|
? ` stale ${ageText(state.meta.updatedAt, Date.now()) ?? ''}`.trimEnd()
|
|
108
114
|
: '';
|
|
109
115
|
|
|
110
|
-
if (state.phase === 'complete') return `${
|
|
116
|
+
if (state.phase === 'complete') return `${lead}${title}${stale}`;
|
|
111
117
|
|
|
112
118
|
const activeTask = focusedTaskId
|
|
113
119
|
? (state.allTasks ?? state.pendingTasks).find((task) => task.id === focusedTaskId)
|
|
114
120
|
: undefined;
|
|
115
121
|
if (activeTask) {
|
|
116
|
-
return `${
|
|
122
|
+
return `${lead}▶ ${focusedTaskText(activeTask, compact)}${stale}`;
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
return `${
|
|
125
|
+
return `${lead}${title}${stale}`;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
function diagnosticsColor(state: DiagnosticsState, now: number): WidgetColor {
|
|
@@ -917,16 +923,19 @@ export function buildHeaderStatusEvent(
|
|
|
917
923
|
...projectedActivity,
|
|
918
924
|
};
|
|
919
925
|
}
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
926
|
+
const openSpec = options.openSpec;
|
|
927
|
+
const progress =
|
|
928
|
+
typeof openSpec?.totalTasks === 'number' &&
|
|
929
|
+
typeof openSpec.completedTasks === 'number'
|
|
930
|
+
? [
|
|
931
|
+
{
|
|
932
|
+
icon: '',
|
|
933
|
+
completed: openSpec.completedTasks,
|
|
934
|
+
total: openSpec.totalTasks,
|
|
935
|
+
color: openSpecColor(openSpec),
|
|
936
|
+
},
|
|
937
|
+
]
|
|
938
|
+
: [];
|
|
930
939
|
|
|
931
940
|
return {
|
|
932
941
|
protocol: 1,
|
|
@@ -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,28 @@ 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
|
+
const GATE_ACT_MAX = 180;
|
|
42
|
+
|
|
43
|
+
export type TitleArtifactState = {
|
|
44
|
+
id: string;
|
|
45
|
+
done: boolean;
|
|
46
|
+
items?: { done: number; total: number };
|
|
47
|
+
};
|
|
40
48
|
|
|
41
49
|
export type TitleWorkState = {
|
|
42
50
|
change: string;
|
|
43
51
|
task?: string;
|
|
44
|
-
done?: number;
|
|
45
|
-
total?: number;
|
|
46
52
|
schemaName?: string;
|
|
47
|
-
|
|
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;
|
|
48
62
|
};
|
|
49
63
|
|
|
50
64
|
export type TitleActivityState = {
|
|
@@ -115,46 +129,69 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
115
129
|
if (!isRecord(value)) return undefined;
|
|
116
130
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
117
131
|
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
132
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
128
133
|
if (task === null) return undefined;
|
|
129
134
|
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
130
135
|
required: false,
|
|
131
136
|
});
|
|
132
137
|
if (schemaName === null) return undefined;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
|
|
139
|
+
required: false,
|
|
140
|
+
});
|
|
141
|
+
if (currentArtifact === null) return undefined;
|
|
142
|
+
let artifacts: TitleArtifactState[] | undefined;
|
|
143
|
+
if ('artifacts' in value) {
|
|
144
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const parsed: TitleArtifactState[] = [];
|
|
148
|
+
for (const entry of value.artifacts) {
|
|
149
|
+
if (!isRecord(entry)) return undefined;
|
|
150
|
+
const id = boundedText(entry.id, TASK_ID_MAX);
|
|
151
|
+
if (!id) return undefined;
|
|
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;
|
|
146
168
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
185
|
const extras = {
|
|
152
186
|
...(schemaName ? { schemaName } : {}),
|
|
153
|
-
...(
|
|
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 } : {}),
|
|
154
193
|
};
|
|
155
|
-
return task === undefined
|
|
156
|
-
? { change, ...counts, ...extras }
|
|
157
|
-
: { change, task, ...counts, ...extras };
|
|
194
|
+
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
@@ -202,46 +239,49 @@ export function shortenTitleTarget(value: string): string {
|
|
|
202
239
|
return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
|
|
203
240
|
}
|
|
204
241
|
|
|
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
242
|
/**
|
|
211
243
|
* Derive the `work` slot from the OpenSpec focus and the focused snapshot.
|
|
212
244
|
* A task focus owns its change; without a focus a passively collected change
|
|
213
|
-
* still reports
|
|
245
|
+
* still reports artifact facts, just without a task id. Counts live only on
|
|
246
|
+
* artifact `items` — file truth; absent counts are absent, never zeros.
|
|
214
247
|
*/
|
|
215
248
|
export function deriveTitleWork(
|
|
216
249
|
focus: OpenSpecFocus,
|
|
217
250
|
openspec: OpenSpecState | null | undefined,
|
|
251
|
+
options: { focusAt?: number; gateAct?: string } = {},
|
|
218
252
|
): 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.
|
|
253
|
+
// Artifact facts come straight from the collector; nothing is invented
|
|
254
|
+
// here — absent facts simply drop out of the contract.
|
|
225
255
|
const extras = (state: OpenSpecState | undefined) => {
|
|
226
256
|
if (!state) return {};
|
|
227
257
|
return {
|
|
228
258
|
...(state.schemaName ? { schemaName: state.schemaName } : {}),
|
|
229
|
-
...(state.
|
|
259
|
+
...(state.artifacts ? { artifacts: state.artifacts } : {}),
|
|
260
|
+
...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
|
|
230
261
|
};
|
|
231
262
|
};
|
|
232
263
|
if (focus.mode === 'task') {
|
|
233
264
|
const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
|
|
234
|
-
const
|
|
265
|
+
const stale =
|
|
266
|
+
options.focusAt !== undefined &&
|
|
267
|
+
state?.changeModifiedAt !== undefined &&
|
|
268
|
+
state.changeModifiedAt > options.focusAt;
|
|
235
269
|
return {
|
|
236
270
|
change: focus.change,
|
|
237
271
|
task: focus.taskId,
|
|
238
|
-
...(taskCounts ?? {}),
|
|
239
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 } : {}),
|
|
240
277
|
};
|
|
241
278
|
}
|
|
242
279
|
if (!openspec) return null;
|
|
243
|
-
|
|
244
|
-
|
|
280
|
+
return {
|
|
281
|
+
change: openspec.changeId,
|
|
282
|
+
...extras(openspec),
|
|
283
|
+
...(options.gateAct ? { gateAct: options.gateAct } : {}),
|
|
284
|
+
};
|
|
245
285
|
}
|
|
246
286
|
|
|
247
287
|
/**
|
|
@@ -254,11 +294,18 @@ export function buildTitleState(options: {
|
|
|
254
294
|
focus: OpenSpecFocus;
|
|
255
295
|
openspec?: OpenSpecState | null;
|
|
256
296
|
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;
|
|
257
301
|
}): TitleState {
|
|
258
302
|
return {
|
|
259
303
|
v: TITLE_STATE_VERSION,
|
|
260
304
|
subject: options.subject.state === 'set' ? options.subject.title : null,
|
|
261
|
-
work: deriveTitleWork(options.focus, options.openspec
|
|
305
|
+
work: deriveTitleWork(options.focus, options.openspec, {
|
|
306
|
+
focusAt: options.focusAt,
|
|
307
|
+
...(options.gateAct ? { gateAct: options.gateAct } : {}),
|
|
308
|
+
}),
|
|
262
309
|
activity: options.activity ?? null,
|
|
263
310
|
waiver: null,
|
|
264
311
|
};
|
|
@@ -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;
|
|
@@ -82,15 +91,23 @@ export interface OpenSpecState {
|
|
|
82
91
|
changeId: string;
|
|
83
92
|
title: string;
|
|
84
93
|
phase: string;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
/** File-truth counts — checkbox tasks first, then done/total
|
|
95
|
+
* artifacts. Absent when neither is readable: absence never becomes
|
|
96
|
+
* zero (truthful-rendered-state). */
|
|
97
|
+
completedTasks?: number;
|
|
98
|
+
totalTasks?: number;
|
|
99
|
+
progressPercent?: number;
|
|
88
100
|
pendingTasks: OpenSpecTask[];
|
|
89
101
|
allTasks?: OpenSpecTask[];
|
|
90
102
|
tasksPath?: string;
|
|
91
103
|
hierarchy?: OpenSpecHierarchyProgress;
|
|
92
104
|
schemaName?: string;
|
|
93
|
-
|
|
105
|
+
artifacts?: OpenSpecArtifact[];
|
|
106
|
+
/** The artifact the work is currently in: first not yet done, falling
|
|
107
|
+
* back to the last done artifact once everything is complete. */
|
|
108
|
+
currentArtifact?: string;
|
|
109
|
+
/** Newest file mtime inside the change directory, when statable. */
|
|
110
|
+
changeModifiedAt?: number;
|
|
94
111
|
meta: SourceMeta;
|
|
95
112
|
}
|
|
96
113
|
|