@aemonge-dev/pi-stardeck 0.13.0 → 0.15.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,13 +1,13 @@
1
- import type { OpenSpecFocus, SessionSubject } from './active-work.ts';
2
- import type { OpenSpecState } from './types.ts';
1
+ import type { SessionSubject } from './active-work.ts';
2
+ import type { RoomAim } from './room-aim.ts';
3
3
 
4
4
  /**
5
- * Title-state contract v1 (`deck-title-state`).
5
+ * Title-state contract v2 (`deck-title-state`).
6
6
  *
7
7
  * One authoritative object for the deck's upper half: `subject` is authored
8
- * once, `work` is derived from OpenSpec focus, `activity` is observed from
9
- * tool executions, and `waiver` suppresses one exact notice text. Rendering
10
- * code may never write any slot; it only parses defensively.
8
+ * once, `work` is the room aim's verbatim fields, `activity` is observed
9
+ * from tool executions, and `waiver` suppresses one exact notice text.
10
+ * Rendering code may never write any slot; it only parses defensively.
11
11
  */
12
12
 
13
13
  export const TITLE_STATE_VERSION = 2 as const;
@@ -28,34 +28,21 @@ export type TitleActivityClass = (typeof TITLE_ACTIVITY_CLASSES)[number];
28
28
  * render-time crop instead of pre-cutting subagent/bash labels). */
29
29
  export const TITLE_TARGET_MAX = 120;
30
30
 
31
- /** Task/step totals bound from the accepted schema. */
32
- export const TITLE_TOTAL_MAX = 999;
33
-
34
31
  const SUBJECT_MAX = 180;
35
32
  const IDENTIFIER_MAX = 120;
36
33
  const TASK_ID_MAX = 32;
37
34
  const SPAN_ID_MAX = 64;
38
35
  const WAIVER_ID_MAX = 64;
39
36
  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
- };
47
37
 
48
38
  export type TitleWorkState = {
49
39
  change: string;
40
+ /** Room-aim verbatim fields (observe-room-aim render law): printed
41
+ * as-is — the deck is a printer, not a translator. */
42
+ artifactName?: string;
43
+ artifactIdx?: string;
50
44
  task?: string;
51
- schemaName?: 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;
45
+ nextStep?: string;
59
46
  };
60
47
 
