@aemonge-dev/pi-stardeck 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/demo/pi-stardeck.tape +6 -3
- package/docs/architecture.md +2 -1
- package/package.json +2 -3
- package/packages/pi-stardeck/index.ts +0 -10
- package/packages/pi-stardeck/src/deck.ts +30 -52
- package/packages/pi-stardeck/src/title-state.ts +63 -136
- package/packages/status/README.md +20 -34
- package/packages/status/index.ts +28 -143
- package/packages/status/src/active-work.ts +16 -109
- package/packages/status/src/config.ts +1 -3
- package/packages/status/src/ledger.ts +1 -7
- package/packages/status/src/openspec.ts +25 -17
- package/packages/status/src/publisher.ts +30 -91
- package/packages/status/src/reducer.ts +2 -36
- package/packages/status/src/room-aim.ts +151 -0
- package/packages/status/src/title-state.ts +43 -135
- package/packages/status/src/types.ts +7 -8
- package/src/index.ts +0 -2
- package/packages/pi-stardeck/src/activity-age.ts +0 -184
- package/packages/work-state/README.md +0 -33
- package/packages/work-state/index.ts +0 -160
- package/packages/work-state/package.json +0 -33
- package/packages/work-state/src/focus.ts +0 -100
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { SessionSubject } from './active-work.ts';
|
|
2
|
+
import type { RoomAim } from './room-aim.ts';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Title-state contract
|
|
5
|
+
* Title-state contract v2 (`deck-title-state`).
|
|
6
6
|
*
|
|
7
7
|
* One authoritative object for the deck's upper half: `subject` is authored
|
|
8
|
-
* once, `work` is
|
|
9
|
-
* tool executions, and `waiver` suppresses one exact notice text.
|
|
10
|
-
* code may never write any slot; it only parses defensively.
|
|
8
|
+
* once, `work` is the room aim's verbatim fields, `activity` is observed
|
|
9
|
+
* from tool executions, and `waiver` suppresses one exact notice text.
|
|
10
|
+
* Rendering code may never write any slot; it only parses defensively.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
export const TITLE_STATE_VERSION = 2 as const;
|
|
@@ -28,34 +28,21 @@ export type TitleActivityClass = (typeof TITLE_ACTIVITY_CLASSES)[number];
|
|
|
28
28
|
* render-time crop instead of pre-cutting subagent/bash labels). */
|
|
29
29
|
export const TITLE_TARGET_MAX = 120;
|
|
30
30
|
|
|
31
|
-
/** Task/step totals bound from the accepted schema. */
|
|
32
|
-
export const TITLE_TOTAL_MAX = 999;
|
|
33
|
-
|
|
34
31
|
const SUBJECT_MAX = 180;
|
|
35
32
|
const IDENTIFIER_MAX = 120;
|
|
36
33
|
const TASK_ID_MAX = 32;
|
|
37
34
|
const SPAN_ID_MAX = 64;
|
|
38
35
|
const WAIVER_ID_MAX = 64;
|
|
39
36
|
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
|
-
};
|
|
47
37
|
|
|
48
38
|
export type TitleWorkState = {
|
|
49
39
|
change: string;
|
|
40
|
+
/** Room-aim verbatim fields (observe-room-aim render law): printed
|
|
41
|
+
* as-is — the deck is a printer, not a translator. */
|
|
42
|
+
artifactName?: string;
|
|
43
|
+
artifactIdx?: string;
|
|
50
44
|
task?: string;
|
|
51
|
-
|
|
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;
|
|
45
|
+
nextStep?: string;
|
|
59
46
|
};
|
|
60
47
|
|
|
61
48
|
export type TitleActivityState = {
|
|
@@ -92,15 +79,6 @@ function boundedText(
|
|
|
92
79
|
return points.join('');
|
|
93
80
|
}
|
|
94
81
|
|
|
95
|
-
function boundedTotal(value: unknown): number | null {
|
|
96
|
-
return typeof value === 'number' &&
|
|
97
|
-
Number.isInteger(value) &&
|
|
98
|
-
value >= 0 &&
|
|
99
|
-
value <= TITLE_TOTAL_MAX
|
|
100
|
-
? value
|
|
101
|
-
: null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
82
|
function parseTitleActivity(value: unknown): TitleActivityState | null | undefined {
|
|
105
83
|
if (value === null) return null;
|
|
106
84
|
if (!isRecord(value)) return undefined;
|
|
@@ -126,66 +104,25 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
126
104
|
if (!isRecord(value)) return undefined;
|
|
127
105
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
128
106
|
if (!change) return undefined;
|
|
129
|
-
const
|
|
130
|
-
if (task === null) return undefined;
|
|
131
|
-
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
107
|
+
const artifactName = boundedText(value.artifactName, IDENTIFIER_MAX, {
|
|
132
108
|
required: false,
|
|
133
109
|
});
|
|
134
|
-
if (
|
|
135
|
-
const
|
|
110
|
+
if (artifactName === null) return undefined;
|
|
111
|
+
const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
|
|
136
112
|
required: false,
|
|
137
113
|
});
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
|
179
|
-
}
|
|
180
|
-
const extras = {
|
|
181
|
-
...(schemaName ? { schemaName } : {}),
|
|
182
|
-
...(artifacts ? { artifacts } : {}),
|
|
183
|
-
...(currentArtifact ? { currentArtifact } : {}),
|
|
184
|
-
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
185
|
-
...(focusAt !== undefined ? { focusAt } : {}),
|
|
186
|
-
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
114
|
+
if (artifactIdx === null) return undefined;
|
|
115
|
+
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
116
|
+
if (task === null) return undefined;
|
|
117
|
+
const nextStep = boundedText(value.nextStep, NOTICE_MAX, { required: false });
|
|
118
|
+
if (nextStep === null) return undefined;
|
|
119
|
+
return {
|
|
120
|
+
change,
|
|
121
|
+
...(artifactName ? { artifactName } : {}),
|
|
122
|
+
...(artifactIdx ? { artifactIdx } : {}),
|
|
123
|
+
...(task ? { task } : {}),
|
|
124
|
+
...(nextStep ? { nextStep } : {}),
|
|
187
125
|
};
|
|
188
|
-
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
189
126
|
}
|
|
190
127
|
|
|
191
128
|
function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
@@ -199,8 +136,8 @@ function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
|
199
136
|
|
|
200
137
|
/**
|
|
201
138
|
* Defensive parser for incoming title-state payloads. Unknown versions,
|
|
202
|
-
* malformed shapes, and out-of-bounds values (target > 120 characters
|
|
203
|
-
*
|
|
139
|
+
* malformed shapes, and out-of-bounds values (target > 120 characters)
|
|
140
|
+
* reject the whole object with `null`; it never throws.
|
|
204
141
|
*/
|
|
205
142
|
export function parseTitleState(value: unknown): TitleState | null {
|
|
206
143
|
try {
|
|
@@ -234,64 +171,35 @@ export function shortenTitleTarget(value: string): string {
|
|
|
234
171
|
}
|
|
235
172
|
|
|
236
173
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* artifact `items` — file truth; absent counts are absent, never zeros.
|
|
174
|
+
* The aimed work slot — the room aim's five fields, verbatim (render
|
|
175
|
+
* law). `change` is the keystone; every other field drops when missing,
|
|
176
|
+
* and the deck prints what remains exactly as the file wrote it.
|
|
241
177
|
*/
|
|
242
|
-
export function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const extras = (state: OpenSpecState | undefined) => {
|
|
250
|
-
if (!state) return {};
|
|
251
|
-
return {
|
|
252
|
-
...(state.schemaName ? { schemaName: state.schemaName } : {}),
|
|
253
|
-
...(state.artifacts ? { artifacts: state.artifacts } : {}),
|
|
254
|
-
...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
|
|
255
|
-
};
|
|
178
|
+
export function aimTitleWork(aim: RoomAim): TitleWorkState {
|
|
179
|
+
return {
|
|
180
|
+
change: aim.change,
|
|
181
|
+
...(aim.artifactName ? { artifactName: aim.artifactName } : {}),
|
|
182
|
+
...(aim.artifactIdx ? { artifactIdx: aim.artifactIdx } : {}),
|
|
183
|
+
...(aim.task ? { task: aim.task } : {}),
|
|
184
|
+
...(aim.nextStep ? { nextStep: aim.nextStep } : {}),
|
|
256
185
|
};
|
|
257
|
-
if (focus.mode === 'task') {
|
|
258
|
-
const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
|
|
259
|
-
const stale =
|
|
260
|
-
options.focusAt !== undefined &&
|
|
261
|
-
state?.changeModifiedAt !== undefined &&
|
|
262
|
-
state.changeModifiedAt > options.focusAt;
|
|
263
|
-
return {
|
|
264
|
-
change: focus.change,
|
|
265
|
-
task: focus.taskId,
|
|
266
|
-
...extras(state),
|
|
267
|
-
...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
|
|
268
|
-
...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
|
|
269
|
-
...(stale ? { focusStale: true } : {}),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
if (!openspec) return null;
|
|
273
|
-
return { change: openspec.changeId, ...extras(openspec) };
|
|
274
186
|
}
|
|
275
187
|
|
|
276
188
|
/**
|
|
277
|
-
* Build the
|
|
278
|
-
* subject entry, the
|
|
279
|
-
* observed activity event.
|
|
189
|
+
* Build the title state from publisher-owned facts: the authored session
|
|
190
|
+
* subject entry, the room aim's verbatim work fields, and the newest
|
|
191
|
+
* observed activity event.
|
|
280
192
|
*/
|
|
281
193
|
export function buildTitleState(options: {
|
|
282
194
|
subject: SessionSubject;
|
|
283
|
-
|
|
284
|
-
|
|
195
|
+
/** The room aim — when present it owns the work slot outright. */
|
|
196
|
+
aim?: RoomAim | null;
|
|
285
197
|
activity?: TitleActivityState | null;
|
|
286
|
-
/** Publisher-owned timestamp of the current focus fact. */
|
|
287
|
-
focusAt?: number;
|
|
288
198
|
}): TitleState {
|
|
289
199
|
return {
|
|
290
200
|
v: TITLE_STATE_VERSION,
|
|
291
201
|
subject: options.subject.state === 'set' ? options.subject.title : null,
|
|
292
|
-
work:
|
|
293
|
-
focusAt: options.focusAt,
|
|
294
|
-
}),
|
|
202
|
+
work: options.aim ? aimTitleWork(options.aim) : null,
|
|
295
203
|
activity: options.activity ?? null,
|
|
296
204
|
waiver: null,
|
|
297
205
|
};
|
|
@@ -21,7 +21,6 @@ export interface GalacticaStatusConfig {
|
|
|
21
21
|
enabled: boolean;
|
|
22
22
|
openspec: {
|
|
23
23
|
enabled: boolean;
|
|
24
|
-
change: string | null;
|
|
25
24
|
};
|
|
26
25
|
diagnostics: {
|
|
27
26
|
enabled: boolean;
|
|
@@ -91,9 +90,12 @@ export interface OpenSpecState {
|
|
|
91
90
|
changeId: string;
|
|
92
91
|
title: string;
|
|
93
92
|
phase: string;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
/** File-truth counts — checkbox tasks first, then done/total
|
|
94
|
+
* artifacts. Absent when neither is readable: absence never becomes
|
|
95
|
+
* zero (truthful-rendered-state). */
|
|
96
|
+
completedTasks?: number;
|
|
97
|
+
totalTasks?: number;
|
|
98
|
+
progressPercent?: number;
|
|
97
99
|
pendingTasks: OpenSpecTask[];
|
|
98
100
|
allTasks?: OpenSpecTask[];
|
|
99
101
|
tasksPath?: string;
|
|
@@ -205,10 +207,7 @@ export type StageValue =
|
|
|
205
207
|
| 'listening'
|
|
206
208
|
| 'aborted';
|
|
207
209
|
|
|
208
|
-
export type SelectionState =
|
|
209
|
-
| { kind: 'openspec-task'; change: string; task: string }
|
|
210
|
-
| { kind: 'subject'; title: string }
|
|
211
|
-
| { kind: 'none' };
|
|
210
|
+
export type SelectionState = { kind: 'subject'; title: string } | { kind: 'none' };
|
|
212
211
|
|
|
213
212
|
export type RunSpan = {
|
|
214
213
|
id: string;
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
|
|
3
3
|
import galacticaContextHeader from '../packages/pi-stardeck/index.js';
|
|
4
|
-
import workState from '../packages/work-state/index.js';
|
|
5
4
|
import galacticaStatus from '../packages/status/index.js';
|
|
6
5
|
import footerTelemetry from '../packages/footer/index.js';
|
|
7
6
|
import { registerStardeckHealth } from './health.js';
|
|
@@ -20,7 +19,6 @@ export const COCKPIT_COMPOSITION = [
|
|
|
20
19
|
export default function piStardeck(pi: ExtensionAPI): void {
|
|
21
20
|
registerPiStatus(pi);
|
|
22
21
|
footerTelemetry(pi);
|
|
23
|
-
workState(pi);
|
|
24
22
|
galacticaStatus(pi);
|
|
25
23
|
galacticaContextHeader(pi, {
|
|
26
24
|
surface: COCKPIT_SURFACES.contextHeader,
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { visibleWidth } from '@earendil-works/pi-tui';
|
|
2
|
-
|
|
3
|
-
export const WIDE_ACTIVITY_AGE_CELLS = 6;
|
|
4
|
-
export const STANDARD_ACTIVITY_AGE_CELLS = 4;
|
|
5
|
-
export const COMPACT_ACTIVITY_AGE_CELLS = 3;
|
|
6
|
-
export const RAIL_CHRONOMETER_CELLS = 8;
|
|
7
|
-
export const RUNTIME_STATUS_WIDGET_ID = 'galactica.runtime-state';
|
|
8
|
-
export const RUNTIME_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.runtime-scope-separator';
|
|
9
|
-
|
|
10
|
-
export type ActivityAgeSize = 6 | 4 | 3;
|
|
11
|
-
|
|
12
|
-
const ACTIVITY_AGE_SIZES: readonly ActivityAgeSize[] = [
|
|
13
|
-
WIDE_ACTIVITY_AGE_CELLS,
|
|
14
|
-
STANDARD_ACTIVITY_AGE_CELLS,
|
|
15
|
-
COMPACT_ACTIVITY_AGE_CELLS,
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
function boundedAge(ageMs: number): number {
|
|
19
|
-
return Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs)) : 0;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function activityHeartbeat(ageMs: number): string {
|
|
23
|
-
return Math.floor(boundedAge(ageMs) / 500) % 2 === 0 ? '·' : '•';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function wideActivityAge(ageMs: number): string {
|
|
27
|
-
const age = boundedAge(ageMs);
|
|
28
|
-
if (age < 100) return `${age}ms`;
|
|
29
|
-
if (age < 60_000) {
|
|
30
|
-
return `${Math.min(59.9, Math.floor(age / 100) / 10).toFixed(1)}s`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const seconds = Math.floor(age / 1_000);
|
|
34
|
-
const minutes = Math.floor(seconds / 60);
|
|
35
|
-
if (minutes < 60) {
|
|
36
|
-
return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const hours = Math.floor(minutes / 60);
|
|
40
|
-
const heartbeat = activityHeartbeat(age);
|
|
41
|
-
if (hours < 10) {
|
|
42
|
-
return `${hours}h${String(minutes % 60).padStart(2, '0')}m${heartbeat}`;
|
|
43
|
-
}
|
|
44
|
-
if (hours < 100) {
|
|
45
|
-
return `${hours}:${String(minutes % 60).padStart(2, '0')}${heartbeat}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const days = Math.min(99, Math.floor(hours / 24));
|
|
49
|
-
if (days < 10) {
|
|
50
|
-
return `${days}d${String(hours % 24).padStart(2, '0')}h${heartbeat}`;
|
|
51
|
-
}
|
|
52
|
-
return `${days}d${String(hours % 24).padStart(2, '0')}${heartbeat}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function standardActivityAge(ageMs: number): string {
|
|
56
|
-
const age = boundedAge(ageMs);
|
|
57
|
-
if (age < 100) return `${age}ms`;
|
|
58
|
-
if (age < 10_000) return `${Math.min(9.9, age / 1_000).toFixed(1)}s`;
|
|
59
|
-
|
|
60
|
-
const seconds = Math.floor(age / 1_000);
|
|
61
|
-
if (seconds < 100) return `${seconds}s`;
|
|
62
|
-
const minutes = Math.floor(seconds / 60);
|
|
63
|
-
if (minutes < 100) return `${minutes}m`;
|
|
64
|
-
const hours = Math.floor(minutes / 60);
|
|
65
|
-
if (hours < 100) return `${hours}h`;
|
|
66
|
-
return `${Math.min(99, Math.floor(hours / 24))}d`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function compactActivityAge(ageMs: number): string {
|
|
70
|
-
const age = boundedAge(ageMs);
|
|
71
|
-
const seconds = Math.floor(age / 1_000);
|
|
72
|
-
if (seconds < 100) return `${seconds}s`;
|
|
73
|
-
const minutes = Math.floor(seconds / 60);
|
|
74
|
-
if (minutes < 100) return `${minutes}m`;
|
|
75
|
-
const hours = Math.floor(minutes / 60);
|
|
76
|
-
if (hours < 100) return `${hours}h`;
|
|
77
|
-
return `${Math.min(99, Math.floor(hours / 24))}d`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function formatActivityAge(ageMs: number, size: ActivityAgeSize): string {
|
|
81
|
-
const text =
|
|
82
|
-
size === WIDE_ACTIVITY_AGE_CELLS
|
|
83
|
-
? wideActivityAge(ageMs)
|
|
84
|
-
: size === STANDARD_ACTIVITY_AGE_CELLS
|
|
85
|
-
? standardActivityAge(ageMs)
|
|
86
|
-
: compactActivityAge(ageMs);
|
|
87
|
-
return `${' '.repeat(Math.max(0, size - visibleWidth(text)))}${text}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function passiveActivityStatus(
|
|
91
|
-
lifecycle: string,
|
|
92
|
-
size: ActivityAgeSize,
|
|
93
|
-
): string {
|
|
94
|
-
const normalized = lifecycle.trim().toLowerCase();
|
|
95
|
-
const full =
|
|
96
|
-
normalized === 'listening'
|
|
97
|
-
? 'idle'
|
|
98
|
-
: normalized === 'blocked'
|
|
99
|
-
? 'hold'
|
|
100
|
-
: normalized === 'aborted'
|
|
101
|
-
? 'stop'
|
|
102
|
-
: 'wait';
|
|
103
|
-
if (size !== COMPACT_ACTIVITY_AGE_CELLS) {
|
|
104
|
-
return `${' '.repeat(Math.max(0, size - visibleWidth(full)))}${full}`;
|
|
105
|
-
}
|
|
106
|
-
const compact =
|
|
107
|
-
full === 'idle' ? '—' : full === 'hold' ? '!' : full === 'stop' ? '×' : '…';
|
|
108
|
-
return `${' '.repeat(size - visibleWidth(compact))}${compact}`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function formatRailChronometer(ageMs: number | null): string {
|
|
112
|
-
if (ageMs === null) return "00:00'00";
|
|
113
|
-
|
|
114
|
-
const centiseconds = Math.floor(boundedAge(ageMs) / 10);
|
|
115
|
-
const minutes = Math.floor(centiseconds / 6_000);
|
|
116
|
-
if (minutes < 100) {
|
|
117
|
-
const seconds = Math.floor(centiseconds / 100) % 60;
|
|
118
|
-
const remainder = centiseconds % 100;
|
|
119
|
-
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}'${String(remainder).padStart(2, '0')}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const totalSeconds = Math.floor(centiseconds / 100);
|
|
123
|
-
const hours = Math.floor(totalSeconds / 3_600);
|
|
124
|
-
if (hours < 100) {
|
|
125
|
-
const remainderMinutes = Math.floor(totalSeconds / 60) % 60;
|
|
126
|
-
const seconds = totalSeconds % 60;
|
|
127
|
-
return `${String(hours).padStart(2, '0')}h${String(remainderMinutes).padStart(2, '0')}'${String(seconds).padStart(2, '0')}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const days = Math.min(99, Math.floor(hours / 24));
|
|
131
|
-
const remainderHours = hours % 24;
|
|
132
|
-
const remainderMinutes = Math.floor(totalSeconds / 60) % 60;
|
|
133
|
-
return `${String(days).padStart(2, '0')}d${String(remainderHours).padStart(2, '0')}'${String(remainderMinutes).padStart(2, '0')}`;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function activityAgeCandidates(
|
|
137
|
-
ageMs: number | null,
|
|
138
|
-
lifecycle = 'listening',
|
|
139
|
-
): ReadonlyArray<{ size: ActivityAgeSize; age: string }> {
|
|
140
|
-
return ACTIVITY_AGE_SIZES.map((size) => ({
|
|
141
|
-
size,
|
|
142
|
-
age:
|
|
143
|
-
ageMs === null
|
|
144
|
-
? passiveActivityStatus(lifecycle, size)
|
|
145
|
-
: formatActivityAge(ageMs, size),
|
|
146
|
-
}));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function buildRuntimeFooterWidgets(status: string, active = false) {
|
|
150
|
-
return [
|
|
151
|
-
{
|
|
152
|
-
protocol: 1,
|
|
153
|
-
type: 'upsert',
|
|
154
|
-
widget: {
|
|
155
|
-
id: RUNTIME_STATUS_WIDGET_ID,
|
|
156
|
-
label: 'Runtime state',
|
|
157
|
-
description: 'Time since foreground activity or the settled runtime state',
|
|
158
|
-
content: { type: 'text', text: status },
|
|
159
|
-
icon: false,
|
|
160
|
-
style: { textColor: active ? 'accent' : 'dim' },
|
|
161
|
-
layout: {
|
|
162
|
-
row: 1,
|
|
163
|
-
position: 4,
|
|
164
|
-
align: 'left',
|
|
165
|
-
fill: 'none',
|
|
166
|
-
minWidth: RAIL_CHRONOMETER_CELLS,
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
protocol: 1,
|
|
172
|
-
type: 'upsert',
|
|
173
|
-
widget: {
|
|
174
|
-
id: RUNTIME_SCOPE_SEPARATOR_WIDGET_ID,
|
|
175
|
-
label: 'Runtime scope separator',
|
|
176
|
-
description: 'Separates the active LLM from runtime state',
|
|
177
|
-
content: { type: 'text', text: '⟩' },
|
|
178
|
-
icon: false,
|
|
179
|
-
style: { textColor: 'thinkingHigh' },
|
|
180
|
-
layout: { row: 1, position: 3, align: 'left', fill: 'none' },
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
] as const;
|
|
184
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# work-state
|
|
2
|
-
|
|
3
|
-
Owns the branch-local OpenSpec Task focus for the pi-stardeck cockpit: the
|
|
4
|
-
`openspec_focus` tool, the `galactica-status.openspec-focus.v1` session entry
|
|
5
|
-
write path, and the taskflow conflict guard.
|
|
6
|
-
|
|
7
|
-
## Entry contract
|
|
8
|
-
|
|
9
|
-
The session entry name `galactica-status.openspec-focus.v1` is an
|
|
10
|
-
**interface, not an ownership mark**: it predates this package and must stay
|
|
11
|
-
byte-identical for existing sessions and external readers. A contract test
|
|
12
|
-
(`test/contract.test.ts`) asserts byte-identity against the renderer's copy —
|
|
13
|
-
runtime code never imports across packages; the suites enforce the name.
|
|
14
|
-
|
|
15
|
-
External readers:
|
|
16
|
-
|
|
17
|
-
- **`pi-openspec-gate`** (separate repository) — parses latest-wins
|
|
18
|
-
`galactica-status.openspec-focus.v1` entries, with explicit clears. Do not
|
|
19
|
-
change the entry shape or name.
|
|
20
|
-
|
|
21
|
-
## Focus changes
|
|
22
|
-
|
|
23
|
-
After each successful focus write this package emits the `pi.events` event
|
|
24
|
-
`focus-changed` carrying the new focus data. The status renderer subscribes
|
|
25
|
-
and repaints; no other consumer is required, and the package fails soft when
|
|
26
|
-
nothing listens.
|
|
27
|
-
|
|
28
|
-
## No slash command
|
|
29
|
-
|
|
30
|
-
Focus is set only through the `openspec_focus` tool, on explicit Human
|
|
31
|
-
direction or an exact declared OpenSpec Task handoff — never inferred. There
|
|
32
|
-
is deliberately **no slash command**; clears and replacements happen through
|
|
33
|
-
the tool's `clear` / `replace` actions under the same policy.
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { StringEnum } from '@earendil-works/pi-ai';
|
|
2
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
import { Type } from 'typebox';
|
|
4
|
-
import {
|
|
5
|
-
NO_OPEN_SPEC_FOCUS,
|
|
6
|
-
OPEN_SPEC_FOCUS_ENTRY_TYPE,
|
|
7
|
-
type OpenSpecFocus,
|
|
8
|
-
restoreOpenSpecFocus,
|
|
9
|
-
sameOpenSpecFocus,
|
|
10
|
-
sameTaskFocus,
|
|
11
|
-
taskflowRunFocus,
|
|
12
|
-
taskFocus,
|
|
13
|
-
} from './src/focus.ts';
|
|
14
|
-
|
|
15
|
-
const FOCUS_CHANGED_EVENT = 'focus-changed';
|
|
16
|
-
|
|
17
|
-
type OpenSpecFocusAction = 'status' | 'set' | 'replace' | 'validate' | 'clear';
|
|
18
|
-
|
|
19
|
-
export default function workState(pi: ExtensionAPI): void {
|
|
20
|
-
let currentFocus: OpenSpecFocus = NO_OPEN_SPEC_FOCUS;
|
|
21
|
-
|
|
22
|
-
pi.on('session_start', (_event, ctx) => {
|
|
23
|
-
currentFocus = restoreOpenSpecFocus(ctx.sessionManager.getBranch());
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
pi.on('session_tree', (_event, ctx) => {
|
|
27
|
-
currentFocus = restoreOpenSpecFocus(ctx.sessionManager.getBranch());
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
function persistAndApplyFocus(focus: OpenSpecFocus): void {
|
|
31
|
-
pi.appendEntry(OPEN_SPEC_FOCUS_ENTRY_TYPE, focus);
|
|
32
|
-
currentFocus = focus;
|
|
33
|
-
pi.events.emit(FOCUS_CHANGED_EVENT, focus);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function applyOpenSpecFocusAction(
|
|
37
|
-
action: OpenSpecFocusAction,
|
|
38
|
-
change?: string,
|
|
39
|
-
taskId?: string,
|
|
40
|
-
): { ok: boolean; message: string; focus: OpenSpecFocus } {
|
|
41
|
-
const current = currentFocus;
|
|
42
|
-
const statusMessage = (focus: OpenSpecFocus): string =>
|
|
43
|
-
focus.mode === 'task'
|
|
44
|
-
? `OpenSpec focus: ${focus.change} › ${focus.taskId}` +
|
|
45
|
-
(focus.awaitingValidation ? ' — awaiting Human validation' : '')
|
|
46
|
-
: 'OpenSpec focus: none';
|
|
47
|
-
|
|
48
|
-
if (action === 'status') {
|
|
49
|
-
return { ok: true, message: statusMessage(current), focus: current };
|
|
50
|
-
}
|
|
51
|
-
if (action === 'clear') {
|
|
52
|
-
persistAndApplyFocus(NO_OPEN_SPEC_FOCUS);
|
|
53
|
-
return {
|
|
54
|
-
ok: true,
|
|
55
|
-
message: 'OpenSpec focus cleared',
|
|
56
|
-
focus: NO_OPEN_SPEC_FOCUS,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
if (action === 'validate') {
|
|
60
|
-
if (current.mode !== 'task') {
|
|
61
|
-
return {
|
|
62
|
-
ok: false,
|
|
63
|
-
message: 'OpenSpec focus has no task to validate',
|
|
64
|
-
focus: current,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
const validated = { ...current, awaitingValidation: true };
|
|
68
|
-
if (!sameOpenSpecFocus(current, validated)) persistAndApplyFocus(validated);
|
|
69
|
-
return { ok: true, message: statusMessage(validated), focus: validated };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const requested = taskFocus(change, taskId);
|
|
73
|
-
if (!requested) {
|
|
74
|
-
return {
|
|
75
|
-
ok: false,
|
|
76
|
-
message: 'OpenSpec focus requires non-empty change and task identifiers',
|
|
77
|
-
focus: current,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
action === 'set' &&
|
|
82
|
-
current.mode === 'task' &&
|
|
83
|
-
!sameTaskFocus(current, requested)
|
|
84
|
-
) {
|
|
85
|
-
return {
|
|
86
|
-
ok: false,
|
|
87
|
-
message:
|
|
88
|
-
`OpenSpec focus is ${current.change} › ${current.taskId}; ` +
|
|
89
|
-
'use replace to change it deliberately',
|
|
90
|
-
focus: current,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
if (!sameOpenSpecFocus(current, requested)) persistAndApplyFocus(requested);
|
|
94
|
-
return {
|
|
95
|
-
ok: true,
|
|
96
|
-
message: statusMessage(requested),
|
|
97
|
-
focus: requested,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
pi.on('tool_call', (event) => {
|
|
102
|
-
if (event.toolName !== 'taskflow') return;
|
|
103
|
-
const requested = taskflowRunFocus(event.input);
|
|
104
|
-
|
|
105
|
-
if (requested) {
|
|
106
|
-
if (currentFocus.mode === 'task' && !sameOpenSpecFocus(currentFocus, requested)) {
|
|
107
|
-
return {
|
|
108
|
-
block: true,
|
|
109
|
-
reason:
|
|
110
|
-
`OpenSpec focus is ${currentFocus.change} › ${currentFocus.taskId}; ` +
|
|
111
|
-
'clear or replace the OpenSpec focus (explicit Human direction) before starting a conflicting run',
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
if (!sameOpenSpecFocus(currentFocus, requested)) {
|
|
115
|
-
persistAndApplyFocus(requested);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
pi.registerTool({
|
|
121
|
-
name: 'openspec_focus',
|
|
122
|
-
label: 'OpenSpec Focus',
|
|
123
|
-
description:
|
|
124
|
-
'Show, safely set, deliberately replace, validate, or clear branch-local OpenSpec Task focus. Use only for explicit Human direction or an exact declared OpenSpec Task handoff; never infer material Plan selection or Human validation',
|
|
125
|
-
promptSnippet:
|
|
126
|
-
'Manage exact branch-local OpenSpec Task focus with replacement safeguards',
|
|
127
|
-
parameters: Type.Object({
|
|
128
|
-
action: StringEnum(['status', 'set', 'replace', 'validate', 'clear'] as const),
|
|
129
|
-
change: Type.Optional(
|
|
130
|
-
Type.String({
|
|
131
|
-
description: 'Required for set and replace: the exact OpenSpec change ID',
|
|
132
|
-
}),
|
|
133
|
-
),
|
|
134
|
-
task: Type.Optional(
|
|
135
|
-
Type.String({
|
|
136
|
-
description: 'Required for set and replace: the exact OpenSpec Task ID',
|
|
137
|
-
}),
|
|
138
|
-
),
|
|
139
|
-
}),
|
|
140
|
-
async execute(_toolCallId, params) {
|
|
141
|
-
const result = applyOpenSpecFocusAction(
|
|
142
|
-
params.action,
|
|
143
|
-
params.change,
|
|
144
|
-
params.task,
|
|
145
|
-
);
|
|
146
|
-
return {
|
|
147
|
-
content: [
|
|
148
|
-
{
|
|
149
|
-
type: 'text',
|
|
150
|
-
text: result.ok ? result.message : `Error: ${result.message}`,
|
|
151
|
-
},
|
|
152
|
-
],
|
|
153
|
-
details: {
|
|
154
|
-
ok: result.ok,
|
|
155
|
-
focus: result.focus,
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
}
|