@dreki-gg/pi-plan-mode 0.23.0 → 0.24.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +48 -5
  3. package/bin/clean-plans.js +67 -49
  4. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
  5. package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
  6. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
  7. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
  8. package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
  9. package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
  10. package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
  11. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
  12. package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
  13. package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
  14. package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
  15. package/extensions/plan-mode/constants.ts +5 -0
  16. package/extensions/plan-mode/index.ts +22 -0
  17. package/extensions/plan-mode/initiative.ts +168 -0
  18. package/extensions/plan-mode/prompts.ts +4 -0
  19. package/extensions/plan-mode/reconcile.ts +65 -0
  20. package/extensions/plan-mode/schema.ts +28 -0
  21. package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
  22. package/extensions/plan-mode/storage/plan-storage.ts +11 -0
  23. package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
  24. package/extensions/plan-mode/tools/initiative-status.ts +191 -0
  25. package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
  26. package/extensions/plan-mode/tools/revise-plan.ts +22 -0
  27. package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
  28. package/extensions/plan-mode/tools/submit-plan.ts +37 -4
  29. package/extensions/plan-mode/tools/update-initiative.ts +120 -0
  30. package/extensions/plan-mode/tools/update-plan.ts +3 -0
  31. package/extensions/plan-mode/types.ts +3 -0
  32. package/package.json +1 -1
  33. package/skills/planning-context/SKILL.md +11 -0
@@ -0,0 +1,59 @@
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 { upsertInitiativeEntry } from '../storage/initiatives-manifest.js';
9
+ import {
10
+ filterInitiatives,
11
+ formatInitiativeList,
12
+ loadInitiativeListItems,
13
+ parseFilter,
14
+ } from '../commands/list-initiatives.js';
15
+
16
+ const runPlanIO = makePlanRuntime();
17
+
18
+ const originalCwd = process.cwd();
19
+ let dir: string;
20
+ beforeEach(async () => {
21
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-list-init-'));
22
+ chdir(dir);
23
+ });
24
+ afterEach(async () => {
25
+ chdir(originalCwd);
26
+ await rm(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('list-initiatives', () => {
30
+ test('parseFilter maps aliases', () => {
31
+ expect(parseFilter('done')).toBe('done');
32
+ expect(parseFilter('active')).toBe('in-progress');
33
+ expect(parseFilter('whatever')).toBe('all');
34
+ });
35
+
36
+ test('rolls up member-plan progress and readiness', async () => {
37
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
38
+ await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
39
+ await runPlanIO(
40
+ upsertPlanEntry('b', { status: 'in-progress', title: 'B', initiative: 'big', depends_on: ['a'] }),
41
+ );
42
+
43
+ const items = await runPlanIO(loadInitiativeListItems());
44
+ expect(items).toHaveLength(1);
45
+ expect(items[0]?.totalPlans).toBe(2);
46
+ expect(items[0]?.donePlans).toBe(1);
47
+ expect(items[0]?.ready).toBe(1); // b unblocked (a done)
48
+
49
+ const text = formatInitiativeList(items, 'all');
50
+ expect(text).toMatch(/big — Big \[1\/2 plans, ready 1, blocked 0\]/);
51
+ });
52
+
53
+ test('filterInitiatives narrows by status', async () => {
54
+ await runPlanIO(upsertInitiativeEntry('x', { status: 'in-progress', title: 'X' }));
55
+ await runPlanIO(upsertInitiativeEntry('y', { status: 'done', title: 'Y' }));
56
+ const items = await runPlanIO(loadInitiativeListItems());
57
+ expect(filterInitiatives(items, 'done').map((i) => i.name)).toEqual(['y']);
58
+ });
59
+ });
@@ -10,7 +10,13 @@ import {
10
10
  upsertPlanEntry,
11
11
  writePlansManifest,
12
12
  } from '../storage/plans-manifest.js';
13
- import { applyReconcile, collectPlanDrift } from '../reconcile.js';
13
+ import { upsertInitiativeEntry, readInitiativesManifest } from '../storage/initiatives-manifest.js';
14
+ import {
15
+ applyInitiativeReconcile,
16
+ applyReconcile,
17
+ collectInitiativeDrift,
18
+ collectPlanDrift,
19
+ } from '../reconcile.js';
14
20
  import type { TaskMeta, TaskRecord, TaskStatus } from '../types.js';
15
21
 
16
22
  const runPlanIO = makePlanRuntime();
@@ -112,3 +118,34 @@ describe('applyReconcile', () => {
112
118
  expect(entry.status).toBe('done');
113
119
  });
114
120
  });
121
+
122
+ describe('initiative drift', () => {
123
+ test('flags an initiative whose members are all closed but registry is in-progress', async () => {
124
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
125
+ await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
126
+ await runPlanIO(upsertPlanEntry('b', { status: 'done', title: 'B', initiative: 'big' }));
127
+
128
+ const rows = await runPlanIO(collectInitiativeDrift());
129
+ const big = rows.find((r) => r.name === 'big')!;
130
+ expect(big.drift).toBe('status');
131
+ expect(big.derivedStatus).toBe('done');
132
+ expect(big.members).toBe(2);
133
+ });
134
+
135
+ test('applyInitiativeReconcile repairs initiative status drift', async () => {
136
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
137
+ await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
138
+
139
+ const repaired = await runPlanIO(applyInitiativeReconcile(await runPlanIO(collectInitiativeDrift())));
140
+ expect(repaired.map((r) => r.name)).toEqual(['big']);
141
+ const [entry] = await runPlanIO(readInitiativesManifest());
142
+ expect(entry.status).toBe('done');
143
+ });
144
+
145
+ test('never flags a manually-closed (abandoned) initiative as drift', async () => {
146
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'abandoned', title: 'Big', reason: 'x' }));
147
+ await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
148
+ const rows = await runPlanIO(collectInitiativeDrift());
149
+ expect(rows.find((r) => r.name === 'big')!.drift).toBeUndefined();
150
+ });
151
+ });
@@ -26,6 +26,8 @@ interface CapturedTool {
26
26
  details?: string;
27
27
  depends_on?: string[];
28
28
  }>;
