@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.
@@ -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
- const completedTasks = Math.max(
412
- 0,
413
- Math.trunc(instructionProgress?.complete ?? change.completedTasks),
414
- );
415
- const totalTasks = Math.max(
416
- 0,
417
- Math.trunc(instructionProgress?.total ?? change.totalTasks),
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, SessionWorkFocus } from './active-work.ts';
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
- const hasActiveFallback =
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
- (options.workFocus?.state === 'validation' ? 'waiting' : 'listening');
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 = options.workFocus?.state === 'validation';
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, SessionWorkFocus } from './active-work.ts';
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,