61
48
  export type TitleActivityState = {
@@ -92,15 +79,6 @@ function boundedText(
92
79
  return points.join('');
93
80
  }
94
81
 
95
- function boundedTotal(value: unknown): number | null {
96
- return typeof value === 'number' &&
97
- Number.isInteger(value) &&
98
- value >= 0 &&
99
- value <= TITLE_TOTAL_MAX
100
- ? value
101
- : null;
102
- }
103
-
104
82
  function parseTitleActivity(value: unknown): TitleActivityState | null | undefined {
105
83
  if (value === null) return null;
106
84
  if (!isRecord(value)) return undefined;
@@ -126,66 +104,25 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
126
104
  if (!isRecord(value)) return undefined;
127
105
  const change = boundedText(value.change, IDENTIFIER_MAX);
128
106
  if (!change) return undefined;
129
- const task = boundedText(value.task, TASK_ID_MAX, { required: false });
130
- if (task === null) return undefined;
131
- const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
107
+ const artifactName = boundedText(value.artifactName, IDENTIFIER_MAX, {
132
108
  required: false,
133
109
  });
134
- if (schemaName === null) return undefined;
135
- const currentArtifact = boundedText(value.currentArtifact, TASK_ID_MAX, {
110
+ if (artifactName === null) return undefined;
111
+ const artifactIdx = boundedText(value.artifactIdx, TASK_ID_MAX, {
136
112
  required: false,
137
113
  });
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;
179
- }
180
- const extras = {
181
- ...(schemaName ? { schemaName } : {}),
182
- ...(artifacts ? { artifacts } : {}),
183
- ...(currentArtifact ? { currentArtifact } : {}),
184
- ...(value.awaitingValidation === true ? { awaitingValidation: true } : {}),
185
- ...(focusAt !== undefined ? { focusAt } : {}),
186
- ...(value.focusStale === true ? { focusStale: true } : {}),
114
+ if (artifactIdx === null) return undefined;
115
+ const task = boundedText(value.task, TASK_ID_MAX, { required: false });
116
+ if (task === null) return undefined;
117
+ const nextStep = boundedText(value.nextStep, NOTICE_MAX, { required: false });
118
+ if (nextStep === null) return undefined;
119
+ return {
120
+ change,
121
+ ...(artifactName ? { artifactName } : {}),
122
+ ...(artifactIdx ? { artifactIdx } : {}),
123
+ ...(task ? { task } : {}),
124
+ ...(nextStep ? { nextStep } : {}),
187
125
  };
188
- return task === undefined ? { change, ...extras } : { change, task, ...extras };
189
126
  }
190
127
 
191
128
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -199,8 +136,8 @@ function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
199
136
 
200
137
  /**
201
138
  * Defensive parser for incoming title-state payloads. Unknown versions,
202
- * malformed shapes, and out-of-bounds values (target > 120 characters,
203
- * totals > 999) reject the whole object with `null`; it never throws.
139
+ * malformed shapes, and out-of-bounds values (target > 120 characters)
140
+ * reject the whole object with `null`; it never throws.
204
141
  */
205
142
  export function parseTitleState(value: unknown): TitleState | null {
206
143
  try {
@@ -234,64 +171,35 @@ export function shortenTitleTarget(value: string): string {
234
171
  }
235
172
 
236
173
  /**
237
- * Derive the `work` slot from the OpenSpec focus and the focused snapshot.
238
- * A task focus owns its change; without a focus a passively collected change
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.
174
+ * The aimed work slot — the room aim's five fields, verbatim (render
175
+ * law). `change` is the keystone; every other field drops when missing,
176
+ * and the deck prints what remains exactly as the file wrote it.
241
177
  */
242
- export function deriveTitleWork(
243
- focus: OpenSpecFocus,
244
- openspec: OpenSpecState | null | undefined,
245
- options: { focusAt?: number } = {},
246
- ): TitleWorkState | null {
247
- // Artifact facts come straight from the collector; nothing is invented
248
- // here — absent facts simply drop out of the contract.
249
- const extras = (state: OpenSpecState | undefined) => {
250
- if (!state) return {};
251
- return {
252
- ...(state.schemaName ? { schemaName: state.schemaName } : {}),
253
- ...(state.artifacts ? { artifacts: state.artifacts } : {}),
254
- ...(state.currentArtifact ? { currentArtifact: state.currentArtifact } : {}),
255
- };
178
+ export function aimTitleWork(aim: RoomAim): TitleWorkState {
179
+ return {
180
+ change: aim.change,
181
+ ...(aim.artifactName ? { artifactName: aim.artifactName } : {}),
182
+ ...(aim.artifactIdx ? { artifactIdx: aim.artifactIdx } : {}),
183
+ ...(aim.task ? { task: aim.task } : {}),
184
+ ...(aim.nextStep ? { nextStep: aim.nextStep } : {}),
256
185
  };
257
- if (focus.mode === 'task') {
258
- const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
259
- const stale =
260
- options.focusAt !== undefined &&
261
- state?.changeModifiedAt !== undefined &&
262
- state.changeModifiedAt > options.focusAt;
263
- return {
264
- change: focus.change,
265
- task: focus.taskId,
266
- ...extras(state),
267
- ...(focus.awaitingValidation ? { awaitingValidation: true } : {}),
268
- ...(options.focusAt !== undefined ? { focusAt: options.focusAt } : {}),
269
- ...(stale ? { focusStale: true } : {}),
270
- };
271
- }
272
- if (!openspec) return null;
273
- return { change: openspec.changeId, ...extras(openspec) };
274
186
  }
275
187
 
276
188
  /**
277
- * Build the v1 title state from publisher-owned facts: the authored session
278
- * subject entry, the OpenSpec focus (plus its snapshot), and the newest
279
- * observed activity event. The waiver slot stays null until the notice slice.
189
+ * Build the title state from publisher-owned facts: the authored session
190
+ * subject entry, the room aim's verbatim work fields, and the newest
191
+ * observed activity event.
280
192
  */
281
193
  export function buildTitleState(options: {
282
194
  subject: SessionSubject;
283
- focus: OpenSpecFocus;
284
- openspec?: OpenSpecState | null;
195
+ /** The room aim — when present it owns the work slot outright. */
196
+ aim?: RoomAim | null;
285
197
  activity?: TitleActivityState | null;
286
- /** Publisher-owned timestamp of the current focus fact. */
287
- focusAt?: number;
288
198
  }): TitleState {
289
199
  return {
290
200
  v: TITLE_STATE_VERSION,
291
201
  subject: options.subject.state === 'set' ? options.subject.title : null,
292
- work: deriveTitleWork(options.focus, options.openspec, {
293
- focusAt: options.focusAt,
294
- }),
202
+ work: options.aim ? aimTitleWork(options.aim) : null,
295
203
  activity: options.activity ?? null,
296
204
  waiver: null,
297
205
  };
@@ -21,7 +21,6 @@ export interface GalacticaStatusConfig {
21
21
  enabled: boolean;
22
22
  openspec: {
23
23
  enabled: boolean;
24
- change: string | null;
25
24
  };
26
25
  diagnostics: {
27
26
  enabled: boolean;
@@ -91,9 +90,12 @@ export interface OpenSpecState {
91
90
  changeId: string;
92
91
  title: string;
93
92
  phase: string;
94
- completedTasks: number;
95
- totalTasks: number;
96
- progressPercent: number;
93
+ /** File-truth counts — checkbox tasks first, then done/total
94
+ * artifacts. Absent when neither is readable: absence never becomes
95
+ * zero (truthful-rendered-state). */
96
+ completedTasks?: number;
97
+ totalTasks?: number;
98
+ progressPercent?: number;
97
99
  pendingTasks: OpenSpecTask[];
98
100
  allTasks?: OpenSpecTask[];
99
101
  tasksPath?: string;
@@ -205,10 +207,7 @@ export type StageValue =
205
207
  | 'listening'
206
208
  | 'aborted';
207
209
 
208
- export type SelectionState =
209
- | { kind: 'openspec-task'; change: string; task: string }
210
- | { kind: 'subject'; title: string }
211
- | { kind: 'none' };
210
+ export type SelectionState = { kind: 'subject'; title: string } | { kind: 'none' };
212
211
 
213
212
  export type RunSpan = {
214
213
  id: string;
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
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';
5
4
  import galacticaStatus from '../packages/status/index.js';
6
5
  import footerTelemetry from '../packages/footer/index.js';
7
6
  import { registerStardeckHealth } from './health.js';
@@ -20,7 +19,6 @@ export const COCKPIT_COMPOSITION = [
20
19
  export default function piStardeck(pi: ExtensionAPI): void {
21
20
  registerPiStatus(pi);
22
21
  footerTelemetry(pi);
23
- workState(pi);
24
22
  galacticaStatus(pi);
25
23
  galacticaContextHeader(pi, {
26
24
  surface: COCKPIT_SURFACES.contextHeader,
@@ -1,184 +0,0 @@
1
- import { visibleWidth } from '@earendil-works/pi-tui';
2
-
3
- export const WIDE_ACTIVITY_AGE_CELLS = 6;
4
- export const STANDARD_ACTIVITY_AGE_CELLS = 4;
5
- export const COMPACT_ACTIVITY_AGE_CELLS = 3;
6
- export const RAIL_CHRONOMETER_CELLS = 8;
7
- export const RUNTIME_STATUS_WIDGET_ID = 'galactica.runtime-state';
8
- export const RUNTIME_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.runtime-scope-separator';
9
-
10
- export type ActivityAgeSize = 6 | 4 | 3;
11
-
12
- const ACTIVITY_AGE_SIZES: readonly ActivityAgeSize[] = [
13
- WIDE_ACTIVITY_AGE_CELLS,
14
- STANDARD_ACTIVITY_AGE_CELLS,
15
- COMPACT_ACTIVITY_AGE_CELLS,
16
- ];
17
-
18
- function boundedAge(ageMs: number): number {
19
- return Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs)) : 0;
20
- }
21
-
22
- function activityHeartbeat(ageMs: number): string {
23
- return Math.floor(boundedAge(ageMs) / 500) % 2 === 0 ? '·' : '•';
24
- }
25
-
26
- function wideActivityAge(ageMs: number): string {
27
- const age = boundedAge(ageMs);
28
- if (age < 100) return `${age}ms`;
29
- if (age < 60_000) {
30
- return `${Math.min(59.9, Math.floor(age / 100) / 10).toFixed(1)}s`;
31
- }
32
-
33
- const seconds = Math.floor(age / 1_000);
34
- const minutes = Math.floor(seconds / 60);
35
- if (minutes < 60) {
36
- return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`;
37
- }
38
-
39
- const hours = Math.floor(minutes / 60);
40
- const heartbeat = activityHeartbeat(age);
41
- if (hours < 10) {
42
- return `${hours}h${String(minutes % 60).padStart(2, '0')}m${heartbeat}`;
43
- }
44
- if (hours < 100) {
45
- return `${hours}:${String(minutes % 60).padStart(2, '0')}${heartbeat}`;
46
- }
47
-
48
- const days = Math.min(99, Math.floor(hours / 24));
49
- if (days < 10) {
50
- return `${days}d${String(hours % 24).padStart(2, '0')}h${heartbeat}`;
51
- }
52
- return `${days}d${String(hours % 24).padStart(2, '0')}${heartbeat}`;
53
- }
54
-
55
- function standardActivityAge(ageMs: number): string {
56
- const age = boundedAge(ageMs);
57
- if (age < 100) return `${age}ms`;
58
- if (age < 10_000) return `${Math.min(9.9, age / 1_000).toFixed(1)}s`;
59
-
60
- const seconds = Math.floor(age / 1_000);
61
- if (seconds < 100) return `${seconds}s`;
62
- const minutes = Math.floor(seconds / 60);
63
- if (minutes < 100) return `${minutes}m`;
64
- const hours = Math.floor(minutes / 60);
65
- if (hours < 100) return `${hours}h`;
66
- return `${Math.min(99, Math.floor(hours / 24))}d`;
67
- }
68
-
69
- export function compactActivityAge(ageMs: number): string {
70
- const age = boundedAge(ageMs);
71
- const seconds = Math.floor(age / 1_000);
72
- if (seconds < 100) return `${seconds}s`;
73
- const minutes = Math.floor(seconds / 60);
74
- if (minutes < 100) return `${minutes}m`;
75
- const hours = Math.floor(minutes / 60);
76
- if (hours < 100) return `${hours}h`;
77
- return `${Math.min(99, Math.floor(hours / 24))}d`;
78
- }
79
-
80
- export function formatActivityAge(ageMs: number, size: ActivityAgeSize): string {
81
- const text =
82
- size === WIDE_ACTIVITY_AGE_CELLS
83
- ? wideActivityAge(ageMs)
84
- : size === STANDARD_ACTIVITY_AGE_CELLS
85
- ? standardActivityAge(ageMs)
86
- : compactActivityAge(ageMs);
87
- return `${' '.repeat(Math.max(0, size - visibleWidth(text)))}${text}`;
88
- }
89
-
90
- export function passiveActivityStatus(
91
- lifecycle: string,
92
- size: ActivityAgeSize,
93
- ): string {
94
- const normalized = lifecycle.trim().toLowerCase();
95
- const full =
96
- normalized === 'listening'
97
- ? 'idle'
98
- : normalized === 'blocked'
99
- ? 'hold'
100
- : normalized === 'aborted'
101
- ? 'stop'
102
- : 'wait';
103
- if (size !== COMPACT_ACTIVITY_AGE_CELLS) {
104
- return `${' '.repeat(Math.max(0, size - visibleWidth(full)))}${full}`;
105
- }
106
- const compact =
107
- full === 'idle' ? '—' : full === 'hold' ? '!' : full === 'stop' ? '×' : '…';
108
- return `${' '.repeat(size - visibleWidth(compact))}${compact}`;
109
- }
110
-
111
- export function formatRailChronometer(ageMs: number | null): string {
112
- if (ageMs === null) return "00:00'00";
113
-
114
- const centiseconds = Math.floor(boundedAge(ageMs) / 10);
115
- const minutes = Math.floor(centiseconds / 6_000);
116
- if (minutes < 100) {
117
- const seconds = Math.floor(centiseconds / 100) % 60;
118
- const remainder = centiseconds % 100;
119
- return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}'${String(remainder).padStart(2, '0')}`;
120
- }
121
-
122
- const totalSeconds = Math.floor(centiseconds / 100);
123
- const hours = Math.floor(totalSeconds / 3_600);
124
- if (hours < 100) {
125
- const remainderMinutes = Math.floor(totalSeconds / 60) % 60;
126
- const seconds = totalSeconds % 60;
127
- return `${String(hours).padStart(2, '0')}h${String(remainderMinutes).padStart(2, '0')}'${String(seconds).padStart(2, '0')}`;
128
- }
129
-
130
- const days = Math.min(99, Math.floor(hours / 24));
131
- const remainderHours = hours % 24;
132
- const remainderMinutes = Math.floor(totalSeconds / 60) % 60;
133
- return `${String(days).padStart(2, '0')}d${String(remainderHours).padStart(2, '0')}'${String(remainderMinutes).padStart(2, '0')}`;
134
- }
135
-
136
- export function activityAgeCandidates(
137
- ageMs: number | null,
138
- lifecycle = 'listening',
139
- ): ReadonlyArray<{ size: ActivityAgeSize; age: string }> {
140
- return ACTIVITY_AGE_SIZES.map((size) => ({
141
- size,
142
- age:
143
- ageMs === null
144
- ? passiveActivityStatus(lifecycle, size)
145
- : formatActivityAge(ageMs, size),
146
- }));
147
- }
148
-
149
- export function buildRuntimeFooterWidgets(status: string, active = false) {
150
- return [
151
- {
152
- protocol: 1,
153
- type: 'upsert',
154
- widget: {
155
- id: RUNTIME_STATUS_WIDGET_ID,
156
- label: 'Runtime state',
157
- description: 'Time since foreground activity or the settled runtime state',
158
- content: { type: 'text', text: status },
159
- icon: false,
160
- style: { textColor: active ? 'accent' : 'dim' },
161
- layout: {
162
- row: 1,
163
- position: 4,
164
- align: 'left',
165
- fill: 'none',
166
- minWidth: RAIL_CHRONOMETER_CELLS,
167
- },
168
- },
169
- },
170
- {
171
- protocol: 1,
172
- type: 'upsert',
173
- widget: {
174
- id: RUNTIME_SCOPE_SEPARATOR_WIDGET_ID,
175
- label: 'Runtime scope separator',
176
- description: 'Separates the active LLM from runtime state',
177
- content: { type: 'text', text: '⟩' },
178
- icon: false,
179
- style: { textColor: 'thinkingHigh' },
180
- layout: { row: 1, position: 3, align: 'left', fill: 'none' },
181
- },
182
- },
183
- ] as const;
184
- }
@@ -1,33 +0,0 @@
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.
@@ -1,160 +0,0 @@
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
- }