29
+ initiative?: string;
30
+ depends_on_plans?: string[];
29
31
  },
30
32
  ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
31
33
  }
@@ -168,4 +170,40 @@ describe('revise_plan tool', () => {
168
170
  const result = await tool.execute('c', { plan: 'ghost', title: 'x' });
169
171
  expect((result.details as { error?: string }).error).toBe('not_found');
170
172
  });
173
+
174
+ test('re-links the plan to an initiative and persists plan-level deps', async () => {
175
+ const plan: PlanData = {
176
+ title: 'P',
177
+ planName: 'p',
178
+ handoff: 'h',
179
+ tasks: [task('t-001')],
180
+ };
181
+ await seed(plan);
182
+ await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
183
+
184
+ const { tool } = setup(plan);
185
+ await tool.execute('c', {
186
+ plan: 'p',
187
+ initiative: 'Auth Overhaul',
188
+ depends_on_plans: ['Schema First'],
189
+ });
190
+
191
+ const [entry] = await runPlanIO(readPlansManifest());
192
+ expect(entry.initiative).toBe('auth-overhaul');
193
+ expect(entry.depends_on).toEqual(['schema-first']);
194
+ });
195
+
196
+ test('preserves existing initiative link when not passed', async () => {
197
+ const plan: PlanData = { title: 'P', planName: 'p', handoff: 'h', tasks: [task('t-001')] };
198
+ await seed(plan);
199
+ await runPlanIO(
200
+ upsertPlanEntry('p', { status: 'in-progress', title: 'P', initiative: 'big' }),
201
+ );
202
+
203
+ const { tool } = setup(plan);
204
+ await tool.execute('c', { plan: 'p', title: 'P2' });
205
+
206
+ const [entry] = await runPlanIO(readPlansManifest());
207
+ expect(entry.initiative).toBe('big');
208
+ });
171
209
  });
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { Either } from 'effect';
3
3
  import {
4
4
  decodeExecPendingConfig,
5
+ decodeInitiativeManifestEntry,
5
6
  decodePlanManifestEntry,
6
7
  decodeTaskMeta,
7
8
  decodeTaskRecord,
@@ -211,6 +212,93 @@ describe('plan manifest entry schema', () => {
211
212
  ),
212
213
  ).toBe(false);
213
214
  });
