@pellux/goodvibes-tui 0.19.84 → 0.19.85
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 +5 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- 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/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/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
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.85",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
98
98
|
"@ast-grep/napi": "^0.42.0",
|
|
99
99
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
100
|
-
"@pellux/goodvibes-sdk": "0.33.
|
|
100
|
+
"@pellux/goodvibes-sdk": "0.33.20",
|
|
101
101
|
"bash-language-server": "^5.6.0",
|
|
102
102
|
"fuse.js": "^7.1.0",
|
|
103
103
|
"graphql": "^16.13.2",
|
|
@@ -144,6 +144,7 @@ export interface CommandWorkspaceUiServices {
|
|
|
144
144
|
bookmarkManager?: import('@pellux/goodvibes-sdk/platform/bookmarks').BookmarkManager;
|
|
145
145
|
projectPlanningService?: import('@pellux/goodvibes-sdk/platform/knowledge').ProjectPlanningService;
|
|
146
146
|
projectPlanningProjectId?: string;
|
|
147
|
+
workPlanStore?: import('../work-plans/work-plan-store.ts').WorkPlanStore;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
export interface CommandWorkspaceServices
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
+
import type { WorkPlanItemStatus, WorkPlanStore } from '../../work-plans/work-plan-store.ts';
|
|
3
|
+
import { WORK_PLAN_STATUSES } from '../../work-plans/work-plan-store.ts';
|
|
4
|
+
import { requirePanelManager } from './runtime-services.ts';
|
|
5
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
|
+
|
|
7
|
+
const STATUS_COMMANDS: Record<string, WorkPlanItemStatus> = {
|
|
8
|
+
pending: 'pending',
|
|
9
|
+
todo: 'pending',
|
|
10
|
+
start: 'in_progress',
|
|
11
|
+
active: 'in_progress',
|
|
12
|
+
progress: 'in_progress',
|
|
13
|
+
block: 'blocked',
|
|
14
|
+
blocked: 'blocked',
|
|
15
|
+
done: 'done',
|
|
16
|
+
complete: 'done',
|
|
17
|
+
fail: 'failed',
|
|
18
|
+
failed: 'failed',
|
|
19
|
+
cancel: 'cancelled',
|
|
20
|
+
cancelled: 'cancelled',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getStore(ctx: import('../command-registry.ts').CommandContext): WorkPlanStore | null {
|
|
24
|
+
return ctx.workspace.workPlanStore ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openPanel(ctx: import('../command-registry.ts').CommandContext): void {
|
|
28
|
+
if (ctx.showPanel) {
|
|
29
|
+
ctx.showPanel('work-plan');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const panelManager = requirePanelManager(ctx);
|
|
33
|
+
panelManager.open('work-plan');
|
|
34
|
+
panelManager.show();
|
|
35
|
+
ctx.renderRequest();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatList(store: WorkPlanStore): string {
|
|
39
|
+
const items = store.listItems();
|
|
40
|
+
if (items.length === 0) return 'Work plan is empty. Add one with /workplan add <title>.';
|
|
41
|
+
return [
|
|
42
|
+
`Work Plan (${items.length})`,
|
|
43
|
+
...items.map((item) => {
|
|
44
|
+
const owner = item.owner ? ` @${item.owner}` : '';
|
|
45
|
+
return ` ${item.id} ${item.status.padEnd(11)} ${item.title}${owner}`;
|
|
46
|
+
}),
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseAddArgs(args: string[]): { title: string; owner?: string; source?: string; notes?: string } {
|
|
51
|
+
const titleParts: string[] = [];
|
|
52
|
+
let owner: string | undefined;
|
|
53
|
+
let source: string | undefined;
|
|
54
|
+
let notes: string | undefined;
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
const part = args[i] ?? '';
|
|
57
|
+
if (part === '--owner' && args[i + 1]) {
|
|
58
|
+
owner = args[++i];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (part === '--source' && args[i + 1]) {
|
|
62
|
+
source = args[++i];
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (part === '--notes' && args[i + 1]) {
|
|
66
|
+
notes = args.slice(i + 1).join(' ').trim();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
titleParts.push(part);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
title: titleParts.join(' ').trim(),
|
|
73
|
+
...(owner ? { owner } : {}),
|
|
74
|
+
...(source ? { source } : {}),
|
|
75
|
+
...(notes ? { notes } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function registerWorkPlanRuntimeCommands(registry: CommandRegistry): void {
|
|
80
|
+
registry.register({
|
|
81
|
+
name: 'workplan',
|
|
82
|
+
aliases: ['wp', 'todo'],
|
|
83
|
+
description: 'Track a persistent workspace-scoped work plan',
|
|
84
|
+
usage: '[panel|list|show|add <title> [--owner name] [--source label] [--notes text]|done <id>|start <id>|block <id>|fail <id>|cancel <id>|pending <id>|remove <id>|clear-done]',
|
|
85
|
+
argsHint: '[panel|add|list|done]',
|
|
86
|
+
handler(args, ctx) {
|
|
87
|
+
const store = getStore(ctx);
|
|
88
|
+
if (!store) {
|
|
89
|
+
ctx.print('Work plan store is not available in this runtime.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const subcommand = (args[0] ?? 'panel').toLowerCase();
|
|
93
|
+
try {
|
|
94
|
+
if (subcommand === 'panel' || subcommand === 'open') {
|
|
95
|
+
openPanel(ctx);
|
|
96
|
+
ctx.print('Opened work plan panel.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (subcommand === 'list') {
|
|
100
|
+
ctx.print(formatList(store));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (subcommand === 'show' || subcommand === 'markdown') {
|
|
104
|
+
ctx.print(store.toMarkdown());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (subcommand === 'add') {
|
|
108
|
+
const parsed = parseAddArgs(args.slice(1));
|
|
109
|
+
if (!parsed.title) {
|
|
110
|
+
ctx.print('Usage: /workplan add <title> [--owner name] [--source label] [--notes text]');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const addOptions = {
|
|
114
|
+
...(parsed.owner ? { owner: parsed.owner } : {}),
|
|
115
|
+
source: parsed.source ?? 'manual',
|
|
116
|
+
...(parsed.notes ? { notes: parsed.notes } : {}),
|
|
117
|
+
};
|
|
118
|
+
const item = store.addItem(parsed.title, addOptions);
|
|
119
|
+
openPanel(ctx);
|
|
120
|
+
ctx.print(`Added work plan item ${item.id}.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (subcommand === 'remove' || subcommand === 'delete' || subcommand === 'rm') {
|
|
124
|
+
const id = args[1];
|
|
125
|
+
if (!id) {
|
|
126
|
+
ctx.print(`Usage: /workplan ${subcommand} <id>`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const item = store.removeItem(id);
|
|
130
|
+
ctx.print(`Removed work plan item ${item.id}: ${item.title}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (subcommand === 'clear-done' || subcommand === 'clear-completed') {
|
|
134
|
+
const count = store.clearCompleted();
|
|
135
|
+
ctx.print(`Cleared ${count} completed/cancelled work plan item${count === 1 ? '' : 's'}.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (subcommand === 'cycle' || subcommand === 'toggle') {
|
|
139
|
+
const id = args[1];
|
|
140
|
+
if (!id) {
|
|
141
|
+
ctx.print(`Usage: /workplan ${subcommand} <id>`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const item = store.cycleItemStatus(id);
|
|
145
|
+
ctx.print(`Updated ${item.id}: ${item.status}.`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const status = STATUS_COMMANDS[subcommand];
|
|
149
|
+
if (status) {
|
|
150
|
+
const id = args[1];
|
|
151
|
+
if (!id) {
|
|
152
|
+
ctx.print(`Usage: /workplan ${subcommand} <id>`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const item = store.setItemStatus(id, status);
|
|
156
|
+
ctx.print(`Updated ${item.id}: ${item.status}.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (WORK_PLAN_STATUSES.includes(subcommand as WorkPlanItemStatus)) {
|
|
160
|
+
ctx.print(`Usage: /workplan ${subcommand} <id>`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ctx.print(`Unknown workplan subcommand: ${subcommand}`);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
ctx.print(summarizeError(error));
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
package/src/input/commands.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { registerQrcodeRuntimeCommands } from './commands/qrcode-runtime.ts';
|
|
|
55
55
|
import { registerOnboardingRuntimeCommands } from './commands/onboarding-runtime.ts';
|
|
56
56
|
import { registerTtsRuntimeCommands } from './commands/tts-runtime.ts';
|
|
57
57
|
import { registerCloudflareRuntimeCommands } from './commands/cloudflare-runtime.ts';
|
|
58
|
+
import { registerWorkPlanRuntimeCommands } from './commands/work-plan-runtime.ts';
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* registerBuiltinCommands - Register all built-in slash commands into the registry.
|
|
@@ -104,6 +105,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
|
|
104
105
|
registerOnboardingRuntimeCommands(registry);
|
|
105
106
|
registerTtsRuntimeCommands(registry);
|
|
106
107
|
registerCloudflareRuntimeCommands(registry);
|
|
108
|
+
registerWorkPlanRuntimeCommands(registry);
|
|
107
109
|
registerLocalRuntimeCommands(registry);
|
|
108
110
|
registerSessionWorkflowCommands(registry);
|
|
109
111
|
registerDiscoveryRuntimeCommands(registry);
|
|
@@ -6,6 +6,7 @@ import { ToolInspectorPanel } from '../tool-inspector-panel.ts';
|
|
|
6
6
|
import { WrfcPanel } from '../wrfc-panel.ts';
|
|
7
7
|
import { SchedulePanel } from '../schedule-panel.ts';
|
|
8
8
|
import { ProjectPlanningPanel } from '../project-planning-panel.ts';
|
|
9
|
+
import { WorkPlanPanel } from '../work-plan-panel.ts';
|
|
9
10
|
import type { ResolvedBuiltinPanelDeps } from './shared.ts';
|
|
10
11
|
import { requireAutomationManager, requireUiServices } from './shared.ts';
|
|
11
12
|
|
|
@@ -79,6 +80,16 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
|
|
|
79
80
|
},
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
manager.registerType({
|
|
84
|
+
id: 'work-plan',
|
|
85
|
+
name: 'Work Plan',
|
|
86
|
+
icon: 'L',
|
|
87
|
+
category: 'agent',
|
|
88
|
+
description: 'Persistent workspace checklist for multi-step work and cross-session task tracking',
|
|
89
|
+
preload: true,
|
|
90
|
+
factory: () => new WorkPlanPanel(deps.workPlanStore),
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
manager.registerType({
|
|
83
94
|
id: 'project-planning',
|
|
84
95
|
name: 'Planning',
|
|
@@ -96,6 +96,8 @@ export interface BuiltinPanelDeps {
|
|
|
96
96
|
projectPlanningService?: ProjectPlanningService;
|
|
97
97
|
/** Stable workspace project id for project:<projectId> planning spaces. */
|
|
98
98
|
projectPlanningProjectId?: string;
|
|
99
|
+
/** TUI-owned persistent work plan store. */
|
|
100
|
+
workPlanStore?: import('../../work-plans/work-plan-store.ts').WorkPlanStore;
|
|
99
101
|
/** Shared system-messages panel instance attached from boot so low-priority chatter stays out of conversation. */
|
|
100
102
|
systemMessagesPanel?: import('../system-messages-panel.ts').SystemMessagesPanel;
|
|
101
103
|
/** Explicit UI-facing runtime services for agent/process/WRFC/remote panels and modals. */
|
|
@@ -124,6 +126,7 @@ export type ResolvedBuiltinPanelDeps = Omit<
|
|
|
124
126
|
| 'adaptivePlanner'
|
|
125
127
|
| 'projectPlanningService'
|
|
126
128
|
| 'projectPlanningProjectId'
|
|
129
|
+
| 'workPlanStore'
|
|
127
130
|
| 'policyRuntimeState'
|
|
128
131
|
| 'systemMessagesPanel'
|
|
129
132
|
> & {
|
|
@@ -137,6 +140,7 @@ export type ResolvedBuiltinPanelDeps = Omit<
|
|
|
137
140
|
readonly adaptivePlanner: AdaptivePlanner;
|
|
138
141
|
readonly projectPlanningService: ProjectPlanningService;
|
|
139
142
|
readonly projectPlanningProjectId: string;
|
|
143
|
+
readonly workPlanStore: import('../../work-plans/work-plan-store.ts').WorkPlanStore;
|
|
140
144
|
readonly policyRuntimeState: PolicyRuntimeState;
|
|
141
145
|
readonly systemMessagesPanel: import('../system-messages-panel.ts').SystemMessagesPanel;
|
|
142
146
|
};
|
|
@@ -198,6 +202,10 @@ export function resolveBuiltinPanelDeps(deps: BuiltinPanelDeps): ResolvedBuiltin
|
|
|
198
202
|
uiServices.planning.projectPlanningProjectId,
|
|
199
203
|
'Project planning project id must be wired at bootstrap for builtin panels.',
|
|
200
204
|
),
|
|
205
|
+
workPlanStore: requireBuiltinPanelDep(
|
|
206
|
+
uiServices.planning.workPlanStore,
|
|
207
|
+
'Work plan store must be wired at bootstrap for builtin panels.',
|
|
208
|
+
),
|
|
201
209
|
policyRuntimeState: requireBuiltinPanelDep(
|
|
202
210
|
uiServices.platform.policyRuntimeState,
|
|
203
211
|
'Policy runtime state must be wired at bootstrap for builtin panels.',
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Line } from '../types/grid.ts';
|
|
2
|
+
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
3
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
|
+
import type { WorkPlanItem, WorkPlanItemStatus, WorkPlanStore } from '../work-plans/work-plan-store.ts';
|
|
5
|
+
|
|
6
|
+
const STATUS_LABEL: Record<WorkPlanItemStatus, string> = {
|
|
7
|
+
pending: '[ ]',
|
|
8
|
+
in_progress: '[>]',
|
|
9
|
+
blocked: '[!]',
|
|
10
|
+
done: '[x]',
|
|
11
|
+
failed: '[x]',
|
|
12
|
+
cancelled: '[-]',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const STATUS_COLOR: Record<WorkPlanItemStatus, string> = {
|
|
16
|
+
pending: '#94a3b8',
|
|
17
|
+
in_progress: '#38bdf8',
|
|
18
|
+
blocked: '#f59e0b',
|
|
19
|
+
done: '#22c55e',
|
|
20
|
+
failed: '#ef4444',
|
|
21
|
+
cancelled: '#64748b',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function line(text: string, width: number, style: Parameters<typeof UIFactory.stringToLine>[2] = {}): Line {
|
|
25
|
+
return UIFactory.stringToLine(text.padEnd(width).slice(0, width), width, style);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compactDate(value: number): string {
|
|
29
|
+
return new Date(value).toISOString().replace('T', ' ').slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusName(status: WorkPlanItemStatus): string {
|
|
33
|
+
return status.replace(/_/g, ' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
|
|
37
|
+
private items: readonly WorkPlanItem[] = [];
|
|
38
|
+
private lastPlanUpdatedAt = 0;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly store: WorkPlanStore) {
|
|
41
|
+
super('work-plan', 'Work Plan', 'L', 'agent');
|
|
42
|
+
this.showSelectionGutter = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onActivate(): void {
|
|
46
|
+
super.onActivate();
|
|
47
|
+
this.refresh();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render(width: number, height: number): Line[] {
|
|
51
|
+
this.refresh();
|
|
52
|
+
return this.renderList(width, height, {
|
|
53
|
+
title: 'Work Plan',
|
|
54
|
+
header: this.renderHeader(width),
|
|
55
|
+
footer: this.renderFooter(width),
|
|
56
|
+
emptyMessage: 'No work plan items yet',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
handleInput(key: string): boolean {
|
|
61
|
+
if (this.lastError !== null) this.clearError();
|
|
62
|
+
const item = this.items[this.selectedIndex];
|
|
63
|
+
try {
|
|
64
|
+
switch (key) {
|
|
65
|
+
case ' ':
|
|
66
|
+
case 'return':
|
|
67
|
+
case 'enter':
|
|
68
|
+
if (!item) return false;
|
|
69
|
+
this.store.cycleItemStatus(item.id);
|
|
70
|
+
this.refresh();
|
|
71
|
+
return true;
|
|
72
|
+
case '1':
|
|
73
|
+
return this.setSelectedStatus('pending');
|
|
74
|
+
case '2':
|
|
75
|
+
return this.setSelectedStatus('in_progress');
|
|
76
|
+
case '3':
|
|
77
|
+
return this.setSelectedStatus('blocked');
|
|
78
|
+
case '4':
|
|
79
|
+
return this.setSelectedStatus('done');
|
|
80
|
+
case '5':
|
|
81
|
+
return this.setSelectedStatus('failed');
|
|
82
|
+
case '6':
|
|
83
|
+
return this.setSelectedStatus('cancelled');
|
|
84
|
+
case 'd':
|
|
85
|
+
case 'delete':
|
|
86
|
+
if (!item) return false;
|
|
87
|
+
this.store.removeItem(item.id);
|
|
88
|
+
this.refresh();
|
|
89
|
+
return true;
|
|
90
|
+
case 'c':
|
|
91
|
+
this.store.clearCompleted();
|
|
92
|
+
this.refresh();
|
|
93
|
+
return true;
|
|
94
|
+
case 'r':
|
|
95
|
+
this.refresh(true);
|
|
96
|
+
return true;
|
|
97
|
+
default:
|
|
98
|
+
return super.handleInput(key);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.setError(error instanceof Error ? error.message : String(error));
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
protected getItems(): readonly WorkPlanItem[] {
|
|
107
|
+
return this.items;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
protected getEmptyStateActions(): Array<{ command: string; summary: string }> {
|
|
111
|
+
return [
|
|
112
|
+
{ command: '/workplan add <title>', summary: 'add a persistent item' },
|
|
113
|
+
{ command: '/workplan list', summary: 'print the current plan' },
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
protected renderItem(item: WorkPlanItem, _index: number, selected: boolean, width: number): Line {
|
|
118
|
+
const status = STATUS_LABEL[item.status];
|
|
119
|
+
const owner = item.owner ? ` @${item.owner}` : '';
|
|
120
|
+
const source = item.source ? ` (${item.source})` : '';
|
|
121
|
+
const text = `${status} ${item.title}${owner}${source}`;
|
|
122
|
+
return line(text, width, {
|
|
123
|
+
fg: selected ? '#e2e8f0' : STATUS_COLOR[item.status],
|
|
124
|
+
bg: selected ? '#1e293b' : undefined,
|
|
125
|
+
bold: selected || item.status === 'in_progress',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private setSelectedStatus(status: WorkPlanItemStatus): boolean {
|
|
130
|
+
const item = this.items[this.selectedIndex];
|
|
131
|
+
if (!item) return false;
|
|
132
|
+
this.store.setItemStatus(item.id, status);
|
|
133
|
+
this.refresh();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private refresh(force = false): void {
|
|
138
|
+
const plan = this.store.getActivePlan();
|
|
139
|
+
if (!force && plan.updatedAt === this.lastPlanUpdatedAt && this.items.length === plan.items.length) return;
|
|
140
|
+
this.items = plan.items;
|
|
141
|
+
this.lastPlanUpdatedAt = plan.updatedAt;
|
|
142
|
+
this.clampSelection();
|
|
143
|
+
this.needsRender = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private renderHeader(width: number): Line[] {
|
|
147
|
+
const plan = this.store.getActivePlan();
|
|
148
|
+
const counts = new Map<WorkPlanItemStatus, number>();
|
|
149
|
+
for (const status of Object.keys(STATUS_LABEL) as WorkPlanItemStatus[]) counts.set(status, 0);
|
|
150
|
+
for (const item of plan.items) counts.set(item.status, (counts.get(item.status) ?? 0) + 1);
|
|
151
|
+
const active = this.items[this.selectedIndex];
|
|
152
|
+
const header = [
|
|
153
|
+
line(`Persistent Work Plan`, width, { fg: '#22d3ee', bold: true }),
|
|
154
|
+
line(`Project: ${plan.projectRoot}`, width, { fg: '#cbd5e1' }),
|
|
155
|
+
line(
|
|
156
|
+
`Items: ${plan.items.length} pending ${counts.get('pending') ?? 0} active ${counts.get('in_progress') ?? 0} blocked ${counts.get('blocked') ?? 0} done ${counts.get('done') ?? 0}`,
|
|
157
|
+
width,
|
|
158
|
+
{ fg: '#94a3b8' },
|
|
159
|
+
),
|
|
160
|
+
line(`Saved: ${this.store.filePath}`, width, { fg: '#64748b' }),
|
|
161
|
+
];
|
|
162
|
+
if (active) {
|
|
163
|
+
header.push(line('', width));
|
|
164
|
+
header.push(line(`Selected: ${active.id} ${statusName(active.status)} updated ${compactDate(active.updatedAt)}`, width, { fg: '#a5b4fc' }));
|
|
165
|
+
if (active.notes) header.push(line(`Notes: ${active.notes}`, width, { fg: '#cbd5e1' }));
|
|
166
|
+
}
|
|
167
|
+
return header;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private renderFooter(width: number): Line[] {
|
|
171
|
+
return [
|
|
172
|
+
line('Enter/Space cycle 1 pending 2 active 3 blocked 4 done 5 failed 6 cancelled d delete c clear done r refresh', width, { fg: '#94a3b8' }),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -74,6 +74,7 @@ export type CreateBootstrapCommandContextOptions = {
|
|
|
74
74
|
knowledgeService?: KnowledgeService;
|
|
75
75
|
projectPlanningService?: import('@pellux/goodvibes-sdk/platform/knowledge').ProjectPlanningService;
|
|
76
76
|
projectPlanningProjectId?: string;
|
|
77
|
+
workPlanStore?: import('../work-plans/work-plan-store.ts').WorkPlanStore;
|
|
77
78
|
providerOptimizer?: import('@pellux/goodvibes-sdk/platform/providers').ProviderOptimizer;
|
|
78
79
|
pluginManager?: PluginManager;
|
|
79
80
|
hookWorkbench?: HookWorkbench;
|
|
@@ -142,6 +143,7 @@ export function createBootstrapCommandContext(
|
|
|
142
143
|
knowledgeService,
|
|
143
144
|
projectPlanningService,
|
|
144
145
|
projectPlanningProjectId,
|
|
146
|
+
workPlanStore,
|
|
145
147
|
providerOptimizer,
|
|
146
148
|
pluginManager,
|
|
147
149
|
hookWorkbench,
|
|
@@ -233,6 +235,7 @@ export function createBootstrapCommandContext(
|
|
|
233
235
|
bookmarkManager,
|
|
234
236
|
projectPlanningService,
|
|
235
237
|
projectPlanningProjectId,
|
|
238
|
+
workPlanStore,
|
|
236
239
|
}, shellServices);
|
|
237
240
|
const platform = createBootstrapCommandPlatformSection({ configManager, voiceProviderRegistry, voiceService }, shellServices);
|
|
238
241
|
const extensions = createBootstrapCommandExtensionsSection({
|
|
@@ -87,6 +87,7 @@ export interface BootstrapCommandSectionOptions {
|
|
|
87
87
|
readonly knowledgeService?: KnowledgeService;
|
|
88
88
|
readonly projectPlanningService?: import('@pellux/goodvibes-sdk/platform/knowledge').ProjectPlanningService;
|
|
89
89
|
readonly projectPlanningProjectId?: string;
|
|
90
|
+
readonly workPlanStore?: import('../work-plans/work-plan-store.ts').WorkPlanStore;
|
|
90
91
|
readonly pluginManager?: PluginManager;
|
|
91
92
|
readonly hookWorkbench?: HookWorkbench;
|
|
92
93
|
readonly providerOptimizer?: import('@pellux/goodvibes-sdk/platform/providers').ProviderOptimizer;
|
|
@@ -313,7 +314,7 @@ export function createBootstrapCommandWorkspaceSection(
|
|
|
313
314
|
options: Pick<
|
|
314
315
|
BootstrapCommandSectionOptions,
|
|
315
316
|
'keybindingsManager' | 'fileUndoManager' | 'panelManager' | 'profileManager' | 'bookmarkManager'
|
|
316
|
-
| 'projectPlanningService' | 'projectPlanningProjectId'
|
|
317
|
+
| 'projectPlanningService' | 'projectPlanningProjectId' | 'workPlanStore'
|
|
317
318
|
>,
|
|
318
319
|
shellServices: BootstrapCommandShellServices,
|
|
319
320
|
): BootstrapCommandWorkspaceSection {
|
|
@@ -325,6 +326,7 @@ export function createBootstrapCommandWorkspaceSection(
|
|
|
325
326
|
bookmarkManager: options.bookmarkManager,
|
|
326
327
|
projectPlanningService: options.projectPlanningService,
|
|
327
328
|
projectPlanningProjectId: options.projectPlanningProjectId,
|
|
329
|
+
workPlanStore: options.workPlanStore,
|
|
328
330
|
...shellServices.workspace,
|
|
329
331
|
};
|
|
330
332
|
}
|
|
@@ -205,6 +205,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
205
205
|
knowledgeService: services.knowledgeService,
|
|
206
206
|
projectPlanningService: services.projectPlanningService,
|
|
207
207
|
projectPlanningProjectId: services.projectPlanningProjectId,
|
|
208
|
+
workPlanStore: services.workPlanStore,
|
|
208
209
|
providerOptimizer: services.providerOptimizer,
|
|
209
210
|
pluginManager: services.pluginManager,
|
|
210
211
|
hookWorkbench: services.hookWorkbench,
|
package/src/runtime/services.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
createWorkflowServices,
|
|
85
85
|
type WorkflowServices,
|
|
86
86
|
} from '@pellux/goodvibes-sdk/platform/tools';
|
|
87
|
+
import { WorkPlanStore } from '../work-plans/work-plan-store.ts';
|
|
87
88
|
|
|
88
89
|
const REGULAR_KNOWLEDGE_DB_FILE = 'knowledge-wiki.sqlite';
|
|
89
90
|
const HOME_GRAPH_KNOWLEDGE_DB_FILE = 'knowledge-home-graph.sqlite';
|
|
@@ -171,6 +172,7 @@ export interface RuntimeServices {
|
|
|
171
172
|
readonly homeGraphService: HomeGraphService;
|
|
172
173
|
readonly projectPlanningService: ProjectPlanningService;
|
|
173
174
|
readonly projectPlanningProjectId: string;
|
|
175
|
+
readonly workPlanStore: WorkPlanStore;
|
|
174
176
|
readonly memoryStore: MemoryStore;
|
|
175
177
|
readonly memoryRegistry: MemoryRegistry;
|
|
176
178
|
readonly serviceRegistry: ServiceRegistry;
|
|
@@ -447,6 +449,11 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
447
449
|
const projectPlanningService = new ProjectPlanningService(knowledgeStore, {
|
|
448
450
|
defaultProjectId: projectPlanningProjectId,
|
|
449
451
|
});
|
|
452
|
+
const workPlanStore = new WorkPlanStore({
|
|
453
|
+
homeDirectory,
|
|
454
|
+
projectId: projectPlanningProjectId,
|
|
455
|
+
projectRoot: workingDirectory,
|
|
456
|
+
});
|
|
450
457
|
const voiceProviders = new VoiceProviderRegistry();
|
|
451
458
|
ensureBuiltinVoiceProviders(voiceProviders);
|
|
452
459
|
const voiceService = new VoiceService(voiceProviders);
|
|
@@ -596,6 +603,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
596
603
|
homeGraphService,
|
|
597
604
|
projectPlanningService,
|
|
598
605
|
projectPlanningProjectId,
|
|
606
|
+
workPlanStore,
|
|
599
607
|
memoryStore,
|
|
600
608
|
memoryRegistry,
|
|
601
609
|
serviceRegistry,
|
|
@@ -81,6 +81,7 @@ export interface UiPlanningServices {
|
|
|
81
81
|
readonly adaptivePlanner: RuntimeServices['adaptivePlanner'];
|
|
82
82
|
readonly projectPlanningService: RuntimeServices['projectPlanningService'];
|
|
83
83
|
readonly projectPlanningProjectId: RuntimeServices['projectPlanningProjectId'];
|
|
84
|
+
readonly workPlanStore: RuntimeServices['workPlanStore'];
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export interface UiCoordinationServices {
|
|
@@ -173,6 +174,7 @@ export function createUiRuntimeServices(
|
|
|
173
174
|
adaptivePlanner: runtimeServices.adaptivePlanner,
|
|
174
175
|
projectPlanningService: runtimeServices.projectPlanningService,
|
|
175
176
|
projectPlanningProjectId: runtimeServices.projectPlanningProjectId,
|
|
177
|
+
workPlanStore: runtimeServices.workPlanStore,
|
|
176
178
|
},
|
|
177
179
|
coordination: {
|
|
178
180
|
approvalBroker: runtimeServices.approvalBroker,
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '0.19.
|
|
9
|
+
let _version = '0.19.85';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|
|
@@ -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
|
+
}
|