@dreki-gg/pi-plan-mode 0.20.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `preview_prototype` is now freeform. The tool dropped its Pug dependency and the imposed wrapper template (fixed dark theme, badge header, purple-accent panel) that made every prototype look the same. It now takes a `html` parameter — a complete, self-contained HTML document the agent authors with full freedom over markup, fonts, colors, layout, and inline scripts — and only persists and opens it. A bare fragment is tolerated and wrapped in a minimal unstyled shell. The `visual-prototype` skill and plan-mode prompt now steer the agent toward product-fitting designs and toward delegating the markup to the `ux-designer` subagent for real design taste, instead of generic boilerplate.
8
+
9
+ ## 0.22.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Add `/plans` command to list, filter, and sort plans interactively. Supports filtering by status (in-progress, done, superseded, abandoned) and sorting by date, task count, or name. Works both interactively and with inline args (e.g. `/plans done oldest`).
14
+
15
+ ## 0.21.0
16
+
17
+ ### Minor Changes
18
+
19
+ - Add two plan-management tools:
20
+
21
+ - `revise_plan` — sister of `submit_plan` that rewrites an existing plan in place by name. All content fields (title, handoff, tasks) are optional, so you pass only what changes. When tasks are supplied they fully replace the set, but `status` and `notes` are preserved for any task whose id is unchanged; registry status is re-derived from task state. Use when a plan was submitted prematurely and follow-up changes arrive, instead of creating a new plan.
22
+ - `set_active_plan` — tool form of the `/plan focus` command. Pins a plan into session state so subsequent `plan_status` / `update_task` / `add_task` calls target it. Useful when `plan_status` reports multiple in-progress plans and the agent needs to select one programmatically. Available in both plan and execution phases.
23
+
3
24
  ## 0.20.1
4
25
 
5
26
  ### Patch Changes
package/README.md CHANGED
@@ -26,10 +26,12 @@ pi install npm:@dreki-gg/pi-questionnaire
26
26
  | Command | `/plan focus <name>` | Pin a plan so tracking calls default to it (multi-plan repos) |
27
27
  | Command | `/todos` | Show current plan progress |
28
28
  | Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
29
+ | Tool | `revise_plan` | Rewrite an existing plan in place (title/handoff/tasks) |
29
30
  | Tool | `update_task` | Mark a task done / skipped / blocked |
30
31
  | Tool | `update_tasks` | Mark several tasks done / skipped in one call |
31
32
  | Tool | `add_task` | Capture a discovered follow-up (deferred) |
32
33
  | Tool | `plan_status` | Read-only snapshot; progress table when many plans are active |
34
+ | Tool | `set_active_plan` | Pin a plan as active (tool form of `/plan focus`) so tracking calls target it |
33
35
  | Tool | `update_plan` | Close/reopen a plan: done, superseded, abandoned, in-progress |
34
36
  | Tool | `reconcile_plans` | Detect & repair drift between tasks.jsonl and the registry |
35
37
 
