@aemonge-dev/pi-stardeck 0.13.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/deck.ts +4 -10
- package/packages/pi-stardeck/src/title-state.ts +34 -9
- package/packages/status/index.ts +28 -14
- package/packages/status/src/active-work.ts +16 -25
- package/packages/status/src/gate-act.ts +85 -0
- package/packages/status/src/openspec.ts +24 -16
- package/packages/status/src/publisher.ts +23 -14
- package/packages/status/src/title-state.ts +16 -2
- package/packages/status/src/types.ts +6 -3
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -41,6 +41,7 @@ const SPAN_ID_MAX = 64;
|
|
|
41
41
|
const WAIVER_ID_MAX = 64;
|
|
42
42
|
const NOTICE_MAX = 180;
|
|
43
43
|
const ARTIFACTS_MAX = 16;
|
|
44
|
+
const GATE_ACT_MAX = 180;
|
|
44
45
|
const TURN_TOKENS_MAX = 999_999_999;
|
|
45
46
|
const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
|
|
46
47
|
|
|
@@ -61,6 +62,8 @@ export type DeckTitleWorkState = {
|
|
|
61
62
|
awaitingValidation?: boolean;
|
|
62
63
|
focusAt?: number;
|
|
63
64
|
focusStale?: boolean;
|
|
65
|
+
/** The gate's relayed act line — dim commentary in the optional slot. */
|
|
66
|
+
gateAct?: string;
|
|
64
67
|
};
|
|
65
68
|
|
|
66
69
|
export type DeckTitleActivityState = {
|
|
@@ -185,6 +188,8 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
185
188
|
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
186
189
|
return undefined;
|
|
187
190
|
}
|
|
191
|
+
const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
|
|
192
|
+
if (gateAct === null) return undefined;
|
|
188
193
|
const extras = {
|
|
189
194
|
...(schemaName ? { schemaName } : {}),
|
|
190
195
|
...(artifacts ? { artifacts } : {}),
|
|
@@ -192,6 +197,7 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
|
|
|
192
197
|
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
193
198
|
...(focusAt !== undefined ? { focusAt } : {}),
|
|
194
199
|
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
200
|
+
...(gateAct ? { gateAct } : {}),
|
|
195
201
|
};
|
|
196
202
|
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
197
203
|
}
|
|
@@ -265,10 +271,13 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
|
|
|
265
271
|
export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
|
|
266
272
|
|
|
267
273
|
/**
|
|
268
|
-
* The Work label as colored segments — the truthful, de-duplicated tile
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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:
|
|
272
281
|
* `⏸ awaiting you` (warning) replaces the whole tile; a stale focus fact
|
|
273
282
|
* (`⚠ focus <age> old`, dim, humanized at render time) replaces the artifact
|
|
274
283
|
* group. Unknown facts drop their segments — absent items never render as
|
|
@@ -284,10 +293,15 @@ export function deckWorkSegments(
|
|
|
284
293
|
if (work.awaitingValidation) {
|
|
285
294
|
return compact ? [] : [{ text: '⏸ awaiting you', color: 'warning' }];
|
|
286
295
|
}
|
|
287
|
-
|
|
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
|
+
];
|
|
288
301
|
const stale = work.focusStale === true && work.focusAt !== undefined;
|
|
289
302
|
if (!compact) {
|
|
290
303
|
if (stale) {
|
|
304
|
+
segments.push({ text: ' › ', color: 'accent' });
|
|
291
305
|
segments.push({
|
|
292
306
|
text: `⚠ focus ${compactActivityAge(Math.max(0, now - (work.focusAt ?? 0)))} old`,
|
|
293
307
|
color: 'dim',
|
|
@@ -307,12 +321,23 @@ export function deckWorkSegments(
|
|
|
307
321
|
// One hue per state: success only when the artifacts are done AND
|
|
308
322
|
// the current artifact's items are settled — otherwise work is open.
|
|
309
323
|
const complete = done === artifacts.length && itemsSettled;
|
|
310
|
-
segments.push(
|
|
311
|
-
text:
|
|
312
|
-
|
|
313
|
-
|
|
324
|
+
segments.push(
|
|
325
|
+
{ text: ' › ', color: 'accent' },
|
|
326
|
+
{
|
|
327
|
+
text: `${current} ${done}/${artifacts.length}`,
|
|
328
|
+
color: complete ? 'success' : 'accent',
|
|
329
|
+
},
|
|
330
|
+
);
|
|
314
331
|
}
|
|
315
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
|
+
}
|
|
316
341
|
}
|
|
317
342
|
const items = work.currentArtifact
|
|
318
343
|
? work.artifacts?.find((artifact) => artifact.id === work.currentArtifact)?.items
|
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 {
|
|
@@ -175,6 +176,10 @@ class GalacticaStatusRuntime {
|
|
|
175
176
|
* construction, refreshed on every accepted focus change; staleness
|
|
176
177
|
* compares it against the change directory's file truth. */
|
|
177
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;
|
|
178
183
|
private subject: SessionSubject;
|
|
179
184
|
private sessionName: string | undefined;
|
|
180
185
|
private diagnosticsPath: string | undefined;
|
|
@@ -459,6 +464,19 @@ class GalacticaStatusRuntime {
|
|
|
459
464
|
) {
|
|
460
465
|
return;
|
|
461
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
|
+
}
|
|
462
480
|
const previous = this.store.get();
|
|
463
481
|
this.updateRefreshHealth(
|
|
464
482
|
'openspec',
|
|
@@ -633,23 +651,15 @@ class GalacticaStatusRuntime {
|
|
|
633
651
|
|
|
634
652
|
private currentTitleState(snapshot = this.store.get()): TitleState {
|
|
635
653
|
const facts = this.cockpitFacts;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
? (snapshot.openspec.allTasks ?? snapshot.openspec.pendingTasks).find(
|
|
643
|
-
(task) => task.id === focusedTaskId,
|
|
644
|
-
)?.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
|
|
645
660
|
: undefined;
|
|
646
|
-
// The band chip renders the fallback chain — task focus, native session
|
|
647
|
-
// name, legacy subject, directory. The terminal titlebar is pi-title's
|
|
648
|
-
// alone; this package never paints it (pure-renderer-split Slice 2).
|
|
649
661
|
const subjectTitle = formatActiveWorkTitle({
|
|
650
|
-
focus: facts.openSpec,
|
|
651
662
|
subject: facts.subject,
|
|
652
|
-
...(taskTitle ? { taskTitle } : {}),
|
|
653
663
|
...(this.sessionName ? { sessionName: this.sessionName } : {}),
|
|
654
664
|
projectRoot: snapshot.projectRoot,
|
|
655
665
|
cwd: snapshot.cwd,
|
|
@@ -660,6 +670,7 @@ class GalacticaStatusRuntime {
|
|
|
660
670
|
openspec: snapshot.openspec,
|
|
661
671
|
activity: this.titleActivity.current(),
|
|
662
672
|
focusAt: this.focusAt,
|
|
673
|
+
...(gateAct ? { gateAct } : {}),
|
|
663
674
|
});
|
|
664
675
|
}
|
|
665
676
|
|
|
@@ -697,6 +708,9 @@ class GalacticaStatusRuntime {
|
|
|
697
708
|
if (sameOpenSpecFocus(this.focus, focus)) return;
|
|
698
709
|
this.focus = focus;
|
|
699
710
|
this.focusAt = Date.now();
|
|
711
|
+
if (focus.mode !== 'task' || this.gateAct?.change !== focus.change) {
|
|
712
|
+
this.gateAct = undefined;
|
|
713
|
+
}
|
|
700
714
|
this.recordEvent({ type: 'focus/openspec', at: Date.now(), focus });
|
|
701
715
|
this.store.clearOpenSpec();
|
|
702
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,34 +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
|
-
// 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);
|
|
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);
|
|
193
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
|
+
}
|
|
@@ -517,7 +517,6 @@ export async function collectOpenSpec(options: {
|
|
|
517
517
|
|
|
518
518
|
let tasks: OpenSpecTask[] = [];
|
|
519
519
|
let tasksPath: string | undefined;
|
|
520
|
-
let instructionProgress: ApplyInstructionsResult['progress'];
|
|
521
520
|
let hierarchy: OpenSpecHierarchyProgress | undefined;
|
|
522
521
|
try {
|
|
523
522
|
const instructions = parseApplyInstructions(
|
|
@@ -530,7 +529,6 @@ export async function collectOpenSpec(options: {
|
|
|
530
529
|
);
|
|
531
530
|
tasks = instructions.tasks;
|
|
532
531
|
tasksPath = instructions.tasksPath;
|
|
533
|
-
instructionProgress = instructions.progress;
|
|
534
532
|
|
|
535
533
|
if (tasksPath) {
|
|
536
534
|
try {
|
|
@@ -552,28 +550,38 @@ export async function collectOpenSpec(options: {
|
|
|
552
550
|
// tile at 0/N (simplify-deck-defaults). CLI progress and list counts
|
|
553
551
|
// remain fallbacks; the blocked-apply zero-guard stays for the
|
|
554
552
|
// no-tasks case.
|
|
553
|
+
// File-truth 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;
|
|
555
563
|
const completedTasks =
|
|
556
|
-
tasks.length > 0
|
|
557
|
-
|
|
558
|
-
: Math.max(0, Math.trunc(instructionProgress?.complete ?? change.completedTasks));
|
|
559
|
-
const totalTasks =
|
|
560
|
-
tasks.length > 0
|
|
561
|
-
? tasks.length
|
|
562
|
-
: Math.max(0, Math.trunc(instructionProgress?.total ?? change.totalTasks));
|
|
564
|
+
tasks.length > 0 ? tasks.filter((task) => task.done).length : artifactCounts?.done;
|
|
565
|
+
const totalTasks = tasks.length > 0 ? tasks.length : artifactCounts?.total;
|
|
563
566
|
const complete =
|
|
564
|
-
change.status === 'complete' ||
|
|
567
|
+
change.status === 'complete' ||
|
|
568
|
+
(totalTasks !== undefined && totalTasks > 0 && (completedTasks ?? 0) >= totalTasks);
|
|
565
569
|
|
|
566
570
|
return {
|
|
567
571
|
projectRoot,
|
|
568
572
|
changeId: change.name,
|
|
569
573
|
title,
|
|
570
574
|
phase: complete ? 'complete' : change.status,
|
|
571
|
-
completedTasks,
|
|
572
|
-
totalTasks,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
575
|
+
...(completedTasks !== undefined ? { completedTasks } : {}),
|
|
576
|
+
...(totalTasks !== undefined ? { totalTasks } : {}),
|
|
577
|
+
...(totalTasks !== undefined && totalTasks > 0
|
|
578
|
+
? {
|
|
579
|
+
progressPercent: Math.max(
|
|
580
|
+
0,
|
|
581
|
+
Math.min(100, Math.round(((completedTasks ?? 0) / totalTasks) * 100)),
|
|
582
|
+
),
|
|
583
|
+
}
|
|
584
|
+
: {}),
|
|
577
585
|
pendingTasks: tasks.filter((task) => !task.done),
|
|
578
586
|
allTasks: tasks,
|
|
579
587
|
...(tasksPath ? { tasksPath } : {}),
|
|
@@ -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,
|
|
@@ -38,6 +38,7 @@ const SPAN_ID_MAX = 64;
|
|
|
38
38
|
const WAIVER_ID_MAX = 64;
|
|
39
39
|
const NOTICE_MAX = 180;
|
|
40
40
|
const ARTIFACTS_MAX = 16;
|
|
41
|
+
const GATE_ACT_MAX = 180;
|
|
41
42
|
|
|
42
43
|
export type TitleArtifactState = {
|
|
43
44
|
id: string;
|
|
@@ -56,6 +57,8 @@ export type TitleWorkState = {
|
|
|
56
57
|
awaitingValidation?: boolean;
|
|
57
58
|
focusAt?: number;
|
|
58
59
|
focusStale?: boolean;
|
|
60
|
+
/** The gate's relayed act line — dim commentary in the optional slot. */
|
|
61
|
+
gateAct?: string;
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
export type TitleActivityState = {
|
|
@@ -177,6 +180,8 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
177
180
|
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
178
181
|
return undefined;
|
|
179
182
|
}
|
|
183
|
+
const gateAct = boundedText(value.gateAct, GATE_ACT_MAX, { required: false });
|
|
184
|
+
if (gateAct === null) return undefined;
|
|
180
185
|
const extras = {
|
|
181
186
|
...(schemaName ? { schemaName } : {}),
|
|
182
187
|
...(artifacts ? { artifacts } : {}),
|
|
@@ -184,6 +189,7 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
184
189
|
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
185
190
|
...(focusAt !== undefined ? { focusAt } : {}),
|
|
186
191
|
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
192
|
+
...(gateAct ? { gateAct } : {}),
|
|
187
193
|
};
|
|
188
194
|
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
189
195
|
}
|
|
@@ -242,7 +248,7 @@ export function shortenTitleTarget(value: string): string {
|
|
|
242
248
|
export function deriveTitleWork(
|
|
243
249
|
focus: OpenSpecFocus,
|
|
244
250
|
openspec: OpenSpecState | null | undefined,
|
|
245
|
-
options: { focusAt?: number } = {},
|
|
251
|
+
options: { focusAt?: number; gateAct?: string } = {},
|
|
246
252
|
): TitleWorkState | null {
|
|
247
253
|
// Artifact facts come straight from the collector; nothing is invented
|
|
248
254
|
// here — absent facts simply drop out of the contract.
|
|
@@ -267,10 +273,15 @@ export function deriveTitleWork(
|
|
|
267
273
|
...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
|
|
268
274
|
...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
|
|
269
275
|
...(stale ? { focusStale: true } : {}),
|
|
276
|
+
...(options.gateAct ? { gateAct: options.gateAct } : {}),
|
|
270
277
|
};
|
|
271
278
|
}
|
|
272
279
|
if (!openspec) return null;
|
|
273
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
change: openspec.changeId,
|
|
282
|
+
...extras(openspec),
|
|
283
|
+
...(options.gateAct ? { gateAct: options.gateAct } : {}),
|
|
284
|
+
};
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
/**
|
|
@@ -285,12 +296,15 @@ export function buildTitleState(options: {
|
|
|
285
296
|
activity?: TitleActivityState | null;
|
|
286
297
|
/** Publisher-owned timestamp of the current focus fact. */
|
|
287
298
|
focusAt?: number;
|
|
299
|
+
/** The gate's relayed act line for the focused change, if resolved. */
|
|
300
|
+
gateAct?: string;
|
|
288
301
|
}): TitleState {
|
|
289
302
|
return {
|
|
290
303
|
v: TITLE_STATE_VERSION,
|
|
291
304
|
subject: options.subject.state === 'set' ? options.subject.title : null,
|
|
292
305
|
work: deriveTitleWork(options.focus, options.openspec, {
|
|
293
306
|
focusAt: options.focusAt,
|
|
307
|
+
...(options.gateAct ? { gateAct: options.gateAct } : {}),
|
|
294
308
|
}),
|
|
295
309
|
activity: options.activity ?? null,
|
|
296
310
|
waiver: null,
|
|
@@ -91,9 +91,12 @@ export interface OpenSpecState {
|
|
|
91
91
|
changeId: string;
|
|
92
92
|
title: string;
|
|
93
93
|
phase: string;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
100
|
pendingTasks: OpenSpecTask[];
|
|
98
101
|
allTasks?: OpenSpecTask[];
|
|
99
102
|
tasksPath?: string;
|