@dreki-gg/pi-plan-mode 0.25.0 → 0.26.1

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 (61) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/extensions/plan-mode/__tests__/git.test.ts +17 -0
  3. package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
  4. package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
  5. package/extensions/plan-mode/__tests__/prompts.test.ts +23 -0
  6. package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
  7. package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
  8. package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
  9. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
  10. package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
  11. package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
  12. package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
  13. package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
  14. package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
  15. package/extensions/plan-mode/commands/list-plans.ts +21 -168
  16. package/extensions/plan-mode/exec-pending.ts +59 -0
  17. package/extensions/plan-mode/git.ts +21 -0
  18. package/extensions/plan-mode/index.ts +9 -7
  19. package/extensions/plan-mode/prompts.ts +6 -1
  20. package/extensions/plan-mode/references/context.ts +5 -5
  21. package/extensions/plan-mode/references/plan-index.ts +1 -1
  22. package/extensions/plan-mode/resolve-plan.ts +5 -4
  23. package/extensions/plan-mode/resume.ts +7 -5
  24. package/extensions/plan-mode/tools/add-task.ts +1 -1
  25. package/extensions/plan-mode/tools/initiative-status.ts +6 -6
  26. package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
  27. package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
  28. package/extensions/plan-mode/tools/revise-plan.ts +9 -7
  29. package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
  30. package/extensions/plan-mode/tools/submit-plan.ts +18 -8
  31. package/extensions/plan-mode/tools/update-initiative.ts +2 -2
  32. package/extensions/plan-mode/tools/update-plan.ts +3 -3
  33. package/extensions/plan-mode/types.ts +17 -51
  34. package/extensions/plan-mode/utils.ts +2 -29
  35. package/package.json +2 -1
  36. package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
  37. package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
  38. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
  39. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
  40. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
  41. package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
  42. package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
  43. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
  44. package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
  45. package/extensions/plan-mode/__tests__/schema.test.ts +0 -319
  46. package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
  47. package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -94
  48. package/extensions/plan-mode/effects/filesystem.ts +0 -65
  49. package/extensions/plan-mode/effects/runtime.ts +0 -24
  50. package/extensions/plan-mode/errors.ts +0 -105
  51. package/extensions/plan-mode/initiative.ts +0 -172
  52. package/extensions/plan-mode/plan-storage.ts +0 -6
  53. package/extensions/plan-mode/reconcile.ts +0 -235
  54. package/extensions/plan-mode/schema.ts +0 -97
  55. package/extensions/plan-mode/storage/atomic-write.ts +0 -75
  56. package/extensions/plan-mode/storage/file-lock.ts +0 -49
  57. package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
  58. package/extensions/plan-mode/storage/plan-storage.ts +0 -88
  59. package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
  60. package/extensions/plan-mode/storage/task-storage.ts +0 -111
  61. package/extensions/plan-mode/task-status.ts +0 -46
