@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +239 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +103 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +17 -0
- package/licenses/pi-fancy-footer-LICENSE +21 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
- package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
- package/packages/pi-fancy-footer-full-palette/README.md +468 -0
- package/packages/pi-fancy-footer-full-palette/package.json +52 -0
- package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
- package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
- package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
- package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
- package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
- package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
- package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
- package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
- package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
- package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
- package/packages/pi-stardeck/README.md +219 -0
- package/packages/pi-stardeck/index.ts +691 -0
- package/packages/pi-stardeck/package.json +32 -0
- package/packages/pi-stardeck/src/activity-age.ts +184 -0
- package/packages/pi-stardeck/src/capabilities.ts +105 -0
- package/packages/pi-stardeck/src/deck.ts +807 -0
- package/packages/pi-stardeck/src/gauge.ts +1323 -0
- package/packages/pi-stardeck/src/git-refresh.ts +5 -0
- package/packages/pi-stardeck/src/process-resources.ts +428 -0
- package/packages/pi-stardeck/src/title-state.ts +195 -0
- package/packages/pi-stardeck/src/title.ts +443 -0
- package/packages/pi-vim-top-border/LICENSE +21 -0
- package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
- package/packages/pi-vim-top-border/README.md +577 -0
- package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
- package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
- package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
- package/packages/pi-vim-top-border/index.ts +4195 -0
- package/packages/pi-vim-top-border/input-keys.ts +49 -0
- package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
- package/packages/pi-vim-top-border/mode-colors.ts +97 -0
- package/packages/pi-vim-top-border/mode-label.ts +62 -0
- package/packages/pi-vim-top-border/motions.ts +297 -0
- package/packages/pi-vim-top-border/package.json +47 -0
- package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
- package/packages/pi-vim-top-border/prompt-status.ts +131 -0
- package/packages/pi-vim-top-border/settings.ts +287 -0
- package/packages/pi-vim-top-border/text-objects.ts +389 -0
- package/packages/pi-vim-top-border/types.ts +51 -0
- package/packages/pi-vim-top-border/visual.ts +65 -0
- package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
- package/packages/status/README.md +398 -0
- package/packages/status/index.ts +1717 -0
- package/packages/status/package.json +33 -0
- package/packages/status/src/active-work.ts +235 -0
- package/packages/status/src/config.ts +218 -0
- package/packages/status/src/diagnostics.ts +238 -0
- package/packages/status/src/goal-block.ts +377 -0
- package/packages/status/src/goal.ts +170 -0
- package/packages/status/src/ledger.ts +101 -0
- package/packages/status/src/openspec.ts +394 -0
- package/packages/status/src/orchestration.ts +237 -0
- package/packages/status/src/publisher.ts +1294 -0
- package/packages/status/src/reducer.ts +190 -0
- package/packages/status/src/runtime-runs.ts +267 -0
- package/packages/status/src/state.ts +178 -0
- package/packages/status/src/title-activity.ts +224 -0
- package/packages/status/src/title-state.ts +218 -0
- package/packages/status/src/turn-cost.ts +63 -0
- package/packages/status/src/types.ts +228 -0
- package/src/deck-surface.ts +46 -0
- package/src/health.ts +228 -0
- package/src/index.ts +28 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
|
|
5
|
+
export const GOAL_STATE_ENTRY_TYPE = 'goal-state';
|
|
6
|
+
|
|
7
|
+
export const GOAL_HEADER_STATUSES = [
|
|
8
|
+
'active',
|
|
9
|
+
'queued',
|
|
10
|
+
'paused',
|
|
11
|
+
'blocked',
|
|
12
|
+
'usage_limited',
|
|
13
|
+
'budget_limited',
|
|
14
|
+
'complete',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type GoalHeaderStatus = (typeof GOAL_HEADER_STATUSES)[number];
|
|
18
|
+
|
|
19
|
+
export interface GoalHeaderState {
|
|
20
|
+
id: string;
|
|
21
|
+
status: GoalHeaderStatus;
|
|
22
|
+
automaticModelTurns: number;
|
|
23
|
+
waiting: boolean;
|
|
24
|
+
objective?: string;
|
|
25
|
+
elapsedSeconds?: number;
|
|
26
|
+
tasksDone?: number;
|
|
27
|
+
tasksActive?: number;
|
|
28
|
+
tasksTotal?: number;
|
|
29
|
+
tokensUsed?: number;
|
|
30
|
+
tokenBudget?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STATUS_SET = new Set<string>(GOAL_HEADER_STATUSES);
|
|
34
|
+
const MAX_GOAL_ID_POINTS = 128;
|
|
35
|
+
const MAX_HEADER_PROGRESS = 9_999;
|
|
36
|
+
const GOAL_PROMPT_MARKER =
|
|
37
|
+
/<!-- pi-goal-(?:prompt|continuation):[A-Za-z0-9:._-]{1,300} -->/u;
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseGoalHeaderState(value: unknown): GoalHeaderState | null {
|
|
44
|
+
if (value === null) return null;
|
|
45
|
+
if (!isRecord(value)) return null;
|
|
46
|
+
if (typeof value.id !== 'string' || !value.id || value.id !== value.id.trim()) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (Array.from(value.id).length > MAX_GOAL_ID_POINTS) return null;
|
|
50
|
+
if (typeof value.status !== 'string' || !STATUS_SET.has(value.status)) return null;
|
|
51
|
+
const automaticModelTurns =
|
|
52
|
+
typeof value.automaticModelTurns === 'number' &&
|
|
53
|
+
Number.isSafeInteger(value.automaticModelTurns) &&
|
|
54
|
+
value.automaticModelTurns >= 0
|
|
55
|
+
? Math.min(value.automaticModelTurns, MAX_HEADER_PROGRESS)
|
|
56
|
+
: 0;
|
|
57
|
+
const boundedCount = (input: unknown): number | undefined =>
|
|
58
|
+
typeof input === 'number' &&
|
|
59
|
+
Number.isSafeInteger(input) &&
|
|
60
|
+
input >= 0 &&
|
|
61
|
+
input <= 9_999
|
|
62
|
+
? input
|
|
63
|
+
: undefined;
|
|
64
|
+
const objective =
|
|
65
|
+
typeof value.objective === 'string' && value.objective.trim() !== ''
|
|
66
|
+
? value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160)
|
|
67
|
+
: undefined;
|
|
68
|
+
const elapsedSeconds =
|
|
69
|
+
typeof value.elapsedSeconds === 'number' &&
|
|
70
|
+
Number.isFinite(value.elapsedSeconds) &&
|
|
71
|
+
value.elapsedSeconds >= 0
|
|
72
|
+
? Math.min(value.elapsedSeconds, 3_600_000)
|
|
73
|
+
: undefined;
|
|
74
|
+
const tokensUsed =
|
|
75
|
+
typeof value.tokensUsed === 'number' &&
|
|
76
|
+
Number.isFinite(value.tokensUsed) &&
|
|
77
|
+
value.tokensUsed >= 0
|
|
78
|
+
? Math.min(value.tokensUsed, 9_999_999_999)
|
|
79
|
+
: undefined;
|
|
80
|
+
return {
|
|
81
|
+
id: value.id,
|
|
82
|
+
status: value.status as GoalHeaderStatus,
|
|
83
|
+
automaticModelTurns,
|
|
84
|
+
waiting: value.status === 'active' && isRecord(value.waiting),
|
|
85
|
+
...(objective !== undefined ? { objective } : {}),
|
|
86
|
+
...(elapsedSeconds !== undefined ? { elapsedSeconds } : {}),
|
|
87
|
+
...(boundedCount(value.tasksDone) !== undefined
|
|
88
|
+
? { tasksDone: boundedCount(value.tasksDone) }
|
|
89
|
+
: {}),
|
|
90
|
+
...(boundedCount(value.tasksActive) !== undefined
|
|
91
|
+
? { tasksActive: boundedCount(value.tasksActive) }
|
|
92
|
+
: {}),
|
|
93
|
+
...(boundedCount(value.tasksTotal) !== undefined
|
|
94
|
+
? { tasksTotal: boundedCount(value.tasksTotal) }
|
|
95
|
+
: {}),
|
|
96
|
+
...(tokensUsed !== undefined ? { tokensUsed } : {}),
|
|
97
|
+
...(boundedCount(value.tokenBudget) !== undefined &&
|
|
98
|
+
(boundedCount(value.tokenBudget) ?? 0) > 0
|
|
99
|
+
? { tokenBudget: boundedCount(value.tokenBudget) }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function restoreGoalHeaderState(
|
|
105
|
+
entries: readonly unknown[],
|
|
106
|
+
): GoalHeaderState | null {
|
|
107
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
108
|
+
const entry = entries[index];
|
|
109
|
+
if (
|
|
110
|
+
!isRecord(entry) ||
|
|
111
|
+
entry.type !== 'custom' ||
|
|
112
|
+
entry.customType !== GOAL_STATE_ENTRY_TYPE
|
|
113
|
+
) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const data = isRecord(entry.data) ? entry.data : undefined;
|
|
117
|
+
return parseGoalHeaderState(data?.goal);
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isGoalOwnedPrompt(value: unknown): boolean {
|
|
123
|
+
return typeof value === 'string' && GOAL_PROMPT_MARKER.test(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function sameGoalHeaderState(
|
|
127
|
+
left: GoalHeaderState | null,
|
|
128
|
+
right: GoalHeaderState | null,
|
|
129
|
+
): boolean {
|
|
130
|
+
return (
|
|
131
|
+
left === right ||
|
|
132
|
+
(left !== null &&
|
|
133
|
+
right !== null &&
|
|
134
|
+
left.id === right.id &&
|
|
135
|
+
left.status === right.status &&
|
|
136
|
+
left.automaticModelTurns === right.automaticModelTurns &&
|
|
137
|
+
left.waiting === right.waiting &&
|
|
138
|
+
left.objective === right.objective &&
|
|
139
|
+
left.tasksDone === right.tasksDone &&
|
|
140
|
+
left.tasksActive === right.tasksActive &&
|
|
141
|
+
left.tasksTotal === right.tasksTotal &&
|
|
142
|
+
left.tokensUsed === right.tokensUsed &&
|
|
143
|
+
left.tokenBudget === right.tokenBudget &&
|
|
144
|
+
Math.floor((left.elapsedSeconds ?? 0) / 10) ===
|
|
145
|
+
Math.floor((right.elapsedSeconds ?? 0) / 10))
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseGoalAutomaticTurnLimit(value: unknown): number | undefined {
|
|
150
|
+
if (!isRecord(value)) return undefined;
|
|
151
|
+
const limits = isRecord(value.continuationLimits)
|
|
152
|
+
? value.continuationLimits
|
|
153
|
+
: undefined;
|
|
154
|
+
const limit = limits?.automaticTurns;
|
|
155
|
+
return typeof limit === 'number' &&
|
|
156
|
+
Number.isSafeInteger(limit) &&
|
|
157
|
+
limit > 0 &&
|
|
158
|
+
limit <= MAX_HEADER_PROGRESS
|
|
159
|
+
? limit
|
|
160
|
+
: undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function loadGoalAutomaticTurnLimit(): Promise<number | undefined> {
|
|
164
|
+
try {
|
|
165
|
+
const raw = await readFile(join(getAgentDir(), 'pi-goal.json'), 'utf8');
|
|
166
|
+
return parseGoalAutomaticTurnLimit(JSON.parse(raw) as unknown);
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiagnosticsState,
|
|
3
|
+
OrchestrationState,
|
|
4
|
+
ProgressFact,
|
|
5
|
+
RunSpan,
|
|
6
|
+
StageValue,
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
import type { GoalHeaderState } from './goal.ts';
|
|
9
|
+
import type { OpenSpecFocus, SessionSubject, SessionWorkFocus } from './active-work.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Append-only event ledger for the cockpit status core.
|
|
13
|
+
*
|
|
14
|
+
* Every producer mutation becomes exactly one bounded event. The reducer is the
|
|
15
|
+
* only reader that writes snapshot state. Payloads carry bounded presentation
|
|
16
|
+
* facts only — never prompts, generated prose, tool arguments, child output,
|
|
17
|
+
* credentials, or hidden reasoning (frozen contract, Privacy section).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const LEDGER_EVENT_TYPES = [
|
|
21
|
+
'focus/openspec',
|
|
22
|
+
'focus/goal',
|
|
23
|
+
'focus/work',
|
|
24
|
+
'focus/subject',
|
|
25
|
+
'run/start',
|
|
26
|
+
'run/stage',
|
|
27
|
+
'run/end',
|
|
28
|
+
'progress/refresh',
|
|
29
|
+
'diagnostics/refresh',
|
|
30
|
+
'orchestration/refresh',
|
|
31
|
+
'taskflow/phase',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type LedgerEventType = (typeof LEDGER_EVENT_TYPES)[number];
|
|
35
|
+
|
|
36
|
+
export type LedgerEvent =
|
|
37
|
+
| {
|
|
38
|
+
type: 'focus/openspec';
|
|
39
|
+
at: number;
|
|
40
|
+
focus: OpenSpecFocus;
|
|
41
|
+
}
|
|
42
|
+
| { type: 'focus/goal'; at: number; goal: GoalHeaderState | null }
|
|
43
|
+
| { type: 'focus/work'; at: number; work: SessionWorkFocus }
|
|
44
|
+
| { type: 'focus/subject'; at: number; subject: SessionSubject }
|
|
45
|
+
| { type: 'run/start'; at: number; span: RunSpan }
|
|
46
|
+
| { type: 'run/stage'; at: number; id: string; stage: StageValue; declared: boolean }
|
|
47
|
+
| { type: 'run/end'; at: number; id: string }
|
|
48
|
+
| { type: 'progress/refresh'; at: number; progress: ProgressFact | null }
|
|
49
|
+
| { type: 'diagnostics/refresh'; at: number; state: DiagnosticsState | null }
|
|
50
|
+
| { type: 'orchestration/refresh'; at: number; state: OrchestrationState | null }
|
|
51
|
+
| { type: 'taskflow/phase'; at: number; phase: string };
|
|
52
|
+
|
|
53
|
+
const DEFAULT_EVENT_CAP = 512;
|
|
54
|
+
|
|
55
|
+
export class EventLedger {
|
|
56
|
+
private readonly events: LedgerEvent[] = [];
|
|
57
|
+
private readonly cap: number;
|
|
58
|
+
|
|
59
|
+
constructor(cap: number = DEFAULT_EVENT_CAP) {
|
|
60
|
+
if (!Number.isInteger(cap) || cap < 1) {
|
|
61
|
+
throw new RangeError('ledger cap must be a positive integer');
|
|
62
|
+
}
|
|
63
|
+
this.cap = cap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
append(event: LedgerEvent): void {
|
|
67
|
+
this.events.push(event);
|
|
68
|
+
if (this.events.length > this.cap) {
|
|
69
|
+
this.events.splice(0, this.events.length - this.cap);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
snapshot(): readonly LedgerEvent[] {
|
|
74
|
+
return this.events.slice();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get size(): number {
|
|
78
|
+
return this.events.length;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Bounded taskflow phase label: inline, trimmed, single line, hard-capped. */
|
|
83
|
+
export function boundedPhase(value: string): string {
|
|
84
|
+
const normalized = value
|
|
85
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
|
|
86
|
+
.replace(/\s+/gu, ' ');
|
|
87
|
+
const points = Array.from(normalized.trim());
|
|
88
|
+
if (points.length <= 40) return points.join('');
|
|
89
|
+
return `${points.slice(0, 39).join('')}…`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Validate an unknown value as a ledger event (fail-soft for adapters). */
|
|
93
|
+
export function isLedgerEvent(value: unknown): value is LedgerEvent {
|
|
94
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
95
|
+
const candidate = value as { type?: unknown; at?: unknown };
|
|
96
|
+
if (typeof candidate.type !== 'string') return false;
|
|
97
|
+
if (!(LEDGER_EVENT_TYPES as readonly string[]).includes(candidate.type)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return typeof candidate.at === 'number' && Number.isFinite(candidate.at);
|
|
101
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
CommandRunner,
|
|
5
|
+
OpenSpecHierarchyProgress,
|
|
6
|
+
OpenSpecOverview,
|
|
7
|
+
OpenSpecState,
|
|
8
|
+
OpenSpecTask,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
interface OpenSpecChangeRecord {
|
|
12
|
+
name: string;
|
|
13
|
+
completedTasks: number;
|
|
14
|
+
totalTasks: number;
|
|
15
|
+
lastModified: string;
|
|
16
|
+
status: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OpenSpecListResult {
|
|
20
|
+
changes: OpenSpecChangeRecord[];
|
|
21
|
+
root?: { path?: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ApplyInstructionsResult {
|
|
25
|
+
progress?: { total?: number; complete?: number };
|
|
26
|
+
tasks: OpenSpecTask[];
|
|
27
|
+
tasksPath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
35
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeJson(text: string, label: string): unknown {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(text) as unknown;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
throw new Error(`${label} returned malformed JSON: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseOpenSpecList(value: unknown): OpenSpecListResult {
|
|
48
|
+
if (!isRecord(value) || !Array.isArray(value.changes)) {
|
|
49
|
+
throw new Error('OpenSpec list result has no changes array');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const changes: OpenSpecChangeRecord[] = [];
|
|
53
|
+
for (const item of value.changes) {
|
|
54
|
+
if (!isRecord(item) || typeof item.name !== 'string') continue;
|
|
55
|
+
const completedTasks = finiteNumber(item.completedTasks);
|
|
56
|
+
const totalTasks = finiteNumber(item.totalTasks);
|
|
57
|
+
if (completedTasks === undefined || totalTasks === undefined) continue;
|
|
58
|
+
changes.push({
|
|
59
|
+
name: item.name,
|
|
60
|
+
completedTasks: Math.max(0, Math.trunc(completedTasks)),
|
|
61
|
+
totalTasks: Math.max(0, Math.trunc(totalTasks)),
|
|
62
|
+
lastModified: typeof item.lastModified === 'string' ? item.lastModified : '',
|
|
63
|
+
status: typeof item.status === 'string' ? item.status : 'unknown',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const root =
|
|
68
|
+
isRecord(value.root) && typeof value.root.path === 'string'
|
|
69
|
+
? { path: value.root.path }
|
|
70
|
+
: undefined;
|
|
71
|
+
return { changes, root };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cleanTaskTitle(text: string): string {
|
|
75
|
+
return text
|
|
76
|
+
.replace(/\s+/gu, ' ')
|
|
77
|
+
.replace(
|
|
78
|
+
/\s+(?:Acceptance\/evidence|Acceptance\/ evidence|Acceptance|Evidence|Dependencies|Estimate):.*$/iu,
|
|
79
|
+
'',
|
|
80
|
+
)
|
|
81
|
+
.trim()
|
|
82
|
+
.replace(/[.:]\s*$/u, '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseTaskHeadline(
|
|
86
|
+
description: string,
|
|
87
|
+
fallbackId: string,
|
|
88
|
+
done: boolean,
|
|
89
|
+
): OpenSpecTask {
|
|
90
|
+
const normalized = description.replace(/\s+/gu, ' ').trim();
|
|
91
|
+
const idMatch = normalized.match(/^(\d+(?:\.\d+)*)\s+(.+)$/u);
|
|
92
|
+
const id = idMatch?.[1] ?? fallbackId;
|
|
93
|
+
const body = idMatch?.[2] ?? normalized;
|
|
94
|
+
const kindMatch = body.match(/\b(Story|Spike|Lab|Hack)\b/iu);
|
|
95
|
+
const kindText = kindMatch?.[1];
|
|
96
|
+
const kind = kindText
|
|
97
|
+
? ((kindText[0]?.toUpperCase() + kindText.slice(1).toLowerCase()) as
|
|
98
|
+
'Story' | 'Spike' | 'Lab' | 'Hack')
|
|
99
|
+
: undefined;
|
|
100
|
+
const title = cleanTaskTitle(
|
|
101
|
+
kindMatch?.index === undefined ? body : body.slice(0, kindMatch.index),
|
|
102
|
+
);
|
|
103
|
+
return { id, title: title || id, ...(kind ? { kind } : {}), done };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseTaskMarkdown(content: string): OpenSpecTask[] {
|
|
107
|
+
const tasks: OpenSpecTask[] = [];
|
|
108
|
+
const pattern = /^\s*-\s*\[([ xX])\]\s+(.+)$/gmu;
|
|
109
|
+
let match: RegExpExecArray | null;
|
|
110
|
+
let ordinal = 0;
|
|
111
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
112
|
+
ordinal += 1;
|
|
113
|
+
tasks.push(
|
|
114
|
+
parseTaskHeadline(match[2] ?? '', String(ordinal), (match[1] ?? '') !== ' '),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return tasks;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseTaskHierarchy(
|
|
121
|
+
content: string,
|
|
122
|
+
): OpenSpecHierarchyProgress | undefined {
|
|
123
|
+
const tasks: Array<{
|
|
124
|
+
id: string;
|
|
125
|
+
completedSteps: number;
|
|
126
|
+
totalSteps: number;
|
|
127
|
+
validated: boolean;
|
|
128
|
+
}> = [];
|
|
129
|
+
let current: (typeof tasks)[number] | undefined;
|
|
130
|
+
let inHumanValidation = false;
|
|
131
|
+
|
|
132
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
133
|
+
const task = line.match(/^##\s+Task\s+([\w.-]+)\b/iu);
|
|
134
|
+
if (task?.[1]) {
|
|
135
|
+
current = {
|
|
136
|
+
id: task[1],
|
|
137
|
+
completedSteps: 0,
|
|
138
|
+
totalSteps: 0,
|
|
139
|
+
validated: false,
|
|
140
|
+
};
|
|
141
|
+
tasks.push(current);
|
|
142
|
+
inHumanValidation = false;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (/^###\s+Human validation\b/iu.test(line)) {
|
|
146
|
+
inHumanValidation = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (/^#{2,3}\s+/u.test(line)) inHumanValidation = false;
|
|
150
|
+
if (!current) continue;
|
|
151
|
+
|
|
152
|
+
const step = line.match(/^\s*-\s*\[([ xX-])\]\s+Step\s+/iu);
|
|
153
|
+
if (step) {
|
|
154
|
+
current.totalSteps += 1;
|
|
155
|
+
if ((step[1] ?? '').toLowerCase() === 'x') current.completedSteps += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const validation = line.match(/^\s*-\s*\[([ xX-])\]\s+Human validates\b/iu);
|
|
159
|
+
if (inHumanValidation && validation) {
|
|
160
|
+
current.validated = (validation[1] ?? '').toLowerCase() === 'x';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (tasks.length === 0) return undefined;
|
|
165
|
+
return {
|
|
166
|
+
tasks: {
|
|
167
|
+
completed: tasks.filter(
|
|
168
|
+
(task) =>
|
|
169
|
+
task.validated &&
|
|
170
|
+
task.totalSteps > 0 &&
|
|
171
|
+
task.completedSteps === task.totalSteps,
|
|
172
|
+
).length,
|
|
173
|
+
total: tasks.length,
|
|
174
|
+
},
|
|
175
|
+
stepsByTask: Object.fromEntries(
|
|
176
|
+
tasks.map((task) => [
|
|
177
|
+
task.id,
|
|
178
|
+
{ completed: task.completedSteps, total: task.totalSteps },
|
|
179
|
+
]),
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseApplyInstructions(value: unknown): ApplyInstructionsResult {
|
|
185
|
+
if (!isRecord(value) || !Array.isArray(value.tasks)) {
|
|
186
|
+
throw new Error('OpenSpec apply instructions have no tasks array');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const tasks = value.tasks.flatMap((item, index) => {
|
|
190
|
+
if (!isRecord(item) || typeof item.description !== 'string') return [];
|
|
191
|
+
return [
|
|
192
|
+
parseTaskHeadline(
|
|
193
|
+
item.description,
|
|
194
|
+
typeof item.id === 'string' ? item.id : String(index + 1),
|
|
195
|
+
item.done === true,
|
|
196
|
+
),
|
|
197
|
+
];
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let tasksPath: string | undefined;
|
|
201
|
+
if (isRecord(value.contextFiles) && Array.isArray(value.contextFiles.tasks)) {
|
|
202
|
+
tasksPath = value.contextFiles.tasks.find(
|
|
203
|
+
(path): path is string => typeof path === 'string',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const progress = isRecord(value.progress)
|
|
208
|
+
? {
|
|
209
|
+
total: finiteNumber(value.progress.total),
|
|
210
|
+
complete: finiteNumber(value.progress.complete),
|
|
211
|
+
}
|
|
212
|
+
: undefined;
|
|
213
|
+
|
|
214
|
+
return { tasks, tasksPath, progress };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function chooseChange(
|
|
218
|
+
list: OpenSpecListResult,
|
|
219
|
+
configuredChange?: string | null,
|
|
220
|
+
environmentChange?: string,
|
|
221
|
+
): OpenSpecChangeRecord | undefined {
|
|
222
|
+
const requested = configuredChange || environmentChange;
|
|
223
|
+
if (!requested) return undefined;
|
|
224
|
+
|
|
225
|
+
const selected = list.changes.find((change) => change.name === requested);
|
|
226
|
+
if (!selected) throw new Error(`configured OpenSpec change not found: ${requested}`);
|
|
227
|
+
return selected;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runJson(
|
|
231
|
+
runner: CommandRunner,
|
|
232
|
+
args: string[],
|
|
233
|
+
cwd: string,
|
|
234
|
+
label: string,
|
|
235
|
+
): Promise<unknown> {
|
|
236
|
+
const result = await runner('openspec', args, { cwd, timeoutMs: 5_000 });
|
|
237
|
+
if (result.code !== 0 || result.killed) {
|
|
238
|
+
const detail = result.stderr.trim();
|
|
239
|
+
throw new Error(`${label} failed${detail ? `: ${detail}` : ''}`);
|
|
240
|
+
}
|
|
241
|
+
return safeJson(result.stdout, label);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fallbackTitle(changeId: string): string {
|
|
245
|
+
return changeId.replaceAll('-', ' ');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function isIdeaChange(projectRoot: string, changeName: string): Promise<boolean> {
|
|
249
|
+
const changesRoot = resolve(projectRoot, 'openspec', 'changes');
|
|
250
|
+
const changeRoot = resolve(changesRoot, changeName);
|
|
251
|
+
if (changeRoot !== changesRoot && !changeRoot.startsWith(`${changesRoot}${sep}`)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const metadata = await fs.readFile(resolve(changeRoot, '.openspec.yaml'), 'utf8');
|
|
256
|
+
const schema = metadata.match(/^schema:\s*["']?([^\s"'#]+)/mu)?.[1];
|
|
257
|
+
return schema === 'ramona-idea';
|
|
258
|
+
} catch {
|
|
259
|
+
// Generic OpenSpec changes have no Ramona Idea marker and count as Plans.
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function collectOpenSpecOverview(options: {
|
|
265
|
+
cwd: string;
|
|
266
|
+
runner: CommandRunner;
|
|
267
|
+
}): Promise<OpenSpecOverview> {
|
|
268
|
+
const list = parseOpenSpecList(
|
|
269
|
+
await runJson(options.runner, ['list', '--json'], options.cwd, 'openspec list'),
|
|
270
|
+
);
|
|
271
|
+
const projectRoot = list.root?.path ?? options.cwd;
|
|
272
|
+
const ideaFlags = await Promise.all(
|
|
273
|
+
list.changes.map((change) => isIdeaChange(projectRoot, change.name)),
|
|
274
|
+
);
|
|
275
|
+
const plans = list.changes.filter((_change, index) => !ideaFlags[index]);
|
|
276
|
+
const completedPlans = plans.filter(
|
|
277
|
+
(change) =>
|
|
278
|
+
change.status === 'complete' ||
|
|
279
|
+
(change.totalTasks > 0 && change.completedTasks >= change.totalTasks),
|
|
280
|
+
).length;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
projectRoot,
|
|
284
|
+
actionableChanges: plans.filter(
|
|
285
|
+
(change) => change.totalTasks > 0 && change.completedTasks < change.totalTasks,
|
|
286
|
+
).length,
|
|
287
|
+
totalChanges: list.changes.length,
|
|
288
|
+
completedPlans,
|
|
289
|
+
totalPlans: plans.length,
|
|
290
|
+
completedTasks: plans.reduce((total, change) => total + change.completedTasks, 0),
|
|
291
|
+
totalTasks: plans.reduce((total, change) => total + change.totalTasks, 0),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function collectOpenSpec(options: {
|
|
296
|
+
cwd: string;
|
|
297
|
+
runner: CommandRunner;
|
|
298
|
+
configuredChange?: string | null;
|
|
299
|
+
environmentChange?: string;
|
|
300
|
+
now?: number;
|
|
301
|
+
}): Promise<OpenSpecState | undefined> {
|
|
302
|
+
const now = options.now ?? Date.now();
|
|
303
|
+
const list = parseOpenSpecList(
|
|
304
|
+
await runJson(options.runner, ['list', '--json'], options.cwd, 'openspec list'),
|
|
305
|
+
);
|
|
306
|
+
const change = chooseChange(
|
|
307
|
+
list,
|
|
308
|
+
options.configuredChange,
|
|
309
|
+
options.environmentChange,
|
|
310
|
+
);
|
|
311
|
+
if (!change) return undefined;
|
|
312
|
+
|
|
313
|
+
const projectRoot = list.root?.path ?? options.cwd;
|
|
314
|
+
let title = fallbackTitle(change.name);
|
|
315
|
+
try {
|
|
316
|
+
const shown = await runJson(
|
|
317
|
+
options.runner,
|
|
318
|
+
['show', change.name, '--json'],
|
|
319
|
+
projectRoot,
|
|
320
|
+
'openspec show',
|
|
321
|
+
);
|
|
322
|
+
if (isRecord(shown) && typeof shown.title === 'string' && shown.title.trim()) {
|
|
323
|
+
title = shown.title.trim();
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
// The list result is still useful when an optional title lookup fails.
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let tasks: OpenSpecTask[] = [];
|
|
330
|
+
let tasksPath: string | undefined;
|
|
331
|
+
let instructionProgress: ApplyInstructionsResult['progress'];
|
|
332
|
+
let hierarchy: OpenSpecHierarchyProgress | undefined;
|
|
333
|
+
try {
|
|
334
|
+
const instructions = parseApplyInstructions(
|
|
335
|
+
await runJson(
|
|
336
|
+
options.runner,
|
|
337
|
+
['instructions', 'apply', '--change', change.name, '--json'],
|
|
338
|
+
projectRoot,
|
|
339
|
+
'openspec instructions apply',
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
tasks = instructions.tasks;
|
|
343
|
+
tasksPath = instructions.tasksPath;
|
|
344
|
+
instructionProgress = instructions.progress;
|
|
345
|
+
|
|
346
|
+
if (tasksPath) {
|
|
347
|
+
try {
|
|
348
|
+
const markdown = await fs.readFile(tasksPath, 'utf8');
|
|
349
|
+
const markdownTasks = parseTaskMarkdown(markdown);
|
|
350
|
+
if (markdownTasks.length > 0) tasks = markdownTasks;
|
|
351
|
+
hierarchy = parseTaskHierarchy(markdown);
|
|
352
|
+
} catch {
|
|
353
|
+
// Instructions still provide a safe task fallback when the file moves.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// Progress from `list` remains valid even if apply instructions are unavailable.
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const completedTasks = Math.max(
|
|
361
|
+
0,
|
|
362
|
+
Math.trunc(instructionProgress?.complete ?? change.completedTasks),
|
|
363
|
+
);
|
|
364
|
+
const totalTasks = Math.max(
|
|
365
|
+
0,
|
|
366
|
+
Math.trunc(instructionProgress?.total ?? change.totalTasks),
|
|
367
|
+
);
|
|
368
|
+
const complete =
|
|
369
|
+
change.status === 'complete' || (totalTasks > 0 && completedTasks >= totalTasks);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
projectRoot,
|
|
373
|
+
changeId: change.name,
|
|
374
|
+
title,
|
|
375
|
+
phase: complete ? 'complete' : change.status,
|
|
376
|
+
completedTasks,
|
|
377
|
+
totalTasks,
|
|
378
|
+
progressPercent:
|
|
379
|
+
totalTasks > 0
|
|
380
|
+
? Math.max(0, Math.min(100, Math.round((completedTasks / totalTasks) * 100)))
|
|
381
|
+
: 0,
|
|
382
|
+
pendingTasks: tasks.filter((task) => !task.done),
|
|
383
|
+
allTasks: tasks,
|
|
384
|
+
...(tasksPath ? { tasksPath } : {}),
|
|
385
|
+
...(hierarchy ? { hierarchy } : {}),
|
|
386
|
+
meta: {
|
|
387
|
+
source: 'openspec-cli',
|
|
388
|
+
refreshedAt: now,
|
|
389
|
+
attemptedAt: now,
|
|
390
|
+
updatedAt: Date.parse(change.lastModified) || now,
|
|
391
|
+
stale: false,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|