@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 CHANGED
@@ -4,6 +4,11 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.85] — 2026-05-09
8
+
9
+ ### Changes
10
+ - 72733e04 feat: add persistent work plan tracking
11
+
7
12
  ## [0.19.84] — 2026-05-09
8
13
 
9
14
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.84-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.85-blue.svg)](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
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.33.19"
6
+ "version": "0.33.20"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.84",
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.19",
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
+ }
@@ -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,
@@ -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.84';
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
+ }