215
+
216
+ test('accepts optional initiative + plan-level depends_on (forward compat)', () => {
217
+ expect(
218
+ isOk(
219
+ {
220
+ _type: 'plan',
221
+ name: 'auth-jwt',
222
+ status: 'in-progress',
223
+ title: 'Auth JWT',
224
+ created_at: now,
225
+ completed_at: null,
226
+ initiative: 'auth-overhaul',
227
+ depends_on: ['auth-schema'],
228
+ },
229
+ decodePlanManifestEntry,
230
+ ),
231
+ ).toBe(true);
232
+ });
233
+
234
+ test('still accepts a legacy entry without the new optional fields (back compat)', () => {
235
+ expect(
236
+ isOk(
237
+ {
238
+ _type: 'plan',
239
+ name: 'legacy',
240
+ status: 'done',
241
+ title: 'Legacy',
242
+ created_at: now,
243
+ completed_at: now,
244
+ },
245
+ decodePlanManifestEntry,
246
+ ),
247
+ ).toBe(true);
248
+ });
249
+ });
250
+
251
+ describe('initiative manifest entry schema', () => {
252
+ test('accepts a valid in-progress initiative', () => {
253
+ expect(
254
+ isOk(
255
+ {
256
+ _type: 'initiative',
257
+ name: 'auth-overhaul',
258
+ status: 'in-progress',
259
+ title: 'Auth Overhaul',
260
+ created_at: now,
261
+ completed_at: null,
262
+ },
263
+ decodeInitiativeManifestEntry,
264
+ ),
265
+ ).toBe(true);
266
+ });
267
+
268
+ test('accepts terminal statuses with a reason', () => {
269
+ for (const status of ['done', 'superseded', 'abandoned'] as const) {
270
+ expect(
271
+ isOk(
272
+ {
273
+ _type: 'initiative',
274
+ name: 'auth-overhaul',
275
+ status,
276
+ title: 'Auth Overhaul',
277
+ created_at: now,
278
+ completed_at: now,
279
+ reason: 'shipped',
280
+ },
281
+ decodeInitiativeManifestEntry,
282
+ ),
283
+ ).toBe(true);
284
+ }
285
+ });
286
+
287
+ test('rejects a plan _type masquerading as an initiative', () => {
288
+ expect(
289
+ isOk(
290
+ {
291
+ _type: 'plan',
292
+ name: 'auth-overhaul',
293
+ status: 'in-progress',
294
+ title: 'Auth Overhaul',
295
+ created_at: now,
296
+ completed_at: null,
297
+ },
298
+ decodeInitiativeManifestEntry,
299
+ ),
300
+ ).toBe(false);
301
+ });
214
302
  });
215
303
 
