@dreki-gg/pi-plan-mode 0.21.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.
- package/CHANGELOG.md +25 -0
- package/README.md +48 -5
- package/bin/clean-plans.js +67 -49
- package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
- package/extensions/plan-mode/__tests__/list-plans.test.ts +253 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
- package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
- package/extensions/plan-mode/commands/list-plans.ts +229 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/html/render.ts +41 -18
- package/extensions/plan-mode/index.ts +31 -0
- package/extensions/plan-mode/initiative.ts +168 -0
- package/extensions/plan-mode/prompts.ts +5 -1
- package/extensions/plan-mode/reconcile.ts +65 -0
- package/extensions/plan-mode/schema.ts +28 -0
- package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
- package/extensions/plan-mode/storage/plan-storage.ts +11 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
- package/extensions/plan-mode/tools/initiative-status.ts +191 -0
- package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
- package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
- package/extensions/plan-mode/tools/revise-plan.ts +22 -0
- package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
- package/extensions/plan-mode/tools/submit-plan.ts +37 -4
- package/extensions/plan-mode/tools/update-initiative.ts +120 -0
- package/extensions/plan-mode/tools/update-plan.ts +3 -0
- package/extensions/plan-mode/types.ts +3 -0
- package/package.json +2 -3
- package/skills/planning-context/SKILL.md +11 -0
- package/skills/visual-prototype/SKILL.md +4 -2
- package/extensions/plan-mode/html/templates/prototype.pug +0 -25
- package/extensions/plan-mode/pug.d.ts +0 -5
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
upsertPlanEntry,
|
|
11
11
|
writePlansManifest,
|
|
12
12
|
} from '../storage/plans-manifest.js';
|
|
13
|
-
import {
|
|
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
|
+
});
|