@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.
@@ -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.workFocus && options.workFocus.state !== 'clear') {
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, SessionWorkFocus } from './active-work.ts';
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, 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
+ }