@@ -1,34 +1,48 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { renderPrototypeHtml } from '../html/render.js';
3
-
4
- describe('renderPrototypeHtml', () => {
5
- test('renders title and intent in a minimal header', () => {
6
- const html = renderPrototypeHtml('Sidebar redesign', 'Left-aligned nav with icons', '.nav Nav');
7
-
8
- expect(html).toContain('Sidebar redesign');
9
- expect(html).toContain('Left-aligned nav with icons');
10
- expect(html).toContain('Prototype');
2
+ import { buildPrototypeDocument } from '../html/render.js';
3
+
4
+ describe('buildPrototypeDocument', () => {
5
+ test('returns a full HTML document untouched, with no imposed wrapper', () => {
6
+ const doc = `<!doctype html>
7
+ <html lang="en">
8
+ <head><meta charset="utf-8"><title>My own design</title></head>
9
+ <body style="background: salmon"><h1>Hand-crafted</h1></body>
10
+ </html>`;
11
+
12
+ const html = buildPrototypeDocument('Sidebar redesign', doc);
13
+
14
+ // Author's document is preserved verbatim — no badge, no theme, no panel.
15
+ expect(html).toBe(doc.trim());
16
+ expect(html).not.toContain('Prototype ·');
17
+ expect(html).not.toContain('class="prototype"');
18
+ expect(html).not.toContain('Inter');
11
19
  });
12
20
 
13
- test('renders the Pug body markup', () => {
14
- const html = renderPrototypeHtml('Card', 'A product card', '.card Product card');
15
-
16
- expect(html).toContain('class="card"');
17
- expect(html).toContain('Product card');
21
+ test('recognizes a full document via <html> even without a doctype', () => {
22
+ const doc = '<html><body><main>Hello</main></body></html>';
23
+ const html = buildPrototypeDocument('Card', doc);
24
+ expect(html).toBe(doc);
18
25
  });
19
26
 
20
- test('does not render tasks or handoff sections', () => {
21
- const html = renderPrototypeHtml('Card', 'A product card', '.card Hello');
27
+ test('wraps a bare fragment in a minimal, unstyled shell', () => {
28
+ const html = buildPrototypeDocument('Card', '<div class="card">Product card</div>');
22
29
 
23
- expect(html).not.toContain('Handoff');
24
- expect(html).not.toContain('Tasks');
25
- expect(html).not.toContain('Depends on');
30
+ expect(html).toMatch(/^<!doctype html>/i);
31
+ expect(html).toContain('<title>Card</title>');
32
+ expect(html).toContain('<div class="card">Product card</div>');
33
+ // The shell imposes no theme of its own.
34
+ expect(html).not.toContain('background');
35
+ expect(html).not.toContain('Inter');
26
36
  });
27
37
 
28
- test('handles an empty prototype body without throwing', () => {
29
- const html = renderPrototypeHtml('Empty', 'Nothing yet', '');
38
+ test('escapes the title when used in the fallback shell', () => {
39
+ const html = buildPrototypeDocument('A & B <script>', '<p>hi</p>');
40
+ expect(html).toContain('<title>A &amp; B &lt;script&gt;</title>');
41
+ });
30
42
 
31
- expect(html).toContain('Empty');
32
- expect(html).toContain('Nothing yet');
43
+ test('handles an empty body without throwing', () => {
44
+ const html = buildPrototypeDocument('Empty', '');
45
+ expect(html).toMatch(/^<!doctype html>/i);
46
+ expect(html).toContain('<title>Empty</title>');
33
47
  });
34
48
  });
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { upsertPlanEntry } from '../storage/plans-manifest.js';
8
+ import { writeTasksJsonl } from '../storage/task-storage.js';
9
+ import type { TaskMeta, TaskRecord, PlanStatus } from '../types.js';
10
+ import {
11
+ filterPlans,
12
+ sortPlans,
13
+ formatPlanList,
14
+ parseArgs,
15
+ loadPlanListItems,
16
+ type PlanListItem,
17
+ type SortField,
18
+ type StatusFilter,
19
+ } from '../commands/list-plans.js';
20
+
21
+ const runPlanIO = makePlanRuntime();
22
+ const originalCwd = process.cwd();
23
+ let dir: string;
24
+
25
+ beforeEach(async () => {
26
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-list-'));
27
+ chdir(dir);
28
+ });
29
+
30
+ afterEach(async () => {
31
+ chdir(originalCwd);
32
+ await rm(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ // ── Helpers ──────────────────────────────────────────────────────────────────
36
+
37
+ const meta = (name: string): TaskMeta => ({
38
+ _type: 'meta',
39
+ title: `Title ${name}`,
40
+ plan_name: name,
41
+ created_at: '2026-01-01T00:00:00.000Z',
42
+ });
43
+
44
+ const task = (id: string, status: 'done' | 'pending' = 'done'): TaskRecord => ({
45
+ _type: 'task',
46
+ id,
47
+ description: `task ${id}`,
48
+ status,
49
+ origin: 'plan',
50
+ created_at: '2026-01-01T00:00:00.000Z',
51
+ updated_at: '2026-01-01T00:00:00.000Z',
52
+ });
53
+
54
+ function makePlanItem(overrides: Partial<PlanListItem> & { name: string }): PlanListItem {
55
+ return {
56
+ title: `Title ${overrides.name}`,
57
+ status: 'in-progress',
58
+ created_at: '2026-01-01T00:00:00.000Z',
59
+ completed_at: null,
60
+ totalTasks: 3,
61
+ doneTasks: 1,
62
+ pendingTasks: 2,
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ // ── filterPlans ──────────────────────────────────────────────────────────────
68
+
69
+ describe('filterPlans', () => {
70
+ const plans: PlanListItem[] = [
71
+ makePlanItem({ name: 'alpha', status: 'in-progress' }),
72
+ makePlanItem({ name: 'beta', status: 'done' }),
73
+ makePlanItem({ name: 'gamma', status: 'abandoned' }),
74
+ ];
75
+
76
+ test('returns all when filter is "all"', () => {
77
+ expect(filterPlans(plans, 'all')).toHaveLength(3);
78
+ });
79
+
80
+ test('filters by in-progress', () => {
81
+ const result = filterPlans(plans, 'in-progress');
82
+ expect(result).toHaveLength(1);
83
+ expect(result[0].name).toBe('alpha');
84
+ });
85
+
86
+ test('filters by done', () => {
87
+ const result = filterPlans(plans, 'done');
88
+ expect(result).toHaveLength(1);
89
+ expect(result[0].name).toBe('beta');
90
+ });
91
+
92
+ test('filters by abandoned', () => {
93
+ const result = filterPlans(plans, 'abandoned');
94
+ expect(result).toHaveLength(1);
95
+ expect(result[0].name).toBe('gamma');
96
+ });
97
+
98
+ test('returns empty when no match', () => {
99
+ expect(filterPlans(plans, 'superseded')).toHaveLength(0);
100
+ });
101
+ });
102
+
103
+ // ── sortPlans ────────────────────────────────────────────────────────────────
104
+
105
+ describe('sortPlans', () => {
106
+ const plans: PlanListItem[] = [
107
+ makePlanItem({ name: 'charlie', created_at: '2026-03-01T00:00:00.000Z', totalTasks: 2 }),
108
+ makePlanItem({ name: 'alpha', created_at: '2026-01-01T00:00:00.000Z', totalTasks: 5 }),
109
+ makePlanItem({ name: 'bravo', created_at: '2026-02-01T00:00:00.000Z', totalTasks: 1 }),
110
+ ];
111
+
112
+ test('sorts by name', () => {
113
+ const result = sortPlans(plans, 'name');
114
+ expect(result.map((p) => p.name)).toEqual(['alpha', 'bravo', 'charlie']);
115
+ });
116
+
117
+ test('sorts by date ascending (oldest first)', () => {
118
+ const result = sortPlans(plans, 'date-asc');
119
+ expect(result.map((p) => p.name)).toEqual(['alpha', 'bravo', 'charlie']);
120
+ });
121
+
122
+ test('sorts by date descending (newest first)', () => {
123
+ const result = sortPlans(plans, 'date-desc');
124
+ expect(result.map((p) => p.name)).toEqual(['charlie', 'bravo', 'alpha']);
125
+ });
126
+
127
+ test('sorts by task count (most tasks first)', () => {
128
+ const result = sortPlans(plans, 'tasks');
129
+ expect(result.map((p) => p.name)).toEqual(['alpha', 'charlie', 'bravo']);
130
+ });
131
+
132
+ test('does not mutate the original array', () => {
133
+ const original = [...plans];
134
+ sortPlans(plans, 'name');
135
+ expect(plans.map((p) => p.name)).toEqual(original.map((p) => p.name));
136
+ });
137
+ });
138
+
139
+ // ── formatPlanList ───────────────────────────────────────────────────────────
140
+
141
+ describe('formatPlanList', () => {
142
+ test('shows message when empty and filter is all', () => {
143
+ expect(formatPlanList([], 'all', 'date-desc')).toContain('No plans found');
144
+ });
145
+
146
+ test('shows filter-specific message when empty', () => {
147
+ expect(formatPlanList([], 'done', 'date-desc')).toContain('No plans with status "done"');
148
+ });
149
+
150
+ test('includes plan details in output', () => {
151
+ const plans = [makePlanItem({ name: 'alpha', title: 'Alpha Plan', totalTasks: 5, doneTasks: 3 })];
152
+ const output = formatPlanList(plans, 'all', 'date-desc');
153
+ expect(output).toContain('alpha');
154
+ expect(output).toContain('Alpha Plan');
155
+ expect(output).toContain('3/5 tasks');
156
+ });
157
+
158
+ test('shows header with count and sort', () => {
159
+ const plans = [makePlanItem({ name: 'a' }), makePlanItem({ name: 'b' })];
160
+ const output = formatPlanList(plans, 'all', 'tasks');
161
+ expect(output).toContain('All plans (2)');
162
+ expect(output).toContain('most tasks first');
163
+ });
164
+ });
165
+
166
+ // ── parseArgs ────────────────────────────────────────────────────────────────
167
+
168
+ describe('parseArgs', () => {
169
+ test('parses filter only', () => {
170
+ expect(parseArgs('done')).toEqual({ filter: 'done', sort: 'date-desc' });
171
+ });
172
+
173
+ test('parses sort only', () => {
174
+ expect(parseArgs('oldest')).toEqual({ filter: 'all', sort: 'date-asc' });
175
+ });
176
+
177
+ test('parses filter and sort together', () => {
178
+ expect(parseArgs('in-progress tasks')).toEqual({ filter: 'in-progress', sort: 'tasks' });
179
+ });
180
+
181
+ test('accepts aliases', () => {
182
+ expect(parseArgs('pending newest')).toEqual({ filter: 'in-progress', sort: 'date-desc' });
183
+ expect(parseArgs('active oldest')).toEqual({ filter: 'in-progress', sort: 'date-asc' });
184
+ expect(parseArgs('completed name')).toEqual({ filter: 'done', sort: 'name' });
185
+ });
186
+
187
+ test('defaults to all + date-desc for unknown tokens', () => {
188
+ expect(parseArgs('unknown gibberish')).toEqual({ filter: 'all', sort: 'date-desc' });
189
+ });
190
+
191
+ test('is case-insensitive', () => {
192
+ expect(parseArgs('DONE TASKS')).toEqual({ filter: 'done', sort: 'tasks' });
193
+ });
194
+ });
195
+
196
+ // ── loadPlanListItems (integration) ──────────────────────────────────────────
197
+
198
+ describe('loadPlanListItems', () => {
199
+ test('returns empty for no plans', async () => {
200
+ const items = await runPlanIO(loadPlanListItems());
201
+ expect(items).toEqual([]);
202
+ });
203
+
204
+ test('loads plans with task counts', async () => {
205
+ await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Alpha' }));
206
+ await runPlanIO(
207
+ writeTasksJsonl('.plans/alpha', meta('alpha'), [
208
+ task('t-001', 'done'),
209
+ task('t-002', 'pending'),
210
+ task('t-003', 'pending'),
211
+ ]),
212
+ );
213
+
214
+ const items = await runPlanIO(loadPlanListItems());
215
+ expect(items).toHaveLength(1);
216
+ expect(items[0].name).toBe('alpha');
217
+ expect(items[0].totalTasks).toBe(3);
218
+ expect(items[0].doneTasks).toBe(1);
219
+ expect(items[0].pendingTasks).toBe(2);
220
+ });
221
+
222
+ test('handles plans without task files', async () => {
223
+ await runPlanIO(upsertPlanEntry('orphan', { status: 'in-progress', title: 'Orphan' }));
224
+
225
+ const items = await runPlanIO(loadPlanListItems());
226
+ expect(items).toHaveLength(1);
227
+ expect(items[0].totalTasks).toBe(0);
228
+ expect(items[0].doneTasks).toBe(0);
229
+ });
230
+
231
+ test('loads multiple plans', async () => {
232
+ await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Alpha' }));
233
+ await runPlanIO(upsertPlanEntry('beta', { status: 'done', title: 'Beta' }));
234
+ await runPlanIO(
235
+ writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'pending')]),
236
+ );
237
+ await runPlanIO(
238
+ writeTasksJsonl('.plans/beta', meta('beta'), [task('t-001', 'done'), task('t-002', 'done')]),
239
+ );
240
+
241
+ const items = await runPlanIO(loadPlanListItems());
242
+ expect(items).toHaveLength(2);
243
+
244
+ const alpha = items.find((i) => i.name === 'alpha')!;
245
+ expect(alpha.status).toBe('in-progress');
246
+ expect(alpha.totalTasks).toBe(1);
247
+
248
+ const beta = items.find((i) => i.name === 'beta')!;
249
+ expect(beta.status).toBe('done');
250
+ expect(beta.totalTasks).toBe(2);
251
+ expect(beta.doneTasks).toBe(2);
252
+ });
253
+ });
@@ -0,0 +1,171 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { registerRevisePlanTool } from '../tools/revise-plan.js';
8
+ import { readTasksJsonl, writeTasksJsonl } from '../storage/task-storage.js';
9
+ import { saveHandoff, loadHandoff } from '../storage/plan-storage.js';
10
+ import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
11
+ import type { PlanData, TaskRecord } from '../types.js';
12
+
13
+ const runPlanIO = makePlanRuntime();
14
+ const now = '2026-05-27T12:00:00.000Z';
15
+
16
+ interface CapturedTool {
17
+ execute: (
18
+ id: string,
19
+ params: {
20
+ plan: string;
21
+ title?: string;
22
+ handoff?: string;
23
+ tasks?: Array<{
24
+ id: string;
25
+ description: string;
26
+ details?: string;
27
+ depends_on?: string[];
28
+ }>;
29
+ },
30
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
31
+ }
32
+
33
+ function setup(plan: PlanData | undefined): { tool: CapturedTool; revised: PlanData[] } {
34
+ let tool: CapturedTool | undefined;
35
+ const revised: PlanData[] = [];
36
+ const pi = {
37
+ registerTool: (config: CapturedTool) => {
38
+ tool = config;
39
+ },
40
+ } as unknown as Parameters<typeof registerRevisePlanTool>[0];
41
+
42
+ registerRevisePlanTool(pi, runPlanIO, {
43
+ resolvePlan: async () => ({ plan, candidates: plan ? [] : ['other'] }),
44
+ onPlanRevised: (_dir, p) => {
45
+ revised.push(p);
46
+ },
47
+ });
48
+
49
+ return { tool: tool!, revised };
50
+ }
51
+
52
+ const task = (id: string, over: Partial<TaskRecord> = {}): TaskRecord => ({
53
+ _type: 'task',
54
+ id,
55
+ description: `task ${id}`,
56
+ details: '',
57
+ status: 'pending',
58
+ origin: 'plan',
59
+ created_at: now,
60
+ updated_at: now,
61
+ ...over,
62
+ });
63
+
64
+ async function seed(plan: PlanData): Promise<void> {
65
+ const dir = `.plans/${plan.planName}`;
66
+ await runPlanIO(
67
+ writeTasksJsonl(
68
+ dir,
69
+ { _type: 'meta', title: plan.title, plan_name: plan.planName, created_at: now },
70
+ plan.tasks,
71
+ ),
72
+ );
73
+ await runPlanIO(saveHandoff(dir, plan.handoff));
74
+ }
75
+
76
+ const originalCwd = process.cwd();
77
+ let dir: string;
78
+ beforeEach(async () => {
79
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-revise-plan-'));
80
+ chdir(dir);
81
+ });
82
+ afterEach(async () => {
83
+ chdir(originalCwd);
84
+ await rm(dir, { recursive: true, force: true });
85
+ });
86
+
87
+ describe('revise_plan tool', () => {
88
+ test('rewrites handoff + title only, leaving tasks untouched', async () => {
89
+ const plan: PlanData = {
90
+ title: 'Old title',
91
+ planName: 'p',
92
+ handoff: 'old handoff',
93
+ tasks: [task('t-001'), task('t-002')],
94
+ };
95
+ await seed(plan);
96
+ await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'Old title' }));
97
+
98
+ const { tool } = setup(plan);
99
+ await tool.execute('c', { plan: 'p', title: 'New title', handoff: 'new handoff' });
100
+
101
+ const snapshot = await runPlanIO(readTasksJsonl('.plans/p'));
102
+ expect(snapshot?.meta.title).toBe('New title');
103
+ expect(snapshot?.tasks.map((t) => t.id)).toEqual(['t-001', 't-002']);
104
+ expect(await runPlanIO(loadHandoff('.plans/p'))).toBe('new handoff');
105
+ const [entry] = await runPlanIO(readPlansManifest());
106
+ expect(entry.title).toBe('New title');
107
+ expect(entry.status).toBe('in-progress');
108
+ });
109
+
110
+ test('replaces task set but preserves status/notes for matching ids', async () => {
111
+ const plan: PlanData = {
112
+ title: 'P',
113
+ planName: 'p',
114
+ handoff: 'h',
115
+ tasks: [
116
+ task('t-001', { status: 'done', notes: 'shipped' }),
117
+ task('t-002', { status: 'pending' }),
118
+ ],
119
+ };
120
+ await seed(plan);
121
+ await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
122
+
123
+ const { tool } = setup(plan);
124
+ await tool.execute('c', {
125
+ plan: 'p',
126
+ tasks: [
127
+ { id: 't-001', description: 'reworded but same id' },
128
+ { id: 't-003', description: 'brand new task' },
129
+ ],
130
+ });
131
+
132
+ const snapshot = await runPlanIO(readTasksJsonl('.plans/p'));
133
+ const tasks = snapshot!.tasks;
134
+ expect(tasks.map((t) => t.id)).toEqual(['t-001', 't-003']);
135
+ const t1 = tasks.find((t) => t.id === 't-001')!;
136
+ expect(t1.status).toBe('done');
137
+ expect(t1.notes).toBe('shipped');
138
+ expect(t1.description).toBe('reworded but same id');
139
+ const t3 = tasks.find((t) => t.id === 't-003')!;
140
+ expect(t3.status).toBe('pending');
141
+ });
142
+
143
+ test('reopens an all-done plan when a pending task is added', async () => {
144
+ const plan: PlanData = {
145
+ title: 'P',
146
+ planName: 'p',
147
+ handoff: 'h',
148
+ tasks: [task('t-001', { status: 'done' })],
149
+ };
150
+ await seed(plan);
151
+ await runPlanIO(upsertPlanEntry('p', { status: 'done', title: 'P' }));
152
+
153
+ const { tool } = setup(plan);
154
+ await tool.execute('c', {
155
+ plan: 'p',
156
+ tasks: [
157
+ { id: 't-001', description: 'done one' },
158
+ { id: 't-002', description: 'new pending work' },
159
+ ],
160
+ });
161
+
162
+ const [entry] = await runPlanIO(readPlansManifest());
163
+ expect(entry.status).toBe('in-progress');
164
+ });
165
+
166
+ test('reports not_found for an unknown plan (no throw)', async () => {
167
+ const { tool } = setup(undefined);
168
+ const result = await tool.execute('c', { plan: 'ghost', title: 'x' });
169
+ expect((result.details as { error?: string }).error).toBe('not_found');
170
+ });
171
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerSetActivePlanTool } from '../tools/set-active-plan.js';
3
+ import type { PlanData } from '../types.js';
4
+ import type { ResolvedPlan } from '../resolve-plan.js';
5
+
6
+ const now = '2026-05-27T12:00:00.000Z';
7
+
8
+ interface CapturedTool {
9
+ execute: (
10
+ id: string,
11
+ params: { plan: string },
12
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
13
+ }
14
+
15
+ function setup(resolved: ResolvedPlan): { tool: CapturedTool; requested: string[] } {
16
+ let tool: CapturedTool | undefined;
17
+ const requested: string[] = [];
18
+ const pi = {
19
+ registerTool: (config: CapturedTool) => {
20
+ tool = config;
21
+ },
22
+ } as unknown as Parameters<typeof registerSetActivePlanTool>[0];
23
+
24
+ registerSetActivePlanTool(pi, {
25
+ setActivePlan: async (name) => {
26
+ requested.push(name);
27
+ return resolved;
28
+ },
29
+ });
30
+
31
+ return { tool: tool!, requested };
32
+ }
33
+
34
+ const plan: PlanData = {
35
+ title: 'My Plan',
36
+ planName: 'my-plan',
37
+ handoff: '',
38
+ tasks: [
39
+ {
40
+ _type: 'task',
41
+ id: 't-001',
42
+ description: 'do',
43
+ status: 'pending',
44
+ created_at: now,
45
+ updated_at: now,
46
+ },
47
+ ],
48
+ };
49
+
50
+ describe('set_active_plan tool', () => {
51
+ test('pins the plan and confirms when resolution succeeds', async () => {
52
+ const { tool, requested } = setup({ plan, candidates: [] });
53
+ const result = await tool.execute('c', { plan: '.plans/my-plan' });
54
+
55
+ expect(requested).toEqual(['.plans/my-plan']);
56
+ const details = result.details as { active?: boolean; plan_name?: string; title?: string };
57
+ expect(details.active).toBe(true);
58
+ expect(details.plan_name).toBe('my-plan');
59
+ expect(details.title).toBe('My Plan');
60
+ expect(result.content?.[0]?.text).toMatch(/my-plan/);
61
+ });
62
+
63
+ test('reports not_found with candidates (no throw) when unresolved', async () => {
64
+ const { tool } = setup({ plan: undefined, candidates: ['alpha', 'beta'] });
65
+ const result = await tool.execute('c', { plan: 'ghost' });
66
+
67
+ const details = result.details as { error?: string; candidates?: string[] };
68
+ expect(details.error).toBe('not_found');
69
+ expect(details.candidates).toEqual(['alpha', 'beta']);
70
+ expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
71
+ });
72
+ });