@@ -1,85 +0,0 @@
1
- /**
2
- * Regression: concurrent read-modify-write on the registries must not lose
3
- * updates. Each `Effect.runPromise` mimics a separate tool execute running in
4
- * the same block (e.g. three `submit_initiative` calls). Before the file-lock
5
- * fix, their reads all saw the same starting file and the last write clobbered
6
- * the rest — only one entry survived.
7
- */
8
-
9
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
- import { Effect } from 'effect';
11
- import { chdir } from 'node:process';
12
- import { mkdtemp, rm } from 'node:fs/promises';
13
- import { join } from 'node:path';
14
- import { tmpdir } from 'node:os';
15
- import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
16
- import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
17
- import {
18
- readInitiativesManifest,
19
- upsertInitiativeEntry,
20
- } from '../storage/initiatives-manifest.js';
21
- import { writeTasksJsonl, readTasksJsonl, updateTask } from '../storage/task-storage.js';
22
- import type { TaskMeta, TaskRecord } from '../types.js';
23
-
24
- // Each call gets its OWN runPromise so the writes are genuinely concurrent —
25
- // the same shape as independent tool executes sharing one Node process.
26
- const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
27
- Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
28
-
29
- const originalCwd = process.cwd();
30
- let dir: string;
31
-
32
- beforeEach(async () => {
33
- dir = await mkdtemp(join(tmpdir(), 'plan-mode-concurrent-'));
34
- chdir(dir);
35
- });
36
- afterEach(async () => {
37
- chdir(originalCwd);
38
- await rm(dir, { recursive: true, force: true });
39
- });
40
-
41
- describe('concurrent registry writes', () => {
42
- test('5 concurrent plan upserts all persist (no lost updates)', async () => {
43
- const names = ['a', 'b', 'c', 'd', 'e'];
44
- await Promise.all(
45
- names.map((n) => run(upsertPlanEntry(n, { status: 'in-progress', title: n.toUpperCase() }))),
46
- );
47
- const entries = await run(readPlansManifest());
48
- expect(entries.map((e) => e.name).sort()).toEqual(names);
49
- });
50
-
51
- test('3 concurrent initiative upserts all persist (no lost updates)', async () => {
52
- const names = ['one', 'two', 'three'];
53
- await Promise.all(
54
- names.map((n) => run(upsertInitiativeEntry(n, { status: 'in-progress', title: n }))),
55
- );
56
- const entries = await run(readInitiativesManifest());
57
- expect(entries.map((e) => e.name).sort()).toEqual([...names].sort());
58
- });
59
-
60
- test('concurrent task updates to the same plan all persist', async () => {
61
- const planDir = join('.plans', 'p');
62
- const meta: TaskMeta = {
63
- _type: 'meta',
64
- plan_name: 'p',
65
- title: 'P',
66
- created_at: 'now',
67
- };
68
- const tasks: TaskRecord[] = ['t-001', 't-002', 't-003'].map((id) => ({
69
- _type: 'task',
70
- id,
71
- description: id,
72
- status: 'pending',
73
- created_at: 'now',
74
- updated_at: 'now',
75
- }));
76
- await run(writeTasksJsonl(planDir, meta, tasks));
77
-
78
- await Promise.all(
79
- tasks.map((t) => run(updateTask(planDir, t.id, { status: 'done' }))),
80
- );
81
-
82
- const snapshot = await run(readTasksJsonl(planDir));
83
- expect(snapshot?.tasks.every((t) => t.status === 'done')).toBe(true);
84
- });
85
- });
@@ -1,159 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { Effect } from 'effect';
3
- import { chdir } from 'node:process';
4
- import { mkdtemp, rm } from 'node:fs/promises';
5
- import { join } from 'node:path';
6
- import { tmpdir } from 'node:os';
7
- import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
8
- import {
9
- computePlanReadiness,
10
- initiativeRollup,
11
- isInitiativeFinalizable,
12
- reconcileInitiativeForPlan,
13
- reconcileInitiativeStatus,
14
- } from '../initiative.js';
15
- import { upsertPlanEntry, type PlanManifestEntry } from '../storage/plans-manifest.js';
16
- import {
17
- readInitiativesManifest,
18
- upsertInitiativeEntry,
19
- } from '../storage/initiatives-manifest.js';
20
-
21
- const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
22
- Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
23
-
24
- function plan(partial: Partial<PlanManifestEntry> & { name: string }): PlanManifestEntry {
25
- return {
26
- _type: 'plan',
27
- status: 'in-progress',
28
- title: partial.name,
29
- created_at: 'now',
30
- completed_at: null,
31
- ...partial,
32
- };
33
- }
34
-
35
- describe('computePlanReadiness (pure)', () => {
36
- test('a plan with no deps is ready', () => {
37
- const rows = computePlanReadiness([plan({ name: 'a' })]);
38
- expect(rows).toEqual([{ name: 'a', ready: true, blockedBy: [] }]);
39
- });
40
-
41
- test('a plan whose dep is done is ready; otherwise blocked', () => {
42
- const plans = [
43
- plan({ name: 'schema', status: 'done' }),
44
- plan({ name: 'api', depends_on: ['schema'] }),
45
- plan({ name: 'ui', depends_on: ['api'] }),
46
- ];
47
- const byName = new Map(computePlanReadiness(plans).map((r) => [r.name, r]));
48
- expect(byName.get('api')?.ready).toBe(true);
49
- expect(byName.get('ui')?.ready).toBe(false);
50
- expect(byName.get('ui')?.blockedBy).toEqual(['api']);
51
- });
52
-
53
- test('only in-progress plans are reported (done dep itself is not a row)', () => {
54
- const rows = computePlanReadiness([
55
- plan({ name: 'schema', status: 'done' }),
56
- plan({ name: 'api', depends_on: ['schema'] }),
57
- ]);
58
- expect(rows.map((r) => r.name)).toEqual(['api']);
59
- });
60
-
61
- test('an unknown or terminally-closed dep keeps a plan blocked', () => {
62
- const rows = computePlanReadiness([
63
- plan({ name: 'ghost-dep', status: 'abandoned' }),
64
- plan({ name: 'x', depends_on: ['ghost-dep', 'missing'] }),
65
- ]);
66
- const x = rows.find((r) => r.name === 'x');
67
- expect(x?.ready).toBe(false);
68
- expect(x?.blockedBy.sort()).toEqual(['ghost-dep', 'missing']);
69
- });
70
- });
71
-
72
- describe('isInitiativeFinalizable / initiativeRollup (pure)', () => {
73
- const plans = [
74
- plan({ name: 'a', initiative: 'big', status: 'done' }),
75
- plan({ name: 'b', initiative: 'big', status: 'in-progress', depends_on: ['a'] }),
76
- plan({ name: 'c', initiative: 'big', status: 'in-progress', depends_on: ['b'] }),
77
- plan({ name: 'solo' }), // not a member
78
- ];
79
-
80
- test('not finalizable while a member is in-progress', () => {
81
- expect(isInitiativeFinalizable('big', plans)).toBe(false);
82
- });
83
-
84
- test('finalizable when all members are terminal', () => {
85
- const allClosed = [
86
- plan({ name: 'a', initiative: 'big', status: 'done' }),
87
- plan({ name: 'b', initiative: 'big', status: 'superseded' }),
88
- ];
89
- expect(isInitiativeFinalizable('big', allClosed)).toBe(true);
90
- });
91
-
92
- test('an empty initiative is never finalizable', () => {
93
- expect(isInitiativeFinalizable('nope', plans)).toBe(false);
94
- });
95
-
96
- test('rollup counts members, progress, and readiness', () => {
97
- const r = initiativeRollup('big', plans);
98
- expect(r.total).toBe(3);
99
- expect(r.done).toBe(1);
100
- expect(r.inProgress).toBe(2);
101
- expect(r.ready).toBe(1); // b (dep a is done)
102
- expect(r.blocked).toBe(1); // c (dep b is in-progress)
103
- const b = r.members.find((m) => m.name === 'b');
104
- expect(b?.ready).toBe(true);
105
- const c = r.members.find((m) => m.name === 'c');
106
- expect(c?.blockedBy).toEqual(['b']);
107
- });
108
- });
109
-
110
- describe('reconcileInitiativeStatus / reconcileInitiativeForPlan (IO projection)', () => {
111
- const originalCwd = process.cwd();
112
- let dir: string;
113
-
114
- beforeEach(async () => {
115
- dir = await mkdtemp(join(tmpdir(), 'plan-mode-init-proj-'));
116
- chdir(dir);
117
- });
118
- afterEach(async () => {
119
- chdir(originalCwd);
120
- await rm(dir, { recursive: true, force: true });
121
- });
122
-
123
- test('flips an initiative to done when its last in-progress member completes', async () => {
124
- await run(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
125
- await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
126
- await run(upsertPlanEntry('b', { status: 'in-progress', title: 'B', initiative: 'big' }));
127
-
128
- await run(reconcileInitiativeStatus('big'));
129
- let [entry] = await run(readInitiativesManifest());
130
- expect(entry.status).toBe('in-progress'); // b still open
131
-
132
- await run(upsertPlanEntry('b', { status: 'done', title: 'B', initiative: 'big' }));
133
- await run(reconcileInitiativeForPlan('b'));
134
- [entry] = await run(readInitiativesManifest());
135
- expect(entry.status).toBe('done');
136
- });
137
-
138
- test('reopens a done initiative when a member goes back in-progress', async () => {
139
- await run(upsertInitiativeEntry('big', { status: 'done', title: 'Big' }));
140
- await run(upsertPlanEntry('a', { status: 'in-progress', title: 'A', initiative: 'big' }));
141
- await run(reconcileInitiativeStatus('big'));
142
- const [entry] = await run(readInitiativesManifest());
143
- expect(entry.status).toBe('in-progress');
144
- });
145
-
146
- test('never overrides a manually-closed (superseded) initiative', async () => {
147
- await run(upsertInitiativeEntry('big', { status: 'superseded', title: 'Big', reason: 'x' }));
148
- await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
149
- await run(reconcileInitiativeStatus('big'));
150
- const [entry] = await run(readInitiativesManifest());
151
- expect(entry.status).toBe('superseded');
152
- });
153
-
154
- test('reconcileInitiativeForPlan is a no-op for a standalone plan', async () => {
155
- await run(upsertPlanEntry('solo', { status: 'done', title: 'Solo' }));
156
- await run(reconcileInitiativeForPlan('solo'));
157
- await expect(run(readInitiativesManifest())).resolves.toEqual([]);
158
- });
159
- });
@@ -1,89 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { Effect } from 'effect';
3
- import { chdir } from 'node:process';
4
- import { mkdtemp, rm } from 'node:fs/promises';
5
- import { join } from 'node:path';
6
- import { tmpdir } from 'node:os';
7
- import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
8
- import {
9
- readInitiativesManifest,
10
- upsertInitiativeEntry,
11
- writeInitiativesManifest,
12
- } from '../storage/initiatives-manifest.js';
13
-
14
- const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
15
- Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
16
-
17
- const originalCwd = process.cwd();
18
- let dir: string;
19
-
20
- beforeEach(async () => {
21
- dir = await mkdtemp(join(tmpdir(), 'plan-mode-initiatives-'));
22
- chdir(dir);
23
- });
24
- afterEach(async () => {
25
- chdir(originalCwd);
26
- await rm(dir, { recursive: true, force: true });
27
- });
28
-
29
- describe('initiatives.jsonl manifest', () => {
30
- test('round trips entries', async () => {
31
- const entry = {
32
- _type: 'initiative' as const,
33
- name: 'auth-overhaul',
34
- status: 'in-progress' as const,
35
- title: 'Auth Overhaul',
36
- created_at: 'now',
37
- completed_at: null,
38
- };
39
- await run(writeInitiativesManifest([entry]));
40
- await expect(run(readInitiativesManifest())).resolves.toEqual([entry]);
41
- });
42
-
43
- test('missing manifest returns an empty list', async () => {
44
- await expect(run(readInitiativesManifest())).resolves.toEqual([]);
45
- });
46
-
47
- test('upserts new entries', async () => {
48
- await run(upsertInitiativeEntry('big-thing', { status: 'in-progress', title: 'Big Thing' }));
49
- const entries = await run(readInitiativesManifest());
50
- expect(entries).toHaveLength(1);
51
- expect(entries[0]?.name).toBe('big-thing');
52
- expect(entries[0]?.title).toBe('Big Thing');
53
- expect(entries[0]?.completed_at).toBeNull();
54
- });
55
-
56
- test('upserts existing entries without changing created_at', async () => {
57
- await run(
58
- writeInitiativesManifest([
59
- {
60
- _type: 'initiative',
61
- name: 'i',
62
- status: 'in-progress',
63
- title: 'Old',
64
- created_at: 'created',
65
- completed_at: null,
66
- },
67
- ]),
68
- );
69
- await run(upsertInitiativeEntry('i', { status: 'done', title: 'New' }));
70
- const [entry] = await run(readInitiativesManifest());
71
- expect(entry.created_at).toBe('created');
72
- expect(entry.status).toBe('done');
73
- expect(entry.completed_at).toBeString();
74
- });
75
-
76
- test('records a reason for terminal statuses and clears completed_at on reopen', async () => {
77
- await run(upsertInitiativeEntry('i', { status: 'in-progress', title: 'I' }));
78
- await run(upsertInitiativeEntry('i', { status: 'superseded', reason: 'merged into j' }));
79
- let [entry] = await run(readInitiativesManifest());
80
- expect(entry.status).toBe('superseded');
81
- expect(entry.reason).toBe('merged into j');
82
- expect(entry.completed_at).toBeString();
83
-
84
- await run(upsertInitiativeEntry('i', { status: 'in-progress' }));
85
- [entry] = await run(readInitiativesManifest());
86
- expect(entry.status).toBe('in-progress');
87
- expect(entry.completed_at).toBeNull();
88
- });
89
- });
@@ -1,59 +0,0 @@
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
- });
@@ -1,253 +0,0 @@
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
- });