@aemonge-dev/pi-stardeck 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/packages/pi-stardeck/README.md +1 -1
- package/packages/pi-stardeck/src/activity-age.ts +1 -1
- package/packages/pi-stardeck/src/deck.ts +9 -6
- package/packages/pi-stardeck/src/gauge.ts +1 -6
- package/packages/pi-stardeck/src/title-state.ts +124 -58
- package/packages/status/README.md +4 -4
- package/packages/status/index.ts +33 -338
- package/packages/status/src/active-work.ts +6 -71
- package/packages/status/src/ledger.ts +1 -3
- package/packages/status/src/openspec.ts +158 -18
- package/packages/status/src/publisher.ts +6 -76
- package/packages/status/src/reducer.ts +1 -19
- package/packages/status/src/title-state.ts +85 -52
- package/packages/status/src/types.ts +15 -2
- package/packages/work-state/README.md +33 -0
- package/packages/work-state/index.ts +160 -0
- package/packages/work-state/package.json +33 -0
- package/packages/work-state/src/focus.ts +100 -0
- package/src/index.ts +2 -0
|
@@ -10,7 +10,7 @@ import type { OpenSpecState } from './types.ts';
|
|
|
10
10
|
* code may never write any slot; it only parses defensively.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
export const TITLE_STATE_VERSION =
|
|
13
|
+
export const TITLE_STATE_VERSION = 2 as const;
|
|
14
14
|
|
|
15
15
|
export const TITLE_ACTIVITY_CLASSES = [
|
|
16
16
|
'editing',
|
|
@@ -37,14 +37,25 @@ const TASK_ID_MAX = 32;
|
|
|
37
37
|
const SPAN_ID_MAX = 64;
|
|
38
38
|
const WAIVER_ID_MAX = 64;
|
|
39
39
|
const NOTICE_MAX = 180;
|
|
40
|
+
const ARTIFACTS_MAX = 16;
|
|
41
|
+
|
|
42
|
+
export type TitleArtifactState = {
|
|
43
|
+
id: string;
|
|
44
|
+
done: boolean;
|
|
45
|
+
items?: { done: number; total: number };
|
|
46
|
+
};
|
|
40
47
|
|
|
41
48
|
export type TitleWorkState = {
|
|
42
49
|
change: string;
|
|
43
50
|
task?: string;
|
|
44
|
-
done?: number;
|
|
45
|
-
total?: number;
|
|
46
51
|
schemaName?: string;
|
|
47
|
-
|
|
52
|
+
artifacts?: TitleArtifactState[];
|
|
53
|
+
/** The artifact the work is in — first not done, else last done. */
|
|
54
|
+
currentArtifact?: string;
|
|
55
|
+
/** Attention facts, derived from publisher-owned truth only. */
|
|
56
|
+
awaitingValidation?: boolean;
|
|
57
|
+
focusAt?: number;
|
|
58
|
+
focusStale?: boolean;
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
export type TitleActivityState = {
|
|
@@ -115,46 +126,66 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
|
|
|
115
126
|
if (!isRecord(value)) return undefined;
|
|
116
127
|
const change = boundedText(value.change, IDENTIFIER_MAX);
|
|
117
128
|
if (!change) return undefined;
|
|
118
|
-
// Counts are optional as a pair: absent means unknown (never fabricated
|
|
119
|
-
// zeros); present-but-invalid rejects the object.
|
|
120
|
-
const hasCounts = 'done' in value && 'total' in value;
|
|
121
|
-
if ('done' in value !== 'total' in value) return undefined;
|
|
122
|
-
const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
|
|
123
|
-
const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
|
|
124
|
-
if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
129
|
const task = boundedText(value.task, TASK_ID_MAX, { required: false });
|
|
128
130
|
if (task === null) return undefined;
|
|
129
131
|
const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
|
|
130
132
|
required: false,
|
|
131
133
|
});
|
|
132
134
|
if (schemaName === null) return undefined;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
|
|
136
|
+
required: false,
|
|
137
|
+
});
|
|
138
|
+
if (currentArtifact === null) return undefined;
|
|
139
|
+
let artifacts: TitleArtifactState[] | undefined;
|
|
140
|
+
if ('artifacts' in value) {
|
|
141
|
+
if (!Array.isArray(value.artifacts) || value.artifacts.length > ARTIFACTS_MAX) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const parsed: TitleArtifactState[] = [];
|
|
145
|
+
for (const entry of value.artifacts) {
|
|
146
|
+
if (!isRecord(entry)) return undefined;
|
|
147
|
+
const id = boundedText(entry.id, TASK_ID_MAX);
|
|
148
|
+
if (!id) return undefined;
|
|
149
|
+
if (typeof entry.done !== 'boolean') return undefined;
|
|
150
|
+
const artifactDone = entry.done;
|
|
151
|
+
let items: { done: number; total: number } | undefined;
|
|
152
|
+
if ('items' in entry) {
|
|
153
|
+
if (!isRecord(entry.items)) return undefined;
|
|
154
|
+
const done = boundedTotal(entry.items.done);
|
|
155
|
+
const total = boundedTotal(entry.items.total);
|
|
156
|
+
if (done === null || total === null) return undefined;
|
|
157
|
+
items = { done, total };
|
|
158
|
+
}
|
|
159
|
+
parsed.push({ id, done: artifactDone, ...(items ? { items } : {}) });
|
|
160
|
+
}
|
|
161
|
+
artifacts = parsed;
|
|
162
|
+
}
|
|
163
|
+
if ('awaitingValidation' in value && typeof value.awaitingValidation !== 'boolean') {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
let focusAt: number | undefined;
|
|
167
|
+
if ('focusAt' in value) {
|
|
168
|
+
if (
|
|
169
|
+
typeof value.focusAt !== 'number' ||
|
|
170
|
+
!Number.isSafeInteger(value.focusAt) ||
|
|
171
|
+
value.focusAt < 0
|
|
172
|
+
) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
focusAt = value.focusAt;
|
|
176
|
+
}
|
|
177
|
+
if ('focusStale' in value && typeof value.focusStale !== 'boolean') {
|
|
178
|
+
return undefined;
|
|
146
179
|
}
|
|
147
|
-
const counts =
|
|
148
|
-
typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
|
|
149
|
-
? { done: boundedDone, total: boundedTotalValue }
|
|
150
|
-
: {};
|
|
151
180
|
const extras = {
|
|
152
181
|
...(schemaName ? { schemaName } : {}),
|
|
153
|
-
...(
|
|
182
|
+
...(artifacts ? { artifacts } : {}),
|
|
183
|
+
...(currentArtifact ? { currentArtifact } : {}),
|
|
184
|
+
...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
|
|
185
|
+
...(focusAt !== undefined ? { focusAt } : {}),
|
|
186
|
+
...(value.focusStale === true ? { focusStale: true } : {}),
|
|
154
187
|
};
|
|
155
|
-
return task === undefined
|
|
156
|
-
? { change, ...counts, ...extras }
|
|
157
|
-
: { change, task, ...counts, ...extras };
|
|
188
|
+
return task === undefined ? { change, ...extras } : { change, task, ...extras };
|
|
158
189
|
}
|
|
159
190
|
|
|
160
191
|
function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
|
|
@@ -202,46 +233,44 @@ export function shortenTitleTarget(value: string): string {
|
|
|
202
233
|
return `${points.slice(0, Math.max(1, TITLE_TARGET_MAX - 1)).join('')}…`;
|
|
203
234
|
}
|
|
204
235
|
|
|
205
|
-
function boundedCount(value: number | undefined, fallback = 0): number {
|
|
206
|
-
const candidate = value === undefined || !Number.isFinite(value) ? fallback : value;
|
|
207
|
-
return Math.max(0, Math.min(TITLE_TOTAL_MAX, Math.round(candidate)));
|
|
208
|
-
}
|
|
209
|
-
|
|
210
236
|
/**
|
|
211
237
|
* Derive the `work` slot from the OpenSpec focus and the focused snapshot.
|
|
212
238
|
* A task focus owns its change; without a focus a passively collected change
|
|
213
|
-
* still reports
|
|
239
|
+
* still reports artifact facts, just without a task id. Counts live only on
|
|
240
|
+
* artifact `items` — file truth; absent counts are absent, never zeros.
|
|
214
241
|
*/
|
|
215
242
|
export function deriveTitleWork(
|
|
216
243
|
focus: OpenSpecFocus,
|
|
217
244
|
openspec: OpenSpecState | null | undefined,
|
|
245
|
+
options: { focusAt?: number } = {},
|
|
218
246
|
): TitleWorkState | null {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
|
|
222
|
-
});
|
|
223
|
-
// Human steering 2026-10-23: no invented phase words — the ladder's
|
|
224
|
-
// rung name comes straight from OpenSpec.
|
|
247
|
+
// Artifact facts come straight from the collector; nothing is invented
|
|
248
|
+
// here — absent facts simply drop out of the contract.
|
|
225
249
|
const extras = (state: OpenSpecState | undefined) => {
|
|
226
250
|
if (!state) return {};
|
|
227
251
|
return {
|
|
228
252
|
...(state.schemaName ? { schemaName: state.schemaName } : {}),
|
|
229
|
-
...(state.
|
|
253
|
+
...(state.artifacts ? { artifacts: state.artifacts } : {}),
|
|
254
|
+
...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
|
|
230
255
|
};
|
|
231
256
|
};
|
|
232
257
|
if (focus.mode === 'task') {
|
|
233
258
|
const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
|
|
234
|
-
const
|
|
259
|
+
const stale =
|
|
260
|
+
options.focusAt !== undefined &&
|
|
261
|
+
state?.changeModifiedAt !== undefined &&
|
|
262
|
+
state.changeModifiedAt > options.focusAt;
|
|
235
263
|
return {
|
|
236
264
|
change: focus.change,
|
|
237
265
|
task: focus.taskId,
|
|
238
|
-
...(taskCounts ?? {}),
|
|
239
266
|
...extras(state),
|
|
267
|
+
...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
|
|
268
|
+
...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
|
|
269
|
+
...(stale ? { focusStale: true } : {}),
|
|
240
270
|
};
|
|
241
271
|
}
|
|
242
272
|
if (!openspec) return null;
|
|
243
|
-
|
|
244
|
-
return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
|
|
273
|
+
return { change: openspec.changeId, ...extras(openspec) };
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
/**
|
|
@@ -254,11 +283,15 @@ export function buildTitleState(options: {
|
|
|
254
283
|
focus: OpenSpecFocus;
|
|
255
284
|
openspec?: OpenSpecState | null;
|
|
256
285
|
activity?: TitleActivityState | null;
|
|
286
|
+
/** Publisher-owned timestamp of the current focus fact. */
|
|
287
|
+
focusAt?: number;
|
|
257
288
|
}): TitleState {
|
|
258
289
|
return {
|
|
259
290
|
v: TITLE_STATE_VERSION,
|
|
260
291
|
subject: options.subject.state === 'set' ? options.subject.title : null,
|
|
261
|
-
work: deriveTitleWork(options.focus, options.openspec
|
|
292
|
+
work: deriveTitleWork(options.focus, options.openspec, {
|
|
293
|
+
focusAt: options.focusAt,
|
|
294
|
+
}),
|
|
262
295
|
activity: options.activity ?? null,
|
|
263
296
|
waiver: null,
|
|
264
297
|
};
|
|
@@ -60,6 +60,15 @@ export interface OpenSpecTask {
|
|
|
60
60
|
done: boolean;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** One OpenSpec artifact: lifecycle from `openspec status`, item counts
|
|
64
|
+
* from the artifact's own file. Absent items mean unreadable or empty —
|
|
65
|
+
* never zeros. */
|
|
66
|
+
export interface OpenSpecArtifact {
|
|
67
|
+
id: string;
|
|
68
|
+
done: boolean;
|
|
69
|
+
items?: { done: number; total: number };
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
export interface OpenSpecOverview {
|
|
64
73
|
projectRoot: string;
|
|
65
74
|
actionableChanges: number;
|
|
@@ -90,7 +99,12 @@ export interface OpenSpecState {
|
|
|
90
99
|
tasksPath?: string;
|
|
91
100
|
hierarchy?: OpenSpecHierarchyProgress;
|
|
92
101
|
schemaName?: string;
|
|
93
|
-
|
|
102
|
+
artifacts?: OpenSpecArtifact[];
|
|
103
|
+
/** The artifact the work is currently in: first not yet done, falling
|
|
104
|
+
* back to the last done artifact once everything is complete. */
|
|
105
|
+
currentArtifact?: string;
|
|
106
|
+
/** Newest file mtime inside the change directory, when statable. */
|
|
107
|
+
changeModifiedAt?: number;
|
|
94
108
|
meta: SourceMeta;
|
|
95
109
|
}
|
|
96
110
|
|
|
@@ -193,7 +207,6 @@ export type StageValue =
|
|
|
193
207
|
|
|
194
208
|
export type SelectionState =
|
|
195
209
|
| { kind: 'openspec-task'; change: string; task: string }
|
|
196
|
-
| { kind: 'session-work'; intent: string; phase: 'active' | 'validation' }
|
|
197
210
|
| { kind: 'subject'; title: string }
|
|
198
211
|
| { kind: 'none' };
|
|
199
212
|
|
|
@@ -0,0 +1,33 @@
|
|
|
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.
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "work-state",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "OpenSpec focus entry owner for the pi-stardeck cockpit",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"work-state",
|
|
11
|
+
"openspec"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\" --ignore-path .prettierignore",
|
|
18
|
+
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}\" --ignore-path .prettierignore",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "node --test --experimental-strip-types test/*.test.ts",
|
|
21
|
+
"check": "npm run format:check && npm run typecheck && npm test"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "*",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"typebox": "*"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const OPEN_SPEC_FOCUS_ENTRY_TYPE = 'galactica-status.openspec-focus.v1';
|
|
2
|
+
|
|
3
|
+
export type TaskOpenSpecFocus = {
|
|
4
|
+
mode: 'task';
|
|
5
|
+
change: string;
|
|
6
|
+
taskId: string;
|
|
7
|
+
awaitingValidation: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type OpenSpecFocus = { mode: 'none' } | TaskOpenSpecFocus;
|
|
11
|
+
|
|
12
|
+
export const NO_OPEN_SPEC_FOCUS: OpenSpecFocus = { mode: 'none' };
|
|
13
|
+
|
|
14
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeTitlePart(value: string): string {
|
|
21
|
+
return value.replace(CONTROL_CHARACTERS, ' ').replace(/\s+/gu, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function explicitId(value: unknown): string | undefined {
|
|
25
|
+
if (typeof value !== 'string') return undefined;
|
|
26
|
+
const normalized = normalizeTitlePart(value);
|
|
27
|
+
return normalized || undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function taskFocus(
|
|
31
|
+
change: unknown,
|
|
32
|
+
taskId: unknown,
|
|
33
|
+
awaitingValidation?: unknown,
|
|
34
|
+
): TaskOpenSpecFocus | undefined {
|
|
35
|
+
const normalizedChange = explicitId(change);
|
|
36
|
+
const normalizedTaskId = explicitId(taskId);
|
|
37
|
+
if (!normalizedChange || !normalizedTaskId) return undefined;
|
|
38
|
+
return {
|
|
39
|
+
mode: 'task',
|
|
40
|
+
change: normalizedChange,
|
|
41
|
+
taskId: normalizedTaskId,
|
|
42
|
+
awaitingValidation: awaitingValidation === true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseOpenSpecFocus(value: unknown): OpenSpecFocus | undefined {
|
|
47
|
+
if (!isRecord(value)) return undefined;
|
|
48
|
+
if (value.mode === 'none') return NO_OPEN_SPEC_FOCUS;
|
|
49
|
+
if (value.mode !== 'task') return undefined;
|
|
50
|
+
return taskFocus(value.change, value.taskId, value.awaitingValidation);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus {
|
|
54
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
55
|
+
const entry = entries[index];
|
|
56
|
+
if (
|
|
57
|
+
!isRecord(entry) ||
|
|
58
|
+
entry.type !== 'custom' ||
|
|
59
|
+
entry.customType !== OPEN_SPEC_FOCUS_ENTRY_TYPE
|
|
60
|
+
) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const focus = parseOpenSpecFocus(entry.data);
|
|
64
|
+
if (focus) return focus;
|
|
65
|
+
}
|
|
66
|
+
return NO_OPEN_SPEC_FOCUS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function taskflowRunFocus(input: unknown): TaskOpenSpecFocus | undefined {
|
|
70
|
+
if (!isRecord(input) || input.action !== 'run' || !isRecord(input.args)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const focus = taskFocus(input.args.change, input.args.task);
|
|
74
|
+
return focus && focus.change !== 'none' && focus.taskId !== 'none'
|
|
75
|
+
? focus
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Task identity (mode · change · taskId) — the replace-safeguard's
|
|
80
|
+
* question; the validation flag is state, not identity. */
|
|
81
|
+
export function sameTaskFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
|
|
82
|
+
if (left.mode !== right.mode) return false;
|
|
83
|
+
return (
|
|
84
|
+
left.mode === 'none' ||
|
|
85
|
+
(right.mode === 'task' &&
|
|
86
|
+
left.change === right.change &&
|
|
87
|
+
left.taskId === right.taskId)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): boolean {
|
|
92
|
+
if (left.mode !== right.mode) return false;
|
|
93
|
+
return (
|
|
94
|
+
left.mode === 'none' ||
|
|
95
|
+
(right.mode === 'task' &&
|
|
96
|
+
left.change === right.change &&
|
|
97
|
+
left.taskId === right.taskId &&
|
|
98
|
+
left.awaitingValidation === right.awaitingValidation)
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
4
5
|
import galacticaStatus from '../packages/status/index.js';
|
|
5
6
|
import footerTelemetry from '../packages/footer/index.js';
|
|
6
7
|
import { registerStardeckHealth } from './health.js';
|
|
@@ -19,6 +20,7 @@ export const COCKPIT_COMPOSITION = [
|
|
|
19
20
|
export default function piStardeck(pi: ExtensionAPI): void {
|
|
20
21
|
registerPiStatus(pi);
|
|
21
22
|
footerTelemetry(pi);
|
|
23
|
+
workState(pi);
|
|
22
24
|
galacticaStatus(pi);
|
|
23
25
|
galacticaContextHeader(pi, {
|
|
24
26
|
surface: COCKPIT_SURFACES.contextHeader,
|