@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.
@@ -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 = 1 as const;
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
- ladder?: { done: number; total: number; rung?: string };
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
- let ladder: { done: number; total: number; rung?: string } | undefined;
134
- if ('ladder' in value) {
135
- if (!isRecord(value.ladder)) return undefined;
136
- const ladderDone = boundedTotal(value.ladder.done);
137
- const ladderTotal = boundedTotal(value.ladder.total);
138
- if (ladderDone === null || ladderTotal === null) return undefined;
139
- const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
140
- if (ladderRung === null) return undefined;
141
- ladder = {
142
- done: ladderDone,
143
- total: ladderTotal,
144
- ...(ladderRung ? { rung: ladderRung } : {}),
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
- ...(ladder ? { ladder } : {}),
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 progress, just without a task id.
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
- const counts = (state: OpenSpecState): { done: number; total: number } => ({
220
- done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
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.ladder ? { ladder: state.ladder } : {}),
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 taskCounts = state ? counts(state) : undefined;
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
- const taskCounts = counts(openspec);
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
- ladder?: { done: number; total: number; rung?: string };
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,