@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,10 +48,11 @@
48
48
  "check:openspec": "node scripts/check-openspec.mjs",
49
49
  "test:tooling": "node --import tsx/esm --test test/*.test.ts",
50
50
  "test:status": "npm --prefix packages/status test",
51
+ "test:work-state": "npm --prefix packages/work-state test",
51
52
  "test:stardeck": "npm --prefix packages/pi-stardeck test",
52
53
  "test:footer": "npm --prefix packages/footer test",
53
54
  "test:vim": "npm --prefix packages/pi-vim-top-border test",
54
- "test:components": "npm run test:status && npm run test:stardeck && npm run test:footer && npm run test:vim"
55
+ "test:components": "npm run test:status && npm run test:work-state && npm run test:stardeck && npm run test:footer && npm run test:vim"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "@earendil-works/pi-ai": "*",
@@ -43,7 +43,7 @@ The additive header protocol projects selection independently from live work:
43
43
 
44
44
  ```ts
45
45
  selection: {
46
- source: 'openspec' | 'session-work';
46
+ source: 'openspec' | 'subject' | 'legacy';
47
47
  titles: string[]; // one or two bounded, normalized titles
48
48
  color: HeaderColor;
49
49
  } | null;
@@ -96,12 +96,7 @@ export type HeaderWork = {
96
96
  activityPath?: HeaderActivitySegment[];
97
97
  };
98
98
 
99
- export const HEADER_SELECTION_SOURCES = [
100
- 'openspec',
101
- 'session-work',
102
- 'subject',
103
- 'legacy',
104
- ] as const;
99
+ export const HEADER_SELECTION_SOURCES = ['openspec', 'subject', 'legacy'] as const;
105
100
 
106
101
  export type HeaderSelection = {
107
102
  source: (typeof HEADER_SELECTION_SOURCES)[number];
@@ -90,10 +90,10 @@ branch entry. The agent-callable `openspec_focus` tool shares the command's `sta
90
90
  safeguarded `set`, deliberate `replace`, validating `validate`, and persistent `clear`
91
91
  transitions; the `awaitingValidation` flag marks a focused task as awaiting Human
92
92
  validation and auto-clears on the next `set`, `replace`, or `clear`. The
93
- agent-callable `title` tool sets the sanitized session title shown when no OpenSpec
94
- focus owns the row. The retired `work_focus` and `stage` instruments are gone: stages
95
- derive from events and Taskflow phases, and legacy `session-work` entries from old
96
- sessions still restore and render, fail-soft.
93
+ retired `work_focus`, `stage`, and `title` instruments are gone: stages
94
+ derive from events and Taskflow phases, the focus row falls back to the
95
+ native session name (named by `pi-title`) and then to legacy `session-subject`
96
+ entries from old sessions, read defensively and fail-soft.
97
97
 
98
98
  Global routing belongs to native Ramona policy rather than this status package. This
99
99
  package does not inject parent routing prompts, block parent tools, create Session Work
@@ -1,4 +1,3 @@
1
- import { StringEnum } from '@earendil-works/pi-ai';
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ExtensionCommandContext,
@@ -6,29 +5,15 @@ import type {
6
5
  } from '@earendil-works/pi-coding-agent';
7
6
  import { promises as fs, watch } from 'node:fs';
8
7
  import { dirname, join, resolve } from 'node:path';
9
- import { Type } from 'typebox';
10
8
  import {
11
9
  formatActiveWorkTitle,
12
- NO_OPEN_SPEC_FOCUS,
13
- NO_SESSION_SUBJECT,
14
- NO_SESSION_WORK_FOCUS,
15
- OPEN_SPEC_FOCUS_ENTRY_TYPE,
16
10
  type OpenSpecFocus,
11
+ parseOpenSpecFocus,
17
12
  restoreOpenSpecFocus,
18
13
  restoreSessionSubject,
19
- restoreSessionWorkFocus,
20
14
  sameOpenSpecFocus,
21
15
  sameSessionSubject,
22
- sameSessionWorkFocus,
23
- sameTaskFocus,
24
- SESSION_SUBJECT_ENTRY_TYPE,
25
- SESSION_WORK_FOCUS_ENTRY_TYPE,
26
16
  type SessionSubject,
27
- type SessionWorkFocus,
28
- sessionSubject,
29
- taskflowRunFocus,
30
- taskFocus,
31
- titleFromPrompt,
32
17
  } from './src/active-work.ts';
