@aemonge-dev/pi-stardeck 0.10.0 → 0.12.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 +33 -66
- package/packages/pi-stardeck/index.ts +39 -202
- package/packages/pi-stardeck/src/deck.ts +15 -64
- package/packages/pi-stardeck/src/gauge.ts +1 -40
- package/packages/status/README.md +4 -4
- package/packages/status/index.ts +25 -321
- package/packages/status/src/active-work.ts +2 -58
- package/packages/status/src/ledger.ts +1 -3
- package/packages/status/src/openspec.ts +14 -8
- package/packages/status/src/publisher.ts +6 -76
- package/packages/status/src/reducer.ts +1 -19
- package/packages/status/src/types.ts +0 -1
- 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
|
@@ -408,14 +408,20 @@ export async function collectOpenSpec(options: {
|
|
|
408
408
|
// Progress from `list` remains valid even if apply instructions are unavailable.
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
411
|
+
// File truth first: when the artifact (or apply) task list parsed, its
|
|
412
|
+
// done flags are what the Human actually ticks — the CLI's done-counting
|
|
413
|
+
// does not track custom-schema execution artifacts and pinned the Work
|
|
414
|
+
// tile at 0/N (simplify-deck-defaults). CLI progress and list counts
|
|
415
|
+
// remain fallbacks; the blocked-apply zero-guard stays for the
|
|
416
|
+
// no-tasks case.
|
|
417
|
+
const completedTasks =
|
|
418
|
+
tasks.length > 0
|
|
419
|
+
? tasks.filter((task) => task.done).length
|
|
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));
|
|
419
425
|
const complete =
|
|
420
426
|
change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
|
|
421
427
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SessionSubject
|
|
1
|
+
import type { SessionSubject } from './active-work.ts';
|
|
2
2
|
import type { ActiveRunSpanView, ActiveRuntimeRuns } from './runtime-runs.ts';
|
|
3
3
|
import type { TitleState } from './title-state.ts';
|
|
4
4
|
import type { TurnCostSnapshot } from './turn-cost.ts';
|
|
@@ -24,7 +24,6 @@ export const GALACTICA_HEADER_CHANNEL = 'galactica-status:header';
|
|
|
24
24
|
const OPEN_SPEC_WIDGET_ID = 'galactica.openspec';
|
|
25
25
|
const OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.openspec-scope-separator';
|
|
26
26
|
const OPEN_SPEC_PROGRESS_WIDGET_ID = 'galactica.openspec-progress';
|
|
27
|
-
const SESSION_WORK_WIDGET_ID = 'galactica.work';
|
|
28
27
|
const DIAGNOSTICS_WIDGET_ID = 'galactica.diagnostics';
|
|
29
28
|
const ORCHESTRATION_WIDGET_ID = 'galactica.orchestration';
|
|
30
29
|
|
|
@@ -88,13 +87,6 @@ function openSpecColor(state: OpenSpecState): WidgetColor {
|
|
|
88
87
|
return 'accent';
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
export function formatSessionWork(focus: SessionWorkFocus): string {
|
|
92
|
-
if (focus.state === 'clear') return 'Work · clear';
|
|
93
|
-
return focus.state === 'validation'
|
|
94
|
-
? `Validate · ${focus.intent}`
|
|
95
|
-
: `Work · ${focus.intent}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
90
|
export function formatOpenSpecOverview(overview: OpenSpecOverview): string {
|
|
99
91
|
return (
|
|
100
92
|
`${overview.completedPlans}/${overview.totalPlans}` +
|
|
@@ -209,21 +201,13 @@ export function buildWidgetSnapshots(
|
|
|
209
201
|
openSpecFeedback?: OpenSpecFeedback;
|
|
210
202
|
openSpecOverview?: OpenSpecOverview;
|
|
211
203
|
openSpecProjectDetected?: boolean;
|
|
212
|
-
workFocus?: SessionWorkFocus;
|
|
213
204
|
} = {},
|
|
214
205
|
): FancyFooterWidget[] {
|
|
215
206
|
const compact = options.compact ?? false;
|
|
216
207
|
const now = options.now ?? Date.now();
|
|
217
208
|
const widgets: FancyFooterWidget[] = [];
|
|
218
209
|
|
|
219
|
-
|
|
220
|
-
!options.openSpecOverview &&
|
|
221
|
-
Boolean(options.workFocus && options.workFocus.state !== 'clear');
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
options.openSpecOverview ||
|
|
225
|
-
(options.openSpecProjectDetected === false && !hasActiveFallback)
|
|
226
|
-
) {
|
|
210
|
+
if (options.openSpecOverview || options.openSpecProjectDetected === false) {
|
|
227
211
|
widgets.push({
|
|
228
212
|
id: OPEN_SPEC_SCOPE_SEPARATOR_WIDGET_ID,
|
|
229
213
|
label: 'OpenSpec scope separator',
|
|
@@ -265,41 +249,7 @@ export function buildWidgetSnapshots(
|
|
|
265
249
|
// Frozen contract: without an OpenSpec focus there is no progress surface —
|
|
266
250
|
// no aggregate overview, no 'no OpenSpec' narration.
|
|
267
251
|
|
|
268
|
-
if (
|
|
269
|
-
!snapshot.openspec &&
|
|
270
|
-
!options.openSpecOverview &&
|
|
271
|
-
options.workFocus &&
|
|
272
|
-
options.workFocus.state !== 'clear'
|
|
273
|
-
) {
|
|
274
|
-
const color: WidgetColor =
|
|
275
|
-
options.workFocus.state === 'validation' ? 'warning' : 'accent';
|
|
276
|
-
const glyph =
|
|
277
|
-
options.workFocus.state === 'validation'
|
|
278
|
-
? { nerd: '◇⠀', emoji: '◇⠀', unicode: '◇⠀', ascii: '? ' }
|
|
279
|
-
: { nerd: '▶⠀', emoji: '▶⠀', unicode: '▶⠀', ascii: '> ' };
|
|
280
|
-
widgets.push({
|
|
281
|
-
id: SESSION_WORK_WIDGET_ID,
|
|
282
|
-
label: options.workFocus.state === 'validation' ? 'Validate' : 'Focus',
|
|
283
|
-
description:
|
|
284
|
-
options.workFocus.state === 'validation'
|
|
285
|
-
? 'Session focus awaiting Human validation; its title remains in the header'
|
|
286
|
-
: 'Active Session Work focus; its title remains in the header',
|
|
287
|
-
content: {
|
|
288
|
-
type: 'text',
|
|
289
|
-
text: options.workFocus.state === 'validation' ? 'Validate' : 'Focus',
|
|
290
|
-
},
|
|
291
|
-
icon: { glyphs: glyph, color },
|
|
292
|
-
style: { textColor: color },
|
|
293
|
-
layout: {
|
|
294
|
-
row: 0,
|
|
295
|
-
position: 0,
|
|
296
|
-
align: 'right',
|
|
297
|
-
fill: 'none',
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!snapshot.openspec && !options.workFocus && options.openSpecFeedback) {
|
|
252
|
+
if (!snapshot.openspec && options.openSpecFeedback) {
|
|
303
253
|
const unavailable = options.openSpecFeedback === 'unavailable';
|
|
304
254
|
const color: WidgetColor = unavailable ? 'warning' : 'dim';
|
|
305
255
|
const stateText =
|
|
@@ -466,11 +416,7 @@ export const HEADER_LIFECYCLES = [
|
|
|
466
416
|
|
|
467
417
|
export type HeaderLifecycle = (typeof HEADER_LIFECYCLES)[number];
|
|
468
418
|
|
|
469
|
-
export const HEADER_SELECTION_SOURCES = [
|
|
470
|
-
'openspec',
|
|
471
|
-
'session-work',
|
|
472
|
-
'subject',
|
|
473
|
-
] as const;
|
|
419
|
+
export const HEADER_SELECTION_SOURCES = ['openspec', 'subject'] as const;
|
|
474
420
|
|
|
475
421
|
export type HeaderSelection = {
|
|
476
422
|
source: (typeof HEADER_SELECTION_SOURCES)[number];
|
|
@@ -547,7 +493,6 @@ function selectedOpenSpecTitles(
|
|
|
547
493
|
export function headerSelection(options: {
|
|
548
494
|
openSpec?: OpenSpecState | null;
|
|
549
495
|
focusedTaskId?: string;
|
|
550
|
-
workFocus?: SessionWorkFocus;
|
|
551
496
|
subject?: SessionSubject;
|
|
552
497
|
}): HeaderSelection | null {
|
|
553
498
|
if (options.openSpec) {
|
|
@@ -557,13 +502,6 @@ export function headerSelection(options: {
|
|
|
557
502
|
color: openSpecColor(options.openSpec),
|
|
558
503
|
};
|
|
559
504
|
}
|
|
560
|
-
if (options.workFocus && options.workFocus.state !== 'clear') {
|
|
561
|
-
return {
|
|
562
|
-
source: 'session-work',
|
|
563
|
-
titles: [normalizeInline(options.workFocus.intent)],
|
|
564
|
-
color: options.workFocus.state === 'validation' ? 'warning' : 'accent',
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
505
|
if (options.subject && options.subject.state === 'set') {
|
|
568
506
|
return {
|
|
569
507
|
source: 'subject',
|
|
@@ -884,7 +822,6 @@ export function buildHeaderStatusEvent(
|
|
|
884
822
|
options: {
|
|
885
823
|
openSpec?: OpenSpecState | null;
|
|
886
824
|
focusedTaskId?: string;
|
|
887
|
-
workFocus?: SessionWorkFocus;
|
|
888
825
|
subject?: SessionSubject;
|
|
889
826
|
orchestration?: OrchestrationState | null;
|
|
890
827
|
lifecycle?: HeaderLifecycle;
|
|
@@ -932,7 +869,7 @@ export function buildHeaderStatusEvent(
|
|
|
932
869
|
orchestrationLifecycle ??
|
|
933
870
|
options.lifecycle ??
|
|
934
871
|
headerLifecycleForActivity(activity) ??
|
|
935
|
-
|
|
872
|
+
'listening';
|
|
936
873
|
const lifecycle: HeaderLifecycle = blocked
|
|
937
874
|
? 'blocked'
|
|
938
875
|
: options.lifecycle === 'aborted'
|
|
@@ -942,7 +879,7 @@ export function buildHeaderStatusEvent(
|
|
|
942
879
|
const projectedActivity =
|
|
943
880
|
activityPath !== undefined ? { activityPath } : activity ? { activity } : {};
|
|
944
881
|
const selection = headerSelection(options);
|
|
945
|
-
const approvalRequired =
|
|
882
|
+
const approvalRequired = false;
|
|
946
883
|
const suggestion = headerSuggestion({
|
|
947
884
|
lifecycle,
|
|
948
885
|
activity,
|
|
@@ -960,13 +897,6 @@ export function buildHeaderStatusEvent(
|
|
|
960
897
|
color: openSpecColor(state),
|
|
961
898
|
...projectedActivity,
|
|
962
899
|
};
|
|
963
|
-
} else if (options.workFocus && options.workFocus.state !== 'clear') {
|
|
964
|
-
work = {
|
|
965
|
-
lifecycle,
|
|
966
|
-
titles: [normalizeInline(options.workFocus.intent)],
|
|
967
|
-
color: options.workFocus.state === 'validation' ? 'warning' : 'accent',
|
|
968
|
-
...projectedActivity,
|
|
969
|
-
};
|
|
970
900
|
} else if (options.orchestration) {
|
|
971
901
|
work = {
|
|
972
902
|
lifecycle,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LedgerEvent } from './ledger.ts';
|
|
2
|
-
import type { OpenSpecFocus, SessionSubject
|
|
2
|
+
import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
|
|
3
3
|
import type {
|
|
4
4
|
CockpitSnapshot,
|
|
5
5
|
ProgressFact,
|
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
|
|
20
20
|
export type ReducerFacts = {
|
|
21
21
|
openSpec: OpenSpecFocus;
|
|
22
|
-
work: SessionWorkFocus;
|
|
23
22
|
subject: SessionSubject;
|
|
24
23
|
runs: ReadonlyMap<string, RunSpan>;
|
|
25
24
|
progress: ProgressFact | null;
|
|
@@ -30,7 +29,6 @@ export const NO_SELECTION: SelectionState = { kind: 'none' };
|
|
|
30
29
|
export function emptyFacts(): ReducerFacts {
|
|
31
30
|
return {
|
|
32
31
|
openSpec: { mode: 'none' },
|
|
33
|
-
work: { state: 'clear' },
|
|
34
32
|
subject: { state: 'clear' },
|
|
35
33
|
runs: new Map(),
|
|
36
34
|
progress: null,
|
|
@@ -40,7 +38,6 @@ export function emptyFacts(): ReducerFacts {
|
|
|
40
38
|
/** The single selection resolver. Priority order is contract-frozen. */
|
|
41
39
|
export function resolveSelection(facts: {
|
|
42
40
|
openSpec: OpenSpecFocus;
|
|
43
|
-
work: SessionWorkFocus;
|
|
44
41
|
subject: SessionSubject;
|
|
45
42
|
}): SelectionState {
|
|
46
43
|
if (facts.openSpec.mode === 'task') {
|
|
@@ -50,13 +47,6 @@ export function resolveSelection(facts: {
|
|
|
50
47
|
task: facts.openSpec.taskId,
|
|
51
48
|
};
|
|
52
49
|
}
|
|
53
|
-
if (facts.work.state !== 'clear') {
|
|
54
|
-
return {
|
|
55
|
-
kind: 'session-work',
|
|
56
|
-
intent: facts.work.intent,
|
|
57
|
-
phase: facts.work.state === 'validation' ? 'validation' : 'active',
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
50
|
if (facts.subject.state === 'set') {
|
|
61
51
|
return { kind: 'subject', title: facts.subject.title };
|
|
62
52
|
}
|
|
@@ -73,8 +63,6 @@ export function applyEvent(facts: ReducerFacts, event: LedgerEvent): ReducerFact
|
|
|
73
63
|
switch (event.type) {
|
|
74
64
|
case 'focus/openspec':
|
|
75
65
|
return { ...facts, openSpec: event.focus };
|
|
76
|
-
case 'focus/work':
|
|
77
|
-
return { ...facts, work: event.work };
|
|
78
66
|
case 'focus/subject':
|
|
79
67
|
return { ...facts, subject: event.subject };
|
|
80
68
|
case 'run/start': {
|
|
@@ -153,12 +141,6 @@ export function deriveTitle(snapshot: CockpitSnapshot): string {
|
|
|
153
141
|
case 'openspec-task':
|
|
154
142
|
detail = `${selection.change} › ${selection.task}`;
|
|
155
143
|
break;
|
|
156
|
-
case 'session-work':
|
|
157
|
-
detail =
|
|
158
|
-
selection.phase === 'validation'
|
|
159
|
-
? `Validate › ${selection.intent}`
|
|
160
|
-
: selection.intent;
|
|
161
|
-
break;
|
|
162
144
|
case 'subject':
|
|
163
145
|
detail = selection.title;
|
|
164
146
|
break;
|
|
@@ -193,7 +193,6 @@ export type StageValue =
|
|
|
193
193
|
|
|
194
194
|
export type SelectionState =
|
|
195
195
|
| { kind: 'openspec-task'; change: string; task: string }
|
|
196
|
-
| { kind: 'session-work'; intent: string; phase: 'active' | 'validation' }
|
|
197
196
|
| { kind: 'subject'; title: string }
|
|
198
197
|
| { kind: 'none' };
|
|
199
198
|
|
|
@@ -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,
|