@pellux/goodvibes-tui 0.19.84 → 0.19.86
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/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5009 -290
- package/package.json +2 -2
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/work-plan-runtime.ts +169 -0
- package/src/input/commands.ts +2 -0
- package/src/main.ts +4 -13
- package/src/panels/builtin/agent.ts +11 -0
- package/src/panels/builtin/shared.ts +8 -0
- package/src/panels/work-plan-panel.ts +175 -0
- package/src/renderer/process-modal.ts +383 -26
- package/src/renderer/process-summary.ts +67 -0
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/runtime/services.ts +8 -0
- package/src/runtime/ui-services.ts +2 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +373 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const WORK_PLAN_STATUSES = [
|
|
6
|
+
'pending',
|
|
7
|
+
'in_progress',
|
|
8
|
+
'blocked',
|
|
9
|
+
'done',
|
|
10
|
+
'failed',
|
|
11
|
+
'cancelled',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type WorkPlanItemStatus = typeof WORK_PLAN_STATUSES[number];
|
|
15
|
+
|
|
16
|
+
export interface WorkPlanLinkTargets {
|
|
17
|
+
readonly agentId?: string;
|
|
18
|
+
readonly wrfcId?: string;
|
|
19
|
+
readonly taskId?: string;
|
|
20
|
+
readonly sessionId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorkPlanItem {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly status: WorkPlanItemStatus;
|
|
27
|
+
readonly owner?: string;
|
|
28
|
+
readonly source?: string;
|
|
29
|
+
readonly notes?: string;
|
|
30
|
+
readonly linked?: WorkPlanLinkTargets;
|
|
31
|
+
readonly createdAt: number;
|
|
32
|
+
readonly updatedAt: number;
|
|
33
|
+
readonly completedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkPlan {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly projectId: string;
|
|
39
|
+
readonly projectRoot: string;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly items: readonly WorkPlanItem[];
|
|
42
|
+
readonly activeItemId?: string;
|
|
43
|
+
readonly source?: string;
|
|
44
|
+
readonly createdAt: number;
|
|
45
|
+
readonly updatedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkPlanStoreOptions {
|
|
49
|
+
readonly homeDirectory: string;
|
|
50
|
+
readonly projectId: string;
|
|
51
|
+
readonly projectRoot: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AddWorkPlanItemOptions {
|
|
55
|
+
readonly status?: WorkPlanItemStatus;
|
|
56
|
+
readonly owner?: string;
|
|
57
|
+
readonly source?: string;
|
|
58
|
+
readonly notes?: string;
|
|
59
|
+
readonly linked?: WorkPlanLinkTargets;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UpdateWorkPlanItemPatch {
|
|
63
|
+
readonly title?: string;
|
|
64
|
+
readonly status?: WorkPlanItemStatus;
|
|
65
|
+
readonly owner?: string | null;
|
|
66
|
+
readonly source?: string | null;
|
|
67
|
+
readonly notes?: string | null;
|
|
68
|
+
readonly linked?: WorkPlanLinkTargets | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nowMs(): number {
|
|
72
|
+
return Date.now();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readString(value: unknown): string | undefined {
|
|
80
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isWorkPlanStatus(value: unknown): value is WorkPlanItemStatus {
|
|
84
|
+
return typeof value === 'string' && WORK_PLAN_STATUSES.includes(value as WorkPlanItemStatus);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeFileId(projectId: string, projectRoot: string): string {
|
|
88
|
+
const normalized = projectId.trim() || 'project';
|
|
89
|
+
const safe = normalized.replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
90
|
+
if (safe.length > 0 && safe.length <= 96) return safe;
|
|
91
|
+
const hash = createHash('sha256').update(`${projectId}\0${projectRoot}`).digest('hex').slice(0, 16);
|
|
92
|
+
return `${safe.slice(0, 80) || 'project'}-${hash}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createPlanId(projectId: string, projectRoot: string): string {
|
|
96
|
+
const hash = createHash('sha256').update(`${projectId}\0${projectRoot}`).digest('hex').slice(0, 12);
|
|
97
|
+
return `wp-${hash}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createItemId(): string {
|
|
101
|
+
return `wpi-${randomUUID().slice(0, 8)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLinked(value: unknown): WorkPlanLinkTargets | undefined {
|
|
105
|
+
if (!isObject(value)) return undefined;
|
|
106
|
+
const agentId = readString(value.agentId);
|
|
107
|
+
const wrfcId = readString(value.wrfcId);
|
|
108
|
+
const taskId = readString(value.taskId);
|
|
109
|
+
const sessionId = readString(value.sessionId);
|
|
110
|
+
const linked: WorkPlanLinkTargets = {
|
|
111
|
+
...(agentId ? { agentId } : {}),
|
|
112
|
+
...(wrfcId ? { wrfcId } : {}),
|
|
113
|
+
...(taskId ? { taskId } : {}),
|
|
114
|
+
...(sessionId ? { sessionId } : {}),
|
|
115
|
+
};
|
|
116
|
+
return Object.keys(linked).length > 0 ? linked : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeItem(value: unknown, fallbackCreatedAt: number): WorkPlanItem | null {
|
|
120
|
+
if (!isObject(value)) return null;
|
|
121
|
+
const title = readString(value.title);
|
|
122
|
+
if (!title) return null;
|
|
123
|
+
const status = isWorkPlanStatus(value.status) ? value.status : 'pending';
|
|
124
|
+
const createdAt = typeof value.createdAt === 'number' ? value.createdAt : fallbackCreatedAt;
|
|
125
|
+
const updatedAt = typeof value.updatedAt === 'number' ? value.updatedAt : createdAt;
|
|
126
|
+
const completedAt = typeof value.completedAt === 'number' ? value.completedAt : undefined;
|
|
127
|
+
const owner = readString(value.owner);
|
|
128
|
+
const source = readString(value.source);
|
|
129
|
+
const notes = readString(value.notes);
|
|
130
|
+
const linked = normalizeLinked(value.linked);
|
|
131
|
+
return {
|
|
132
|
+
id: readString(value.id) ?? createItemId(),
|
|
133
|
+
title,
|
|
134
|
+
status,
|
|
135
|
+
...(owner ? { owner } : {}),
|
|
136
|
+
...(source ? { source } : {}),
|
|
137
|
+
...(notes ? { notes } : {}),
|
|
138
|
+
...(linked ? { linked } : {}),
|
|
139
|
+
createdAt,
|
|
140
|
+
updatedAt,
|
|
141
|
+
...(completedAt !== undefined ? { completedAt } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatStatus(status: WorkPlanItemStatus): string {
|
|
146
|
+
return status.replace(/_/g, ' ');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function nextWorkPlanStatus(status: WorkPlanItemStatus): WorkPlanItemStatus {
|
|
150
|
+
switch (status) {
|
|
151
|
+
case 'pending':
|
|
152
|
+
return 'in_progress';
|
|
153
|
+
case 'in_progress':
|
|
154
|
+
return 'done';
|
|
155
|
+
case 'done':
|
|
156
|
+
return 'pending';
|
|
157
|
+
case 'blocked':
|
|
158
|
+
case 'failed':
|
|
159
|
+
case 'cancelled':
|
|
160
|
+
return 'pending';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class WorkPlanStore {
|
|
165
|
+
readonly filePath: string;
|
|
166
|
+
|
|
167
|
+
constructor(private readonly options: WorkPlanStoreOptions) {
|
|
168
|
+
const fileName = `${safeFileId(options.projectId, options.projectRoot)}.json`;
|
|
169
|
+
this.filePath = join(options.homeDirectory, '.goodvibes', 'tui', 'work-plans', fileName);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getActivePlan(): WorkPlan {
|
|
173
|
+
return this.readPlan();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
listItems(): readonly WorkPlanItem[] {
|
|
177
|
+
return this.getActivePlan().items;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
addItem(title: string, options: AddWorkPlanItemOptions = {}): WorkPlanItem {
|
|
181
|
+
const normalizedTitle = title.trim();
|
|
182
|
+
if (!normalizedTitle) throw new Error('Work plan item title is required.');
|
|
183
|
+
const plan = this.readPlan();
|
|
184
|
+
const time = nowMs();
|
|
185
|
+
const item: WorkPlanItem = {
|
|
186
|
+
id: createItemId(),
|
|
187
|
+
title: normalizedTitle,
|
|
188
|
+
status: options.status ?? 'pending',
|
|
189
|
+
...(options.owner ? { owner: options.owner } : {}),
|
|
190
|
+
...(options.source ? { source: options.source } : {}),
|
|
191
|
+
...(options.notes ? { notes: options.notes } : {}),
|
|
192
|
+
...(options.linked ? { linked: options.linked } : {}),
|
|
193
|
+
createdAt: time,
|
|
194
|
+
updatedAt: time,
|
|
195
|
+
...(options.status === 'done' ? { completedAt: time } : {}),
|
|
196
|
+
};
|
|
197
|
+
this.writePlan({
|
|
198
|
+
...plan,
|
|
199
|
+
items: [...plan.items, item],
|
|
200
|
+
activeItemId: item.id,
|
|
201
|
+
updatedAt: time,
|
|
202
|
+
});
|
|
203
|
+
return item;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
updateItem(idOrPrefix: string, patch: UpdateWorkPlanItemPatch): WorkPlanItem {
|
|
207
|
+
const plan = this.readPlan();
|
|
208
|
+
const item = this.resolveItem(plan, idOrPrefix);
|
|
209
|
+
const time = nowMs();
|
|
210
|
+
const nextStatus = patch.status ?? item.status;
|
|
211
|
+
const next: WorkPlanItem = this.pruneItem({
|
|
212
|
+
...item,
|
|
213
|
+
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
|
|
214
|
+
status: nextStatus,
|
|
215
|
+
...(patch.owner !== undefined ? { owner: patch.owner || undefined } : {}),
|
|
216
|
+
...(patch.source !== undefined ? { source: patch.source || undefined } : {}),
|
|
217
|
+
...(patch.notes !== undefined ? { notes: patch.notes || undefined } : {}),
|
|
218
|
+
...(patch.linked !== undefined ? { linked: patch.linked || undefined } : {}),
|
|
219
|
+
updatedAt: time,
|
|
220
|
+
...(nextStatus === 'done' ? { completedAt: item.completedAt ?? time } : { completedAt: undefined }),
|
|
221
|
+
});
|
|
222
|
+
if (!next.title) throw new Error('Work plan item title is required.');
|
|
223
|
+
this.writePlan({
|
|
224
|
+
...plan,
|
|
225
|
+
items: plan.items.map((candidate) => candidate.id === item.id ? next : candidate),
|
|
226
|
+
activeItemId: next.id,
|
|
227
|
+
updatedAt: time,
|
|
228
|
+
});
|
|
229
|
+
return next;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
setItemStatus(idOrPrefix: string, status: WorkPlanItemStatus): WorkPlanItem {
|
|
233
|
+
return this.updateItem(idOrPrefix, { status });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
cycleItemStatus(idOrPrefix: string): WorkPlanItem {
|
|
237
|
+
const item = this.resolveItem(this.readPlan(), idOrPrefix);
|
|
238
|
+
return this.setItemStatus(item.id, nextWorkPlanStatus(item.status));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
removeItem(idOrPrefix: string): WorkPlanItem {
|
|
242
|
+
const plan = this.readPlan();
|
|
243
|
+
const item = this.resolveItem(plan, idOrPrefix);
|
|
244
|
+
const time = nowMs();
|
|
245
|
+
const remaining = plan.items.filter((candidate) => candidate.id !== item.id);
|
|
246
|
+
this.writePlan({
|
|
247
|
+
...plan,
|
|
248
|
+
items: remaining,
|
|
249
|
+
activeItemId: remaining[0]?.id,
|
|
250
|
+
updatedAt: time,
|
|
251
|
+
});
|
|
252
|
+
return item;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
clearCompleted(): number {
|
|
256
|
+
const plan = this.readPlan();
|
|
257
|
+
const remaining = plan.items.filter((item) => item.status !== 'done' && item.status !== 'cancelled');
|
|
258
|
+
const removed = plan.items.length - remaining.length;
|
|
259
|
+
if (removed === 0) return 0;
|
|
260
|
+
this.writePlan({
|
|
261
|
+
...plan,
|
|
262
|
+
items: remaining,
|
|
263
|
+
activeItemId: remaining[0]?.id,
|
|
264
|
+
updatedAt: nowMs(),
|
|
265
|
+
});
|
|
266
|
+
return removed;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
toMarkdown(plan: WorkPlan = this.readPlan()): string {
|
|
270
|
+
const lines = [
|
|
271
|
+
`# ${plan.title}`,
|
|
272
|
+
'',
|
|
273
|
+
`Project: ${plan.projectRoot}`,
|
|
274
|
+
`Project ID: ${plan.projectId}`,
|
|
275
|
+
`Updated: ${new Date(plan.updatedAt).toISOString()}`,
|
|
276
|
+
'',
|
|
277
|
+
];
|
|
278
|
+
if (plan.items.length === 0) {
|
|
279
|
+
lines.push('No work plan items recorded.');
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
282
|
+
for (const item of plan.items) {
|
|
283
|
+
const marker = item.status === 'done' ? 'x' : ' ';
|
|
284
|
+
const suffix = item.status === 'pending' ? '' : ` (${formatStatus(item.status)})`;
|
|
285
|
+
lines.push(`- [${marker}] ${item.title}${suffix}`);
|
|
286
|
+
if (item.owner) lines.push(` - Owner: ${item.owner}`);
|
|
287
|
+
if (item.source) lines.push(` - Source: ${item.source}`);
|
|
288
|
+
if (item.notes) lines.push(` - Notes: ${item.notes}`);
|
|
289
|
+
}
|
|
290
|
+
return lines.join('\n');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private readPlan(): WorkPlan {
|
|
294
|
+
if (!existsSync(this.filePath)) return this.createEmptyPlan();
|
|
295
|
+
const raw = readFileSync(this.filePath, 'utf8');
|
|
296
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
297
|
+
if (!isObject(parsed)) return this.createEmptyPlan();
|
|
298
|
+
const time = nowMs();
|
|
299
|
+
const createdAt = typeof parsed.createdAt === 'number' ? parsed.createdAt : time;
|
|
300
|
+
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : createdAt;
|
|
301
|
+
const items = Array.isArray(parsed.items)
|
|
302
|
+
? parsed.items.map((item) => normalizeItem(item, createdAt)).filter((item): item is WorkPlanItem => item !== null)
|
|
303
|
+
: [];
|
|
304
|
+
const activeItemId = readString(parsed.activeItemId);
|
|
305
|
+
const source = readString(parsed.source);
|
|
306
|
+
return {
|
|
307
|
+
id: readString(parsed.id) ?? createPlanId(this.options.projectId, this.options.projectRoot),
|
|
308
|
+
projectId: readString(parsed.projectId) ?? this.options.projectId,
|
|
309
|
+
projectRoot: readString(parsed.projectRoot) ?? this.options.projectRoot,
|
|
310
|
+
title: readString(parsed.title) ?? 'Work Plan',
|
|
311
|
+
items,
|
|
312
|
+
...(activeItemId && items.some((item) => item.id === activeItemId) ? { activeItemId } : {}),
|
|
313
|
+
...(source ? { source } : {}),
|
|
314
|
+
createdAt,
|
|
315
|
+
updatedAt,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private createEmptyPlan(): WorkPlan {
|
|
320
|
+
const time = nowMs();
|
|
321
|
+
return {
|
|
322
|
+
id: createPlanId(this.options.projectId, this.options.projectRoot),
|
|
323
|
+
projectId: this.options.projectId,
|
|
324
|
+
projectRoot: this.options.projectRoot,
|
|
325
|
+
title: 'Work Plan',
|
|
326
|
+
items: [],
|
|
327
|
+
source: 'tui',
|
|
328
|
+
createdAt: time,
|
|
329
|
+
updatedAt: time,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private writePlan(plan: WorkPlan): void {
|
|
334
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
335
|
+
const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
336
|
+
writeFileSync(tmp, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
|
|
337
|
+
renameSync(tmp, this.filePath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private resolveItem(plan: WorkPlan, idOrPrefix: string): WorkPlanItem {
|
|
341
|
+
const needle = idOrPrefix.trim();
|
|
342
|
+
if (!needle) throw new Error('Work plan item id is required.');
|
|
343
|
+
const exact = plan.items.find((item) => item.id === needle);
|
|
344
|
+
if (exact) return exact;
|
|
345
|
+
const matches = plan.items.filter((item) => item.id.startsWith(needle));
|
|
346
|
+
if (matches.length === 1) return matches[0]!;
|
|
347
|
+
if (matches.length > 1) {
|
|
348
|
+
throw new Error(`Work plan item id "${needle}" is ambiguous: ${matches.map((item) => item.id).join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`Work plan item not found: ${needle}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private pruneItem(item: WorkPlanItem & {
|
|
354
|
+
owner?: string | undefined;
|
|
355
|
+
source?: string | undefined;
|
|
356
|
+
notes?: string | undefined;
|
|
357
|
+
linked?: WorkPlanLinkTargets | undefined;
|
|
358
|
+
completedAt?: number | undefined;
|
|
359
|
+
}): WorkPlanItem {
|
|
360
|
+
return {
|
|
361
|
+
id: item.id,
|
|
362
|
+
title: item.title,
|
|
363
|
+
status: item.status,
|
|
364
|
+
...(item.owner ? { owner: item.owner } : {}),
|
|
365
|
+
...(item.source ? { source: item.source } : {}),
|
|
366
|
+
...(item.notes ? { notes: item.notes } : {}),
|
|
367
|
+
...(item.linked ? { linked: item.linked } : {}),
|
|
368
|
+
createdAt: item.createdAt,
|
|
369
|
+
updatedAt: item.updatedAt,
|
|
370
|
+
...(item.completedAt !== undefined ? { completedAt: item.completedAt } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|