@aemonge-dev/pi-stardeck 0.14.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.
- package/README.md +20 -2
- package/demo/pi-stardeck.tape +6 -3
- package/docs/architecture.md +2 -1
- package/package.json +2 -3
- package/packages/pi-stardeck/index.ts +0 -10
- package/packages/pi-stardeck/src/deck.ts +26 -42
- package/packages/pi-stardeck/src/title-state.ts +64 -162
- package/packages/status/README.md +20 -34
- package/packages/status/index.ts +26 -155
- package/packages/status/src/active-work.ts +0 -84
- package/packages/status/src/config.ts +1 -3
- package/packages/status/src/ledger.ts +1 -7
- package/packages/status/src/openspec.ts +2 -2
- package/packages/status/src/publisher.ts +8 -78
- package/packages/status/src/reducer.ts +2 -36
- package/packages/status/src/room-aim.ts +151 -0
- package/packages/status/src/title-state.ts +42 -148
- package/packages/status/src/types.ts +1 -5
- package/src/index.ts +0 -2
- package/packages/pi-stardeck/src/activity-age.ts +0 -184
- package/packages/status/src/gate-act.ts +0 -85
- package/packages/work-state/README.md +0 -33
- package/packages/work-state/index.ts +0 -160
- package/packages/work-state/package.json +0 -33
- package/packages/work-state/src/focus.ts +0 -100
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,85 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { resolve, sep } from 'node:path';
|
|
3
|
-
import type { CommandRunner } from './types.ts';
|
|
4
|
-
|
|
5
|
-
/** Relay budget: the gate's act line is cockpit commentary — a slow or
|
|
6
|
-
* absent gate must cost less than a refresh, never block one. */
|
|
7
|
-
const GATE_PROBE_TIMEOUT_MS = 2_000;
|
|
8
|
-
/** Re-probe cadence when the meta file cannot be statted at all — time
|
|
9
|
-
* is the only cache key left, so it steps lightly. */
|
|
10
|
-
const ABSENT_META_TTL_MS = 30_000;
|
|
11
|
-
|
|
12
|
-
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
13
|
-
|
|
14
|
-
interface GateActCacheEntry {
|
|
15
|
-
mtimeMs?: number;
|
|
16
|
-
at: number;
|
|
17
|
-
line: string | undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** mtime-keyed relay cache: one probe per meta mtime, negatives cached
|
|
21
|
-
* exactly like hits — a missing or refusing gate must never become a
|
|
22
|
-
* per-render spawn storm. Keyed by contained change root, not slug. */
|
|
23
|
-
const gateActCache = new Map<string, GateActCacheEntry>();
|
|
24
|
-
|
|
25
|
-
function containedChangeRoot(projectRoot: string, change: string): string | undefined {
|
|
26
|
-
const changesRoot = resolve(projectRoot, 'openspec', 'changes');
|
|
27
|
-
const changeRoot = resolve(changesRoot, change);
|
|
28
|
-
if (changeRoot === changesRoot || !changeRoot.startsWith(`${changesRoot}${sep}`)) {
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
return changeRoot;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The gate's teaching line for one change, relayed verbatim:
|
|
36
|
-
* `pios-gate <slug>` — the aim — owns truth and words; this module owns
|
|
37
|
-
* only placement and cost. Any non-zero exit (unknown changes error),
|
|
38
|
-
* timeout kill, spawn failure, or empty output yields `undefined` —
|
|
39
|
-
* declared focus renders alone, quietly. The meta file is never parsed:
|
|
40
|
-
* its mtime only keys the cache.
|
|
41
|
-
*/
|
|
42
|
-
export async function resolveGateAct(options: {
|
|
43
|
-
change: string;
|
|
44
|
-
projectRoot: string;
|
|
45
|
-
runner: CommandRunner;
|
|
46
|
-
now?: number;
|
|
47
|
-
}): Promise<string | undefined> {
|
|
48
|
-
const changeRoot = containedChangeRoot(options.projectRoot, options.change);
|
|
49
|
-
if (!changeRoot) return undefined;
|
|
50
|
-
const now = options.now ?? Date.now();
|
|
51
|
-
|
|
52
|
-
let mtimeMs: number | undefined;
|
|
53
|
-
try {
|
|
54
|
-
mtimeMs = (await fs.stat(resolve(changeRoot, '.openspec.yaml'))).mtimeMs;
|
|
55
|
-
} catch {
|
|
56
|
-
mtimeMs = undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const cached = gateActCache.get(changeRoot);
|
|
60
|
-
if (
|
|
61
|
-
cached &&
|
|
62
|
-
(cached.mtimeMs !== undefined
|
|
63
|
-
? cached.mtimeMs === mtimeMs
|
|
64
|
-
: mtimeMs === undefined && now - cached.at < ABSENT_META_TTL_MS)
|
|
65
|
-
) {
|
|
66
|
-
return cached.line;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let line: string | undefined;
|
|
70
|
-
try {
|
|
71
|
-
const result = await options.runner('pios-gate', [options.change], {
|
|
72
|
-
cwd: options.projectRoot,
|
|
73
|
-
timeoutMs: GATE_PROBE_TIMEOUT_MS,
|
|
74
|
-
});
|
|
75
|
-
if (result.code === 0 && !result.killed) {
|
|
76
|
-
const firstLine = String(result.stdout).split('\n', 1)[0] ?? '';
|
|
77
|
-
const cleaned = firstLine.replace(CONTROL_CHARACTERS, ' ').trim();
|
|
78
|
-
if (cleaned) line = cleaned;
|
|
79
|
-
}
|
|
80
|
-
} catch {
|
|
81
|
-
// Missing binary, spawn refusal, whatever went wrong: no gate line.
|
|
82
|
-
}
|
|
83
|
-
gateActCache.set(changeRoot, { mtimeMs, at: now, line });
|
|
84
|
-
return line;
|
|
85
|
-
}
|
|
@@ -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
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
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
|
-
}
|