@aemonge-dev/pi-stardeck 0.11.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 +1 -1
- package/packages/pi-stardeck/src/gauge.ts +1 -6
- package/packages/status/README.md +4 -4
- package/packages/status/index.ts +26 -338
- package/packages/status/src/active-work.ts +2 -70
- package/packages/status/src/ledger.ts +1 -3
- 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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
|
|
3
3
|
export const OPEN_SPEC_FOCUS_ENTRY_TYPE = 'galactica-status.openspec-focus.v1';
|
|
4
|
-
export const SESSION_WORK_FOCUS_ENTRY_TYPE = 'galactica-status.session-work.v1';
|
|
5
4
|
export const SESSION_SUBJECT_ENTRY_TYPE = 'galactica-status.session-subject.v1';
|
|
6
5
|
|
|
7
6
|
export type TaskOpenSpecFocus = {
|
|
@@ -15,11 +14,6 @@ export type OpenSpecFocus = { mode: 'none' } | TaskOpenSpecFocus;
|
|
|
15
14
|
|
|
16
15
|
export const NO_OPEN_SPEC_FOCUS: OpenSpecFocus = { mode: 'none' };
|
|
17
16
|
|
|
18
|
-
export type SessionWorkFocus =
|
|
19
|
-
{ state: 'clear' } | { state: 'active' | 'validation'; intent: string };
|
|
20
|
-
|
|
21
|
-
export const NO_SESSION_WORK_FOCUS: SessionWorkFocus = { state: 'clear' };
|
|
22
|
-
|
|
23
17
|
export type SessionSubject = { state: 'clear' } | { state: 'set'; title: string };
|
|
24
18
|
|
|
25
19
|
export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
|
|
@@ -27,7 +21,6 @@ export const NO_SESSION_SUBJECT: SessionSubject = { state: 'clear' };
|
|
|
27
21
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
28
22
|
const MAX_TITLE_POINTS = 180;
|
|
29
23
|
const MAX_TASK_TITLE_POINTS = 72;
|
|
30
|
-
const MAX_WORK_INTENT_POINTS = 160;
|
|
31
24
|
const MAX_SUBJECT_POINTS = 120;
|
|
32
25
|
|
|
33
26
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -73,25 +66,6 @@ export function parseOpenSpecFocus(value: unknown): OpenSpecFocus | undefined {
|
|
|
73
66
|
return taskFocus(value.change, value.taskId, value.awaitingValidation);
|
|
74
67
|
}
|
|
75
68
|
|
|
76
|
-
export function sessionWorkFocus(
|
|
77
|
-
state: unknown,
|
|
78
|
-
intent?: unknown,
|
|
79
|
-
): SessionWorkFocus | undefined {
|
|
80
|
-
if (state === 'clear') return NO_SESSION_WORK_FOCUS;
|
|
81
|
-
if (state !== 'active' && state !== 'validation') return undefined;
|
|
82
|
-
const normalizedIntent = explicitId(intent);
|
|
83
|
-
if (!normalizedIntent) return undefined;
|
|
84
|
-
return {
|
|
85
|
-
state,
|
|
86
|
-
intent: shorten(normalizedIntent, MAX_WORK_INTENT_POINTS),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function parseSessionWorkFocus(value: unknown): SessionWorkFocus | undefined {
|
|
91
|
-
if (!isRecord(value)) return undefined;
|
|
92
|
-
return sessionWorkFocus(value.state, value.intent);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
69
|
export function sessionSubject(
|
|
96
70
|
state: unknown,
|
|
97
71
|
title?: unknown,
|
|
@@ -111,18 +85,6 @@ export function parseSessionSubject(value: unknown): SessionSubject | undefined
|
|
|
111
85
|
return sessionSubject(value.state, value.title);
|
|
112
86
|
}
|
|
113
87
|
|
|
114
|
-
/** Derive a session title from a raw user prompt — the extension-side
|
|
115
|
-
* fallback that makes the agent's first-turn `title` call optional
|
|
116
|
-
* polish instead of a load-bearing bet (every agent skipped it). The
|
|
117
|
-
* same sanitizer bounds it; slash commands are commands, not subjects:
|
|
118
|
-
* they never title. */
|
|
119
|
-
export function titleFromPrompt(prompt: unknown): SessionSubject | undefined {
|
|
120
|
-
if (typeof prompt !== 'string') return undefined;
|
|
121
|
-
const normalized = normalizeTitlePart(prompt);
|
|
122
|
-
if (!normalized || normalized.startsWith('/')) return undefined;
|
|
123
|
-
return sessionSubject('set', normalized);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
88
|
export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus {
|
|
127
89
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
128
90
|
const entry = entries[index];
|
|
@@ -139,22 +101,6 @@ export function restoreOpenSpecFocus(entries: readonly unknown[]): OpenSpecFocus
|
|
|
139
101
|
return NO_OPEN_SPEC_FOCUS;
|
|
140
102
|
}
|
|
141
103
|
|
|
142
|
-
export function restoreSessionWorkFocus(entries: readonly unknown[]): SessionWorkFocus {
|
|
143
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
144
|
-
const entry = entries[index];
|
|
145
|
-
if (
|
|
146
|
-
!isRecord(entry) ||
|
|
147
|
-
entry.type !== 'custom' ||
|
|
148
|
-
entry.customType !== SESSION_WORK_FOCUS_ENTRY_TYPE
|
|
149
|
-
) {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const focus = parseSessionWorkFocus(entry.data);
|
|
153
|
-
if (focus) return focus;
|
|
154
|
-
}
|
|
155
|
-
return NO_SESSION_WORK_FOCUS;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
104
|
export function restoreSessionSubject(entries: readonly unknown[]): SessionSubject {
|
|
159
105
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
160
106
|
const entry = entries[index];
|
|
@@ -204,16 +150,6 @@ export function sameOpenSpecFocus(left: OpenSpecFocus, right: OpenSpecFocus): bo
|
|
|
204
150
|
);
|
|
205
151
|
}
|
|
206
152
|
|
|
207
|
-
export function sameSessionWorkFocus(
|
|
208
|
-
left: SessionWorkFocus,
|
|
209
|
-
right: SessionWorkFocus,
|
|
210
|
-
): boolean {
|
|
211
|
-
if (left.state !== right.state) return false;
|
|
212
|
-
return (
|
|
213
|
-
left.state === 'clear' || (right.state !== 'clear' && left.intent === right.intent)
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
153
|
export function sameSessionSubject(
|
|
218
154
|
left: SessionSubject,
|
|
219
155
|
right: SessionSubject,
|
|
@@ -226,7 +162,6 @@ export function sameSessionSubject(
|
|
|
226
162
|
|
|
227
163
|
export function formatActiveWorkTitle(options: {
|
|
228
164
|
focus: OpenSpecFocus;
|
|
229
|
-
workFocus?: SessionWorkFocus;
|
|
230
165
|
subject?: SessionSubject;
|
|
231
166
|
taskTitle?: string;
|
|
232
167
|
sessionName?: string;
|
|
@@ -239,11 +174,8 @@ export function formatActiveWorkTitle(options: {
|
|
|
239
174
|
? shorten(normalizeTitlePart(options.taskTitle), MAX_TASK_TITLE_POINTS)
|
|
240
175
|
: '';
|
|
241
176
|
detail = `${options.focus.change} › ${options.focus.taskId}${taskTitle ? ` ${taskTitle}` : ''}`;
|
|
242
|
-
} else if (options.
|
|
243
|
-
detail =
|
|
244
|
-
options.workFocus.state === 'validation'
|
|
245
|
-
? `Validate › ${options.workFocus.intent}`
|
|
246
|
-
: options.workFocus.intent;
|
|
177
|
+
} else if (normalizeTitlePart(options.sessionName ?? '')) {
|
|
178
|
+
detail = normalizeTitlePart(options.sessionName ?? '');
|
|
247
179
|
} else if (options.subject && options.subject.state === 'set') {
|
|
248
180
|
detail = options.subject.title;
|
|
249
181
|
} else {
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
RunSpan,
|
|
6
6
|
StageValue,
|
|
7
7
|
} from './types.ts';
|
|
8
|
-
import type { OpenSpecFocus, SessionSubject
|
|
8
|
+
import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Append-only event ledger for the cockpit status core.
|
|
@@ -18,7 +18,6 @@ import type { OpenSpecFocus, SessionSubject, SessionWorkFocus } from './active-w
|
|
|
18
18
|
|
|
19
19
|
export const LEDGER_EVENT_TYPES = [
|
|
20
20
|
'focus/openspec',
|
|
21
|
-
'focus/work',
|
|
22
21
|
'focus/subject',
|
|
23
22
|
'run/start',
|
|
24
23
|
'run/stage',
|
|
@@ -37,7 +36,6 @@ export type LedgerEvent =
|
|
|
37
36
|
at: number;
|
|
38
37
|
focus: OpenSpecFocus;
|
|
39
38
|
}
|
|
40
|
-
| { type: 'focus/work'; at: number; work: SessionWorkFocus }
|
|
41
39
|
| { type: 'focus/subject'; at: number; subject: SessionSubject }
|
|
42
40
|
| { type: 'run/start'; at: number; span: RunSpan }
|
|
43
41
|
| { type: 'run/stage'; at: number; id: string; stage: StageValue; declared: boolean }
|
|
@@ -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
|
+
}
|