@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/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
- }