33
18
  import { collectDiagnostics } from './src/diagnostics.ts';
34
19
  import { loadConfig } from './src/config.ts';
@@ -90,7 +75,6 @@ import type {
90
75
 
91
76
  type SessionContext = ExtensionContext | ExtensionCommandContext;
92
77
  type Domain = RuntimeErrorRecord['domain'];
93
- type OpenSpecFocusAction = 'status' | 'set' | 'replace' | 'validate' | 'clear';
94
78
 
95
79
  type StatusHealthCondition = {
96
80
  id: string;
@@ -187,10 +171,8 @@ class GalacticaStatusRuntime {
187
171
  private openSpecOverview: OpenSpecOverview | undefined;
188
172
  private openSpecProjectDetected: boolean | undefined;
189
173
  private focus: OpenSpecFocus;
190
- private workFocus: SessionWorkFocus;
191
174
  private subject: SessionSubject;
192
175
  private sessionName: string | undefined;
193
- private lastTitle: string | undefined;
194
176
  private diagnosticsPath: string | undefined;
195
177
  private orchestrationPath: string | undefined;
196
178
  private liveLifecycle: HeaderLifecycle;
@@ -218,7 +200,6 @@ class GalacticaStatusRuntime {
218
200
  ctx: SessionContext,
219
201
  publisher: FancyFooterPublisher,
220
202
  focus: OpenSpecFocus,
221
- workFocus: SessionWorkFocus,
222
203
  subject: SessionSubject,
223
204
  sessionName: string | undefined,
224
205
  ) {
@@ -226,12 +207,10 @@ class GalacticaStatusRuntime {
226
207
  this.ctx = ctx;
227
208
  this.publisher = publisher;
228
209
  this.focus = focus;
229
- this.workFocus = workFocus;
230
210
  this.subject = subject;
231
211
  this.sessionName = sessionName;
232
212
  const restoredAwaitingValidation =
233
- (focus.mode === 'task' && focus.awaitingValidation) ||
234
- workFocus.state === 'validation';
213
+ focus.mode === 'task' && focus.awaitingValidation;
235
214
  this.liveLifecycle = restoredAwaitingValidation ? 'waiting' : 'listening';
236
215
  this.liveActivity = restoredAwaitingValidation
237
216
  ? { kind: 'awaiting-validation' }
@@ -243,7 +222,6 @@ class GalacticaStatusRuntime {
243
222
  });
244
223
  const seededAt = Date.now();
245
224
  this.recordEvent({ type: 'focus/openspec', at: seededAt, focus });
246
- this.recordEvent({ type: 'focus/work', at: seededAt, work: workFocus });
247
225
  this.recordEvent({ type: 'focus/subject', at: seededAt, subject });
248
226
  }
249
227
 
@@ -608,28 +586,19 @@ class GalacticaStatusRuntime {
608
586
  // the same object references), so output stays byte-identical while the
609
587
  // ledger becomes the feeding seam.
610
588
  const focus = this.cockpitFacts.openSpec;
611
- const work = this.cockpitFacts.work;
612
589
  const subject = this.cockpitFacts.subject;
613
590
  const title = this.currentTitleState(snapshot);
614
591
  const focusedOpenSpec =
615
592
  focus.mode === 'task' && snapshot.openspec?.changeId === focus.change
616
593
  ? snapshot.openspec
617
594
  : null;
618
- const visibleWorkFocus =
619
- focus.mode === 'task' || (work.state === 'clear' && this.openSpecFeedback)
620
- ? undefined
621
- : work;
622
595
  const widgets = buildWidgetSnapshots(
623
596
  { ...snapshot, openspec: focusedOpenSpec },
624
597
  {
625
598
  focusedTaskId: focus.mode === 'task' ? focus.taskId : undefined,
626
- openSpecFeedback:
627
- focus.mode === 'none' && !visibleWorkFocus
628
- ? this.openSpecFeedback
629
- : undefined,
599
+ openSpecFeedback: focus.mode === 'none' ? this.openSpecFeedback : undefined,
630
600
  openSpecOverview: this.openSpecOverview,
631
601
  openSpecProjectDetected: this.openSpecProjectDetected,
632
- workFocus: visibleWorkFocus,
633
602
  },
634
603
  );
635
604
  this.publisher.sync(widgets);
@@ -638,7 +607,6 @@ class GalacticaStatusRuntime {
638
607
  buildHeaderStatusEvent(widgets, {
639
608
  openSpec: focusedOpenSpec,
640
609
  focusedTaskId: focus.mode === 'task' ? focus.taskId : undefined,
641
- workFocus: visibleWorkFocus,
642
610
  subject,
643
611
  orchestration: snapshot.orchestration,
644
612
  lifecycle: this.liveLifecycle,
@@ -655,20 +623,10 @@ class GalacticaStatusRuntime {
655
623
  diagnostics: snapshot.diagnostics,
656
624
  }),
657
625
  );
658
- this.updateTitle(snapshot);
659
626
  this.publishStatusHealth();
660
627
  }
661
628
 
662
629
  private currentTitleState(snapshot = this.store.get()): TitleState {
663
- return buildTitleState({
664
- subject: this.cockpitFacts.subject,
665
- focus: this.cockpitFacts.openSpec,
666
- openspec: snapshot.openspec,
667
- activity: this.titleActivity.current(),
668
- });
669
- }
670
-
671
- private updateTitle(snapshot = this.store.get()): void {
672
630
  const facts = this.cockpitFacts;
673
631
  const focusedTaskId =
674
632
  facts.openSpec.mode === 'task' ? facts.openSpec.taskId : undefined;
@@ -680,18 +638,23 @@ class GalacticaStatusRuntime {
680
638
  (task) => task.id === focusedTaskId,
681
639
  )?.title
682
640
  : undefined;
683
- const title = formatActiveWorkTitle({
641
+ // The band chip renders the fallback chain — task focus, native session
642
+ // name, legacy subject, directory. The terminal titlebar is pi-title's
643
+ // alone; this package never paints it (pure-renderer-split Slice 2).
644
+ const subjectTitle = formatActiveWorkTitle({
684
645
  focus: facts.openSpec,
685
- workFocus: facts.work,
686
646
  subject: facts.subject,
687
647
  ...(taskTitle ? { taskTitle } : {}),
688
648
  ...(this.sessionName ? { sessionName: this.sessionName } : {}),
689
649
  projectRoot: snapshot.projectRoot,
690
650
  cwd: snapshot.cwd,
691
651
  });
692
- if (title === this.lastTitle) return;
693
- this.lastTitle = title;
694
- this.ctx.ui.setTitle(title);
652
+ return buildTitleState({
653
+ subject: { state: 'set', title: subjectTitle },
654
+ focus: facts.openSpec,
655
+ openspec: snapshot.openspec,
656
+ activity: this.titleActivity.current(),
657
+ });
695
658
  }
696
659
 
697
660
  private recordEvent(event: LedgerEvent): void {
@@ -708,10 +671,6 @@ class GalacticaStatusRuntime {
708
671
  return this.focus;
709
672
  }
710
673
 
711
- currentWorkFocus(): SessionWorkFocus {
712
- return this.workFocus;
713
- }
714
-
715
674
  currentSubject(): SessionSubject {
716
675
  return this.subject;
717
676
  }
@@ -735,27 +694,13 @@ class GalacticaStatusRuntime {
735
694
  this.store.clearOpenSpec();
736
695
  if (focus.mode === 'task' && focus.awaitingValidation) {
737
696
  this.setLiveProjection('waiting', { kind: 'awaiting-validation' });
738
- } else if (focus.mode === 'none' && this.workFocus.state === 'clear') {
697
+ } else if (focus.mode === 'none') {
739
698
  this.setLiveProjection('listening');
740
699
  }
741
700
  this.publish();
742
701
  this.launch(this.refresh(false), 'openspec');
743
702
  }
744
703
 
745
- setWorkFocus(focus: SessionWorkFocus): void {
746
- if (sameSessionWorkFocus(this.workFocus, focus)) return;
747
- this.workFocus = focus;
748
- this.recordEvent({ type: 'focus/work', at: Date.now(), work: focus });
749
- if (focus.state === 'validation') {
750
- this.liveLifecycle = 'waiting';
751
- this.liveActivity = { kind: 'awaiting-validation' };
752
- } else if (focus.state === 'clear' && this.focus.mode === 'none') {
753
- this.liveLifecycle = 'listening';
754
- this.liveActivity = undefined;
755
- }
756
- this.publish();
757
- }
758
-
759
704
  setSubject(subject: SessionSubject): void {
760
705
  if (sameSessionSubject(this.subject, subject)) return;
761
706
  this.subject = subject;
@@ -938,20 +883,16 @@ class GalacticaStatusRuntime {
938
883
  this.publish();
939
884
  return;
940
885
  }
941
- if (this.focus.mode === 'task' || this.workFocus.state === 'validation') {
886
+ if (this.focus.mode === 'task') {
942
887
  this.setLiveProjection('waiting', { kind: 'awaiting-validation' });
943
888
  return;
944
889
  }
945
- if (this.workFocus.state === 'active') {
946
- this.setLiveProjection('waiting', { kind: 'awaiting-input' });
947
- return;
948
- }
949
890
  this.setLiveProjection('listening');
950
891
  }
951
892
 
952
893
  setSessionName(name: string | undefined): void {
953
894
  this.sessionName = name;
954
- this.updateTitle();
895
+ this.publish();
955
896
  }
956
897
 
957
898
  async forceRefresh(): Promise<void> {
@@ -978,11 +919,6 @@ class GalacticaStatusRuntime {
978
919
  ? `OpenSpec focus: ${this.openSpecFeedback}`
979
920
  : 'OpenSpec focus: none',
980
921
  );
981
- lines.push(
982
- this.workFocus.state === 'clear'
983
- ? 'Work: clear'
984
- : `Work: ${this.workFocus.state} · ${this.workFocus.intent}`,
985
- );
986
922
  lines.push(
987
923
  snapshot.diagnostics
988
924
  ? `Diagnostics: ${formatDiagnostics(snapshot.diagnostics)}`
@@ -1004,7 +940,6 @@ class GalacticaStatusRuntime {
1004
940
  configPath: snapshot.configPath,
1005
941
  configWarnings: snapshot.configWarnings,
1006
942
  focus: this.focus,
1007
- workFocus: this.workFocus,
1008
943
  openSpecFeedback: this.openSpecFeedback ?? null,
1009
944
  openSpecOverview: this.openSpecOverview ?? null,
1010
945
  detected: {
@@ -1055,123 +990,20 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1055
990
  }
1056
991
  });
1057
992
 
1058
- function persistAndApplyWorkFocus(focus: SessionWorkFocus): void {
1059
- pi.appendEntry(SESSION_WORK_FOCUS_ENTRY_TYPE, focus);
1060
- runtime?.setWorkFocus(focus);
1061
- }
1062
-
1063
- function persistAndApplySubject(subject: SessionSubject): void {
1064
- pi.appendEntry(SESSION_SUBJECT_ENTRY_TYPE, subject);
1065
- runtime?.setSubject(subject);
1066
- }
1067
-
1068
- function persistAndApplyFocus(focus: OpenSpecFocus): void {
1069
- if (
1070
- focus.mode === 'task' &&
1071
- (runtime?.currentWorkFocus() ?? NO_SESSION_WORK_FOCUS).state !== 'clear'
1072
- ) {
1073
- persistAndApplyWorkFocus(NO_SESSION_WORK_FOCUS);
1074
- }
1075
- pi.appendEntry(OPEN_SPEC_FOCUS_ENTRY_TYPE, focus);
993
+ // work-state owns focus writes and emits `focus-changed` after each
994
+ // successful one (pi.events); the renderer subscribes for immediate
995
+ // repaint (pure-renderer-split Slice 2).
996
+ const stopFocusRepaint = pi.events.on('focus-changed', (data: unknown) => {
997
+ const focus = parseOpenSpecFocus(data);
998
+ if (!focus) return;
1076
999
  runtime?.setFocus(focus);
1077
- }
1078
-
1079
- function subjectStatus(subject: SessionSubject): string {
1080
- return subject.state === 'clear' ? 'Title: clear' : `Title: ${subject.title}`;
1081
- }
1082
-
1083
- function applyTitleAction(
1084
- action: 'status' | 'set',
1085
- title?: string,
1086
- ): { ok: boolean; message: string; subject: SessionSubject } {
1087
- const current = runtime?.currentSubject() ?? NO_SESSION_SUBJECT;
1088
-
1089
- if (action === 'status') {
1090
- return { ok: true, message: subjectStatus(current), subject: current };
1091
- }
1092
-
1093
- const next = sessionSubject('set', title);
1094
- if (!next || next.state !== 'set') {
1095
- return {
1096
- ok: false,
1097
- message: 'Title requires a non-empty title',
1098
- subject: current,
1099
- };
1100
- }
1101
- if (!sameSessionSubject(current, next)) persistAndApplySubject(next);
1102
- return { ok: true, message: subjectStatus(next), subject: next };
1103
- }
1104
-
1105
- function applyOpenSpecFocusAction(
1106
- action: OpenSpecFocusAction,
1107
- change?: string,
1108
- taskId?: string,
1109
- ): { ok: boolean; message: string; focus: OpenSpecFocus } {
1110
- const current = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
1111
- const statusMessage = (focus: OpenSpecFocus): string =>
1112
- focus.mode === 'task'
1113
- ? `OpenSpec focus: ${focus.change} › ${focus.taskId}` +
1114
- (focus.awaitingValidation ? ' — awaiting Human validation' : '')
1115
- : 'OpenSpec focus: none';
1116
-
1117
- if (action === 'status') {
1118
- return { ok: true, message: statusMessage(current), focus: current };
1119
- }
1120
- if (action === 'clear') {
1121
- persistAndApplyFocus(NO_OPEN_SPEC_FOCUS);
1122
- return {
1123
- ok: true,
1124
- message: 'OpenSpec focus cleared',
1125
- focus: NO_OPEN_SPEC_FOCUS,
1126
- };
1127
- }
1128
- if (action === 'validate') {
1129
- if (current.mode !== 'task') {
1130
- return {
1131
- ok: false,
1132
- message: 'OpenSpec focus has no task to validate',
1133
- focus: current,
1134
- };
1135
- }
1136
- const validated = { ...current, awaitingValidation: true };
1137
- if (!sameOpenSpecFocus(current, validated)) persistAndApplyFocus(validated);
1138
- return { ok: true, message: statusMessage(validated), focus: validated };
1139
- }
1140
-
1141
- const requested = taskFocus(change, taskId);
1142
- if (!requested) {
1143
- return {
1144
- ok: false,
1145
- message: 'OpenSpec focus requires non-empty change and task identifiers',
1146
- focus: current,
1147
- };
1148
- }
1149
- if (
1150
- action === 'set' &&
1151
- current.mode === 'task' &&
1152
- !sameTaskFocus(current, requested)
1153
- ) {
1154
- return {
1155
- ok: false,
1156
- message:
1157
- `OpenSpec focus is ${current.change} › ${current.taskId}; ` +
1158
- 'use replace to change it deliberately',
1159
- focus: current,
1160
- };
1161
- }
1162
- if (!sameOpenSpecFocus(current, requested)) persistAndApplyFocus(requested);
1163
- return {
1164
- ok: true,
1165
- message: statusMessage(requested),
1166
- focus: requested,
1167
- };
1168
- }
1000
+ runtime?.settle();
1001
+ });
1169
1002
 
1170
1003
  pi.on('session_start', (_event, ctx) => {
1171
1004
  runtime?.stop();
1172
1005
  const branch = ctx.sessionManager.getBranch();
1173
1006
  const focus = restoreOpenSpecFocus(branch);
1174
- const workFocus = restoreSessionWorkFocus(branch);
1175
1007
  const subject = restoreSessionSubject(branch);
1176
1008
  const sessionName = pi.getSessionName() ?? ctx.sessionManager.getSessionName();
1177
1009
  runtime = new GalacticaStatusRuntime(
@@ -1179,7 +1011,6 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1179
1011
  ctx,
1180
1012
  publisher,
1181
1013
  focus,
1182
- workFocus,
1183
1014
  subject,
1184
1015
  sessionName,
1185
1016
  );
@@ -1193,28 +1024,12 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1193
1024
  pi.on('session_tree', (_event, ctx) => {
1194
1025
  const branch = ctx.sessionManager.getBranch();
1195
1026
  runtime?.setFocus(restoreOpenSpecFocus(branch));
1196
- runtime?.setWorkFocus(restoreSessionWorkFocus(branch));
1197
1027
  runtime?.setSubject(restoreSessionSubject(branch));
1198
1028
  runtime?.settle();
1199
1029
  });
1200
1030
 
1201
- pi.on('before_agent_start', (event) => {
1031
+ pi.on('before_agent_start', () => {
1202
1032
  runtime?.beginTurn();
1203
- // Auto-title: the first real user prompt names the session while the
1204
- // subject is still clear — the agent's `title` call stays optional
1205
- // polish (every agent skipped the first-turn rule; verified
1206
- // systematically in simplify-session-focus-surface). Human- or
1207
- // agent-set titles and restored sessions are never overridden.
1208
- // The event fields are narrowed structurally: the registry-pinned
1209
- // @earendil-works/pi-coding-agent types predate messageType/prompt,
1210
- // while the runtime event carries both (extensions.md, before_agent_start).
1211
- const promptRun = event as { messageType?: unknown; prompt?: unknown };
1212
- if (promptRun.messageType !== 'user' || typeof promptRun.prompt !== 'string') {
1213
- return;
1214
- }
1215
- if ((runtime?.currentSubject() ?? NO_SESSION_SUBJECT).state !== 'clear') return;
1216
- const next = titleFromPrompt(promptRun.prompt);
1217
- if (next && next.state === 'set') persistAndApplySubject(next);
1218
1033
  });
1219
1034
 
1220
1035
  pi.on('tool_execution_start', (event) => {
@@ -1230,23 +1045,6 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1230
1045
  );
1231
1046
  return;
1232
1047
  }
1233
- const requested = taskflowRunFocus(event.input);
1234
-
1235
- if (requested) {
1236
- const current = runtime?.currentFocus() ?? NO_OPEN_SPEC_FOCUS;
1237
- if (current.mode === 'task' && !sameOpenSpecFocus(current, requested)) {
1238
- return {
1239
- block: true,
1240
- reason:
1241
- `OpenSpec focus is ${current.change} › ${current.taskId}; ` +
1242
- 'use /openspec-focus clear or /openspec-focus replace <change> <task> before starting a conflicting run',
1243
- };
1244
- }
1245
- if (!sameOpenSpecFocus(current, requested)) {
1246
- persistAndApplyFocus(requested);
1247
- }
1248
- }
1249
-
1250
1048
  const initialPhase = taskflowInitialHeaderPhase(event.input);
1251
1049
  if (initialPhase) {
1252
1050
  runtime?.setTaskflowPhase(initialPhase);
@@ -1312,117 +1110,7 @@ export default function galacticaStatus(pi: ExtensionAPI): void {
1312
1110
  runtime = undefined;
1313
1111
  publisher.removeAll();
1314
1112
  stopReady();
1315
- });
1316
-
1317
- pi.registerTool({
1318
- name: 'title',
1319
- label: 'Session Title',
1320
- description:
1321
- 'Show or set the sanitized session title that keeps the focus row populated beneath OpenSpec and Session Work. The session titles itself from the first real prompt; call this only to name or rename deliberately (at most 120 characters, never raw prompt text), revising when the topic clearly shifts; never require Human action for it',
1322
- promptSnippet: 'Manage the automatic session title',
1323
- parameters: Type.Object({
1324
- action: StringEnum(['status', 'set'] as const),
1325
- title: Type.Optional(
1326
- Type.String({
1327
- description: 'Required only for set: the short sanitized subject title',
1328
- }),
1329
- ),
1330
- }),
1331
- async execute(_toolCallId, params) {
1332
- const result = applyTitleAction(params.action, params.title);
1333
- return {
1334
- content: [
1335
- {
1336
- type: 'text',
1337
- text: result.ok ? result.message : `Error: ${result.message}`,
1338
- },
1339
- ],
1340
- details: {
1341
- ok: result.ok,
1342
- subject: result.subject,
1343
- },
1344
- };
1345
- },
1346
- });
1347
-
1348
- pi.registerTool({
1349
- name: 'openspec_focus',
1350
- label: 'OpenSpec Focus',
1351
- description:
1352
- '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',
1353
- promptSnippet:
1354
- 'Manage exact branch-local OpenSpec Task focus with replacement safeguards',
1355
- parameters: Type.Object({
1356
- action: StringEnum(['status', 'set', 'replace', 'validate', 'clear'] as const),
1357
- change: Type.Optional(
1358
- Type.String({
1359
- description: 'Required for set and replace: the exact OpenSpec change ID',
1360
- }),
1361
- ),
1362
- task: Type.Optional(
1363
- Type.String({
1364
- description: 'Required for set and replace: the exact OpenSpec Task ID',
1365
- }),
1366
- ),
1367
- }),
1368
- async execute(_toolCallId, params) {
1369
- const result = applyOpenSpecFocusAction(
1370
- params.action,
1371
- params.change,
1372
- params.task,
1373
- );
1374
- return {
1375
- content: [
1376
- {
1377
- type: 'text',
1378
- text: result.ok ? result.message : `Error: ${result.message}`,
1379
- },
1380
- ],
1381
- details: {
1382
- ok: result.ok,
1383
- focus: result.focus,
1384
- },
1385
- };
1386
- },
1387
- });
1388
-
1389
- pi.registerCommand('openspec-focus', {
1390
- description: "Show, clear, or explicitly set this session's OpenSpec task focus",
1391
- handler: async (args, ctx) => {
1392
- const parts = args.trim().split(/\s+/u).filter(Boolean);
1393
- if (parts.length === 0) {
1394
- ctx.ui.notify(applyOpenSpecFocusAction('status').message, 'info');
1395
- return;
1396
- }
1397
-
1398
- if (parts.length === 1 && parts[0]?.toLowerCase() === 'clear') {
1399
- const result = applyOpenSpecFocusAction('clear');
1400
- ctx.ui.notify(result.message, 'info');
1401
- return;
1402
- }
1403
-
1404
- if (parts.length === 1 && parts[0]?.toLowerCase() === 'validate') {
1405
- const result = applyOpenSpecFocusAction('validate');
1406
- ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
1407
- return;
1408
- }
1409
-
1410
- const rawAction = parts[0]?.toLowerCase();
1411
- const action: 'set' | 'replace' = ['set', 'replace'].includes(rawAction ?? '')
1412
- ? (rawAction as 'set' | 'replace')
1413
- : 'set';
1414
- const identifiers = rawAction === action ? parts.slice(1) : parts;
1415
- if (identifiers.length !== 2) {
1416
- ctx.ui.notify(
1417
- 'Usage: /openspec-focus [set|replace] <change> <task> | clear',
1418
- 'warning',
1419
- );
1420
- return;
1421
- }
1422
-
1423
- const result = applyOpenSpecFocusAction(action, identifiers[0], identifiers[1]);
1424
- ctx.ui.notify(result.message, result.ok ? 'info' : 'warning');
1425
- },
1113
+ stopFocusRepaint();
1426
1114
  });
1427
1115
 
1428
1116
  pi.registerCommand('galactica-status', {