216
304
  describe('exec pending config schema', () => {
@@ -0,0 +1,58 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, readFile, 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 { readInitiativesManifest } from '../storage/initiatives-manifest.js';
8
+ import { registerSubmitInitiativeTool } from '../tools/submit-initiative.js';
9
+
10
+ const runPlanIO = makePlanRuntime();
11
+
12
+ interface CapturedTool {
13
+ execute: (
14
+ id: string,
15
+ params: { name: string; title: string; overview: string },
16
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
17
+ }
18
+
19
+ function setup(): CapturedTool {
20
+ let tool: CapturedTool | undefined;
21
+ const pi = {
22
+ registerTool: (config: CapturedTool) => {
23
+ tool = config;
24
+ },
25
+ } as unknown as Parameters<typeof registerSubmitInitiativeTool>[0];
26
+ registerSubmitInitiativeTool(pi, runPlanIO);
27
+ return tool!;
28
+ }
29
+
30
+ const originalCwd = process.cwd();
31
+ let dir: string;
32
+ beforeEach(async () => {
33
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-submit-init-'));
34
+ chdir(dir);
35
+ });
36
+ afterEach(async () => {
37
+ chdir(originalCwd);
38
+ await rm(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe('submit_initiative tool', () => {
42
+ test('creates a registry entry + INITIATIVE.md and kebab-cases the name', async () => {
43
+ const tool = setup();
44
+ const result = await tool.execute('c', {
45
+ name: 'Auth Overhaul',
46
+ title: 'Auth Overhaul',
47
+ overview: '# Overview\n\nBreak auth into plans.',
48
+ });
49
+
50
+ const [entry] = await runPlanIO(readInitiativesManifest());
51
+ expect(entry.name).toBe('auth-overhaul');
52
+ expect(entry.status).toBe('in-progress');
53
+
54
+ const md = await readFile(join(dir, '.plans/auth-overhaul/INITIATIVE.md'), 'utf-8');
55
+ expect(md).toMatch(/Break auth into plans/);
56
+ expect(result.content?.[0]?.text).toMatch(/initiative: "auth-overhaul"/);
57
+ });
58
+ });
@@ -0,0 +1,97 @@
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 { readPlansManifest } from '../storage/plans-manifest.js';
8
+ import {
9
+ readInitiativesManifest,
10
+ upsertInitiativeEntry,
11
+ } from '../storage/initiatives-manifest.js';
12
+ import { registerSubmitPlanTool } from '../tools/submit-plan.js';
13
+
14
+ const runPlanIO = makePlanRuntime();
15
+
16
+ interface SubmitParams {
17
+ name: string;
18
+ title: string;
19
+ handoff: string;
20
+ tasks: Array<{ id: string; description: string }>;
21
+ initiative?: string;
22
+ depends_on_plans?: string[];
23
+ }
24
+ interface CapturedTool {
25
+ execute: (
26
+ id: string,
27
+ params: SubmitParams,
28
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
29
+ }
30
+
31
+ function setup(): CapturedTool {
32
+ let tool: CapturedTool | undefined;
33
+ const pi = {
34
+ registerTool: (config: CapturedTool) => {
35
+ tool = config;
36
+ },
37
+ } as unknown as Parameters<typeof registerSubmitPlanTool>[0];
38
+ registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted: () => {} });
39
+ return tool!;
40
+ }
41
+
42
+ const baseParams = (over: Partial<SubmitParams> = {}): SubmitParams => ({
43
+ name: 'auth-jwt',
44
+ title: 'Auth JWT',
45
+ handoff: '# handoff',
46
+ tasks: [{ id: 't-001', description: 'do it' }],
47
+ ...over,
48
+ });
49
+
50
+ const originalCwd = process.cwd();
51
+ let dir: string;
52
+ beforeEach(async () => {
53
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-submit-plan-'));
54
+ chdir(dir);
55
+ });
56
+ afterEach(async () => {
57
+ chdir(originalCwd);
58
+ await rm(dir, { recursive: true, force: true });
59
+ });
60
+
61
+ describe('submit_plan tool — initiative + plan deps', () => {
62
+ test('persists initiative + depends_on onto the plan manifest entry', async () => {
63
+ await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
64
+ const tool = setup();
65
+ await tool.execute('c', baseParams({ initiative: 'auth-overhaul', depends_on_plans: ['auth-schema'] }));
66
+
67
+ const [entry] = await runPlanIO(readPlansManifest());
68
+ expect(entry.initiative).toBe('auth-overhaul');
69
+ expect(entry.depends_on).toEqual(['auth-schema']);
70
+ });
71
+
72
+ test('kebab-cases the initiative name and reconciles it (member keeps it in-progress)', async () => {
73
+ await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'done', title: 'Auth' }));
74
+ const tool = setup();
75
+ await tool.execute('c', baseParams({ initiative: 'Auth Overhaul' }));
76
+
77
+ const [plan] = await runPlanIO(readPlansManifest());
78
+ expect(plan.initiative).toBe('auth-overhaul');
79
+ // A fresh in-progress member must reopen a prematurely-done initiative.
80
+ const [init] = await runPlanIO(readInitiativesManifest());
81
+ expect(init.status).toBe('in-progress');
82
+ });
83
+
84
+ test('warns softly when the initiative has no registry entry yet', async () => {
85
+ const tool = setup();
86
+ const result = await tool.execute('c', baseParams({ initiative: 'ghost-initiative' }));
87
+ expect(result.content?.[0]?.text).toMatch(/no initiatives\.jsonl entry yet/);
88
+ });
89
+
90
+ test('a standalone plan stores no initiative and no warning', async () => {
91
+ const tool = setup();
92
+ const result = await tool.execute('c', baseParams());
93
+ const [entry] = await runPlanIO(readPlansManifest());
94
+ expect(entry.initiative).toBeUndefined();
95
+ expect(result.content?.[0]?.text).not.toMatch(/initiative/i);
96
+ });
97
+ });
@@ -0,0 +1,72 @@
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 {
8
+ readInitiativesManifest,
9
+ upsertInitiativeEntry,
10
+ } from '../storage/initiatives-manifest.js';
11
+ import { registerUpdateInitiativeTool } from '../tools/update-initiative.js';
12
+
13
+ const runPlanIO = makePlanRuntime();
14
+
15
+ interface CapturedTool {
16
+ execute: (
17
+ id: string,
18
+ params: { initiative: string; status: string; reason?: string },
19
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
20
+ }
21
+
22
+ function setup(): CapturedTool {
23
+ let tool: CapturedTool | undefined;
24
+ const pi = {
25
+ registerTool: (config: CapturedTool) => {
26
+ tool = config;
27
+ },
28
+ } as unknown as Parameters<typeof registerUpdateInitiativeTool>[0];
29
+ registerUpdateInitiativeTool(pi, runPlanIO);
30
+ return tool!;
31
+ }
32
+
33
+ const originalCwd = process.cwd();
34
+ let dir: string;
35
+ beforeEach(async () => {
36
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-update-init-'));
37
+ chdir(dir);
38
+ });
39
+ afterEach(async () => {
40
+ chdir(originalCwd);
41
+ await rm(dir, { recursive: true, force: true });
42
+ });
43
+
44
+ describe('update_initiative tool', () => {
45
+ test('closes an initiative as superseded with a reason', async () => {
46
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
47
+ const tool = setup();
48
+ const result = await tool.execute('c', {
49
+ initiative: 'big',
50
+ status: 'superseded',
51
+ reason: 'merged into mega',
52
+ });
53
+ expect(result.content?.[0]?.text).toMatch(/in-progress → superseded/);
54
+ const [entry] = await runPlanIO(readInitiativesManifest());
55
+ expect(entry.status).toBe('superseded');
56
+ expect(entry.reason).toBe('merged into mega');
57
+ });
58
+
59
+ test('accepts a .plans/<name> hint', async () => {
60
+ await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
61
+ const tool = setup();
62
+ await tool.execute('c', { initiative: '.plans/big', status: 'abandoned', reason: 'dropped' });
63
+ const [entry] = await runPlanIO(readInitiativesManifest());
64
+ expect(entry.status).toBe('abandoned');
65
+ });
66
+
67
+ test('reports not_found for an unknown initiative (no throw)', async () => {
68
+ const tool = setup();
69
+ const result = await tool.execute('c', { initiative: 'ghost', status: 'done' });
70
+ expect((result.details as { error?: string }).error).toBe('not_found');
71
+ });
72
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * /initiatives command — list, filter, and sort initiatives interactively.
3
+ *
4
+ * The initiative sibling of /plans. Reads the initiatives registry plus the
5
+ * plans manifest to roll up member-plan progress (done/total, ready/blocked).
6
+ */
7
+
8
+ import { Effect } from 'effect';
9
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
10
+ import type { RunPlanIO } from '../effects/runtime.js';
11
+ import { FileSystem } from '../effects/filesystem.js';
12
+ import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
13
+ import {
14
+ readInitiativesManifest,
15
+ type InitiativeManifestEntry,
16
+ } from '../storage/initiatives-manifest.js';
17
+ import { initiativeRollup } from '../initiative.js';
18
+ import type { InitiativeStatus } from '../types.js';
19
+
20
+ export type StatusFilter = 'all' | InitiativeStatus;
21
+
22
+ export interface InitiativeListItem {
23
+ name: string;
24
+ title: string;
25
+ status: InitiativeStatus;
26
+ created_at: string;
27
+ totalPlans: number;
28
+ donePlans: number;
29
+ ready: number;
30
+ blocked: number;
31
+ }
32
+
33
+ export function filterInitiatives(
34
+ items: InitiativeListItem[],
35
+ filter: StatusFilter,
36
+ ): InitiativeListItem[] {
37
+ if (filter === 'all') return items;
38
+ return items.filter((i) => i.status === filter);
39
+ }
40
+
41
+ const STATUS_ICON: Record<InitiativeStatus, string> = {
42
+ 'in-progress': '🔵',
43
+ done: '✅',
44
+ superseded: '🔄',
45
+ abandoned: '❌',
46
+ };
47
+
48
+ export function formatInitiativeList(items: InitiativeListItem[], filter: StatusFilter): string {
49
+ if (items.length === 0) {
50
+ return filter === 'all'
51
+ ? 'No initiatives found in .plans/initiatives.jsonl'
52
+ : `No initiatives with status "${filter}"`;
53
+ }
54
+ const header =
55
+ filter === 'all'
56
+ ? `All initiatives (${items.length})`
57
+ : `Initiatives: ${filter} (${items.length})`;
58
+ const lines = items.map((i) => {
59
+ const icon = STATUS_ICON[i.status];
60
+ const progress =
61
+ i.totalPlans > 0
62
+ ? ` [${i.donePlans}/${i.totalPlans} plans, ready ${i.ready}, blocked ${i.blocked}]`
63
+ : ' [no plans]';
64
+ const date = i.created_at.slice(0, 10);
65
+ return ` ${icon} ${i.name} — ${i.title}${progress} (${date})`;
66
+ });
67
+ return `${header}\n${lines.join('\n')}`;
68
+ }
69
+
70
+ export function loadInitiativeListItems(): Effect.Effect<InitiativeListItem[], never, FileSystem> {
71
+ return Effect.gen(function* () {
72
+ const initiatives = yield* Effect.orElseSucceed(
73
+ readInitiativesManifest(),
74
+ () => [] as InitiativeManifestEntry[],
75
+ );
76
+ const plans = yield* Effect.orElseSucceed(
77
+ readPlansManifest(),
78
+ () => [] as PlanManifestEntry[],
79
+ );
80
+ return initiatives.map((entry): InitiativeListItem => {
81
+ const r = initiativeRollup(entry.name, plans);
82
+ return {
83
+ name: entry.name,
84
+ title: entry.title,
85
+ status: entry.status,
86
+ created_at: entry.created_at,
87
+ totalPlans: r.total,
88
+ donePlans: r.done,
89
+ ready: r.ready,
90
+ blocked: r.blocked,
91
+ };
92
+ });
93
+ });
94
+ }
95
+
96
+ const FILTER_ALIASES: Record<string, StatusFilter> = {
97
+ all: 'all',
98
+ 'in-progress': 'in-progress',
99
+ active: 'in-progress',
100
+ done: 'done',
101
+ completed: 'done',
102
+ superseded: 'superseded',
103
+ abandoned: 'abandoned',
104
+ };
105
+
106
+ export function parseFilter(raw: string): StatusFilter {
107
+ for (const token of raw.toLowerCase().split(/\s+/)) {
108
+ if (FILTER_ALIASES[token]) return FILTER_ALIASES[token];
109
+ }
110
+ return 'all';
111
+ }
112
+
113
+ export async function handleListInitiatives(
114
+ ctx: ExtensionCommandContext,
115
+ runPlanIO: RunPlanIO,
116
+ args?: string,
117
+ ): Promise<void> {
118
+ const items = await runPlanIO(loadInitiativeListItems());
119
+ if (items.length === 0) {
120
+ ctx.ui.notify('No initiatives found in .plans/initiatives.jsonl', 'info');
121
+ return;
122
+ }
123
+
124
+ let filter: StatusFilter = 'all';
125
+ if (args?.trim()) {
126
+ filter = parseFilter(args.trim());
127
+ } else {
128
+ const choice = await ctx.ui.select('Filter initiatives by status:', [
129
+ 'All',
130
+ 'In-progress',
131
+ 'Done',
132
+ 'Superseded',
133
+ 'Abandoned',
134
+ ]);
135
+ if (!choice) return;
136
+ const map: Record<string, StatusFilter> = {
137
+ All: 'all',
138
+ 'In-progress': 'in-progress',
139
+ Done: 'done',
140
+ Superseded: 'superseded',
141
+ Abandoned: 'abandoned',
142
+ };
143
+ filter = map[choice] ?? 'all';
144
+ }
145
+
146
+ const filtered = filterInitiatives(items, filter);
147
+ ctx.ui.notify(formatInitiativeList(filtered, filter), 'info');
148
+ }
@@ -10,6 +10,7 @@ export const PLAN_TOOLS = [
10
10
  'find',
11
11
  'ls',
12
12
  'submit_plan',
13
+ 'submit_initiative',
13
14
  'revise_plan',
14
15
  'preview_prototype',
15
16
  'write',
@@ -19,6 +20,8 @@ export const PLAN_TOOLS = [
19
20
  'plan_status',
20
21
  'set_active_plan',
21
22
  'update_plan',
23
+ 'update_initiative',
24
+ 'initiative_status',
22
25
  'reconcile_plans',
23
26
  ];
24
27
 
@@ -33,6 +36,8 @@ export const EXEC_TOOLS = [
33
36
  'plan_status',
34
37
  'set_active_plan',
35
38
  'update_plan',
39
+ 'update_initiative',
40
+ 'initiative_status',
36
41
  'reconcile_plans',
37
42
  ];
38
43