@dreki-gg/pi-plan-mode 0.26.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.
- package/CHANGELOG.md +12 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
- package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
- package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
- package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
- package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
- package/extensions/plan-mode/commands/list-plans.ts +21 -168
- package/extensions/plan-mode/exec-pending.ts +59 -0
- package/extensions/plan-mode/index.ts +8 -7
- package/extensions/plan-mode/references/context.ts +5 -5
- package/extensions/plan-mode/references/plan-index.ts +1 -1
- package/extensions/plan-mode/resolve-plan.ts +4 -4
- package/extensions/plan-mode/resume.ts +6 -5
- package/extensions/plan-mode/tools/add-task.ts +1 -1
- package/extensions/plan-mode/tools/initiative-status.ts +6 -6
- package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
- package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
- package/extensions/plan-mode/tools/revise-plan.ts +7 -7
- package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
- package/extensions/plan-mode/tools/submit-plan.ts +7 -7
- package/extensions/plan-mode/tools/update-initiative.ts +2 -2
- package/extensions/plan-mode/tools/update-plan.ts +3 -3
- package/extensions/plan-mode/types.ts +17 -59
- package/extensions/plan-mode/utils.ts +2 -29
- package/package.json +2 -1
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
- package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
- package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
- package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
- package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
- package/extensions/plan-mode/__tests__/schema.test.ts +0 -334
- package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
- package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -101
- package/extensions/plan-mode/effects/filesystem.ts +0 -65
- package/extensions/plan-mode/effects/runtime.ts +0 -24
- package/extensions/plan-mode/errors.ts +0 -105
- package/extensions/plan-mode/initiative.ts +0 -172
- package/extensions/plan-mode/plan-storage.ts +0 -6
- package/extensions/plan-mode/reconcile.ts +0 -235
- package/extensions/plan-mode/schema.ts +0 -99
- package/extensions/plan-mode/storage/atomic-write.ts +0 -75
- package/extensions/plan-mode/storage/file-lock.ts +0 -49
- package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
- package/extensions/plan-mode/storage/plan-storage.ts +0 -88
- package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
- package/extensions/plan-mode/storage/task-storage.ts +0 -111
- package/extensions/plan-mode/task-status.ts +0 -46
|
@@ -1,188 +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 { writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
-
import {
|
|
9
|
-
readPlansManifest,
|
|
10
|
-
upsertPlanEntry,
|
|
11
|
-
writePlansManifest,
|
|
12
|
-
} from '../storage/plans-manifest.js';
|
|
13
|
-
import { upsertInitiativeEntry, readInitiativesManifest } from '../storage/initiatives-manifest.js';
|
|
14
|
-
import {
|
|
15
|
-
applyInitiativeReconcile,
|
|
16
|
-
applyReconcile,
|
|
17
|
-
collectInitiativeDrift,
|
|
18
|
-
collectPlanDrift,
|
|
19
|
-
} from '../reconcile.js';
|
|
20
|
-
import type { TaskMeta, TaskRecord, TaskStatus } from '../types.js';
|
|
21
|
-
|
|
22
|
-
const runPlanIO = makePlanRuntime();
|
|
23
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
24
|
-
|
|
25
|
-
const meta = (name: string): TaskMeta => ({
|
|
26
|
-
_type: 'meta',
|
|
27
|
-
title: `Title ${name}`,
|
|
28
|
-
plan_name: name,
|
|
29
|
-
created_at: now,
|
|
30
|
-
});
|
|
31
|
-
const task = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
|
|
32
|
-
_type: 'task',
|
|
33
|
-
id,
|
|
34
|
-
description: `task ${id}`,
|
|
35
|
-
status,
|
|
36
|
-
origin: 'plan',
|
|
37
|
-
created_at: now,
|
|
38
|
-
updated_at: now,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const originalCwd = process.cwd();
|
|
42
|
-
let dir: string;
|
|
43
|
-
beforeEach(async () => {
|
|
44
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-reconcile-'));
|
|
45
|
-
chdir(dir);
|
|
46
|
-
});
|
|
47
|
-
afterEach(async () => {
|
|
48
|
-
chdir(originalCwd);
|
|
49
|
-
await rm(dir, { recursive: true, force: true });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('collectPlanDrift', () => {
|
|
53
|
-
test('flags a fully-done plan still registered in-progress as status drift', async () => {
|
|
54
|
-
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
|
|
55
|
-
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
56
|
-
|
|
57
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
58
|
-
const alpha = rows.find((r) => r.name === 'alpha')!;
|
|
59
|
-
expect(alpha.drift).toBe('status');
|
|
60
|
-
expect(alpha.derivedStatus).toBe('done');
|
|
61
|
-
expect(alpha.resolved).toBe(1);
|
|
62
|
-
expect(alpha.total).toBe(1);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('reports an in-sync plan with no drift', async () => {
|
|
66
|
-
await runPlanIO(writeTasksJsonl('.plans/beta', meta('beta'), [task('t-001', 'pending')]));
|
|
67
|
-
await runPlanIO(upsertPlanEntry('beta', { status: 'in-progress', title: 'Title beta' }));
|
|
68
|
-
|
|
69
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
70
|
-
expect(rows.find((r) => r.name === 'beta')!.drift).toBeUndefined();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('flags registry-only plans (no tasks.jsonl)', async () => {
|
|
74
|
-
await runPlanIO(upsertPlanEntry('ghost', { status: 'in-progress', title: 'Ghost' }));
|
|
75
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
76
|
-
const ghost = rows.find((r) => r.name === 'ghost')!;
|
|
77
|
-
expect(ghost.drift).toBe('registry-only');
|
|
78
|
-
expect(ghost.hasTasks).toBe(false);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('flags orphan task dirs (tasks.jsonl, no registry entry)', async () => {
|
|
82
|
-
await runPlanIO(writeTasksJsonl('.plans/orphan', meta('orphan'), [task('t-001', 'done')]));
|
|
83
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
84
|
-
const orphan = rows.find((r) => r.name === 'orphan')!;
|
|
85
|
-
expect(orphan.drift).toBe('orphan');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('never flags a manually-closed terminal status as drift', async () => {
|
|
89
|
-
await runPlanIO(writeTasksJsonl('.plans/sup', meta('sup'), [task('t-001', 'pending')]));
|
|
90
|
-
await runPlanIO(
|
|
91
|
-
writePlansManifest([
|
|
92
|
-
{
|
|
93
|
-
_type: 'plan',
|
|
94
|
-
name: 'sup',
|
|
95
|
-
status: 'superseded',
|
|
96
|
-
title: 'Title sup',
|
|
97
|
-
created_at: now,
|
|
98
|
-
completed_at: now,
|
|
99
|
-
reason: 'absorbed',
|
|
100
|
-
},
|
|
101
|
-
]),
|
|
102
|
-
);
|
|
103
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
104
|
-
expect(rows.find((r) => r.name === 'sup')!.drift).toBeUndefined();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('applyReconcile', () => {
|
|
109
|
-
test('repairs only status drift and projects derived status into the registry', async () => {
|
|
110
|
-
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
|
|
111
|
-
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
112
|
-
|
|
113
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
114
|
-
const repaired = await runPlanIO(applyReconcile(rows));
|
|
115
|
-
expect(repaired.map((r) => r.name)).toEqual(['alpha']);
|
|
116
|
-
|
|
117
|
-
const [entry] = await runPlanIO(readPlansManifest());
|
|
118
|
-
expect(entry.status).toBe('done');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test('classifies an upgrade (in-progress registry, done tasks) as direction:upgrade', async () => {
|
|
122
|
-
await runPlanIO(writeTasksJsonl('.plans/up', meta('up'), [task('t-001', 'done')]));
|
|
123
|
-
await runPlanIO(upsertPlanEntry('up', { status: 'in-progress', title: 'Up' }));
|
|
124
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
125
|
-
expect(rows.find((r) => r.name === 'up')!.direction).toBe('upgrade');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('a done plan with incomplete tasks is flagged downgrade and NOT auto-repaired', async () => {
|
|
129
|
-
// Work merged but tasks never marked done: registry done, tasks in-progress.
|
|
130
|
-
await runPlanIO(
|
|
131
|
-
writeTasksJsonl('.plans/merged', meta('merged'), [task('t-001', 'pending')]),
|
|
132
|
-
);
|
|
133
|
-
await runPlanIO(
|
|
134
|
-
writePlansManifest([
|
|
135
|
-
{
|
|
136
|
-
_type: 'plan',
|
|
137
|
-
name: 'merged',
|
|
138
|
-
status: 'done',
|
|
139
|
-
title: 'Merged',
|
|
140
|
-
created_at: now,
|
|
141
|
-
completed_at: now,
|
|
142
|
-
},
|
|
143
|
-
]),
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
const rows = await runPlanIO(collectPlanDrift());
|
|
147
|
-
const merged = rows.find((r) => r.name === 'merged')!;
|
|
148
|
-
expect(merged.drift).toBe('status');
|
|
149
|
-
expect(merged.direction).toBe('downgrade');
|
|
150
|
-
|
|
151
|
-
// --apply must NOT regress it back to in-progress.
|
|
152
|
-
const repaired = await runPlanIO(applyReconcile(rows));
|
|
153
|
-
expect(repaired.map((r) => r.name)).not.toContain('merged');
|
|
154
|
-
const [entry] = await runPlanIO(readPlansManifest());
|
|
155
|
-
expect(entry.status).toBe('done');
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('initiative drift', () => {
|
|
160
|
-
test('flags an initiative whose members are all closed but registry is in-progress', async () => {
|
|
161
|
-
await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
162
|
-
await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
163
|
-
await runPlanIO(upsertPlanEntry('b', { status: 'done', title: 'B', initiative: 'big' }));
|
|
164
|
-
|
|
165
|
-
const rows = await runPlanIO(collectInitiativeDrift());
|
|
166
|
-
const big = rows.find((r) => r.name === 'big')!;
|
|
167
|
-
expect(big.drift).toBe('status');
|
|
168
|
-
expect(big.derivedStatus).toBe('done');
|
|
169
|
-
expect(big.members).toBe(2);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test('applyInitiativeReconcile repairs initiative status drift', async () => {
|
|
173
|
-
await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
174
|
-
await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
175
|
-
|
|
176
|
-
const repaired = await runPlanIO(applyInitiativeReconcile(await runPlanIO(collectInitiativeDrift())));
|
|
177
|
-
expect(repaired.map((r) => r.name)).toEqual(['big']);
|
|
178
|
-
const [entry] = await runPlanIO(readInitiativesManifest());
|
|
179
|
-
expect(entry.status).toBe('done');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test('never flags a manually-closed (abandoned) initiative as drift', async () => {
|
|
183
|
-
await runPlanIO(upsertInitiativeEntry('big', { status: 'abandoned', title: 'Big', reason: 'x' }));
|
|
184
|
-
await runPlanIO(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
185
|
-
const rows = await runPlanIO(collectInitiativeDrift());
|
|
186
|
-
expect(rows.find((r) => r.name === 'big')!.drift).toBeUndefined();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Either } from 'effect';
|
|
3
|
-
import {
|
|
4
|
-
decodeExecPendingConfig,
|
|
5
|
-
decodeInitiativeManifestEntry,
|
|
6
|
-
decodePlanManifestEntry,
|
|
7
|
-
decodeTaskMeta,
|
|
8
|
-
decodeTaskRecord,
|
|
9
|
-
decodeTasksLine,
|
|
10
|
-
} from '../schema.js';
|
|
11
|
-
|
|
12
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
13
|
-
|
|
14
|
-
const isOk = (value: unknown, decode: (v: unknown) => Either.Either<unknown, unknown>): boolean =>
|
|
15
|
-
Either.isRight(decode(value));
|
|
16
|
-
|
|
17
|
-
describe('task record schema', () => {
|
|
18
|
-
test('accepts a full task record', () => {
|
|
19
|
-
expect(
|
|
20
|
-
isOk(
|
|
21
|
-
{
|
|
22
|
-
_type: 'task',
|
|
23
|
-
id: 't-001',
|
|
24
|
-
description: 'Do work',
|
|
25
|
-
details: 'Full instructions',
|
|
26
|
-
status: 'pending',
|
|
27
|
-
depends_on: ['t-000'],
|
|
28
|
-
notes: 'note',
|
|
29
|
-
created_at: now,
|
|
30
|
-
updated_at: now,
|
|
31
|
-
},
|
|
32
|
-
decodeTaskRecord,
|
|
33
|
-
),
|
|
34
|
-
).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('accepts a lightweight task record without details', () => {
|
|
38
|
-
expect(
|
|
39
|
-
isOk(
|
|
40
|
-
{
|
|
41
|
-
_type: 'task',
|
|
42
|
-
id: 't-001',
|
|
43
|
-
description: 'Do work',
|
|
44
|
-
status: 'pending',
|
|
45
|
-
created_at: now,
|
|
46
|
-
updated_at: now,
|
|
47
|
-
},
|
|
48
|
-
decodeTaskRecord,
|
|
49
|
-
),
|
|
50
|
-
).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('accepts a deferred discovered task', () => {
|
|
54
|
-
expect(
|
|
55
|
-
isOk(
|
|
56
|
-
{
|
|
57
|
-
_type: 'task',
|
|
58
|
-
id: 't-011',
|
|
59
|
-
description: 'Follow-up discovered mid-run',
|
|
60
|
-
status: 'deferred',
|
|
61
|
-
origin: 'discovered',
|
|
62
|
-
notes: 'noticed while implementing',
|
|
63
|
-
created_at: now,
|
|
64
|
-
updated_at: now,
|
|
65
|
-
},
|
|
66
|
-
decodeTaskRecord,
|
|
67
|
-
),
|
|
68
|
-
).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('rejects an unknown origin', () => {
|
|
72
|
-
expect(
|
|
73
|
-
isOk(
|
|
74
|
-
{
|
|
75
|
-
_type: 'task',
|
|
76
|
-
id: 't-001',
|
|
77
|
-
description: 'Do work',
|
|
78
|
-
status: 'pending',
|
|
79
|
-
origin: 'bogus',
|
|
80
|
-
created_at: now,
|
|
81
|
-
updated_at: now,
|
|
82
|
-
},
|
|
83
|
-
decodeTaskRecord,
|
|
84
|
-
),
|
|
85
|
-
).toBe(false);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('rejects missing fields and unknown status', () => {
|
|
89
|
-
expect(isOk({ _type: 'task', id: 't-001', status: 'pending' }, decodeTaskRecord)).toBe(false);
|
|
90
|
-
expect(
|
|
91
|
-
isOk(
|
|
92
|
-
{
|
|
93
|
-
_type: 'task',
|
|
94
|
-
id: 't-001',
|
|
95
|
-
description: 'Do work',
|
|
96
|
-
status: 'unknown',
|
|
97
|
-
created_at: now,
|
|
98
|
-
updated_at: now,
|
|
99
|
-
},
|
|
100
|
-
decodeTaskRecord,
|
|
101
|
-
),
|
|
102
|
-
).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe('task meta schema', () => {
|
|
107
|
-
test('accepts a valid meta record', () => {
|
|
108
|
-
expect(
|
|
109
|
-
isOk(
|
|
110
|
-
{ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now },
|
|
111
|
-
decodeTaskMeta,
|
|
112
|
-
),
|
|
113
|
-
).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('accepts a meta record with base_commit', () => {
|
|
117
|
-
expect(
|
|
118
|
-
isOk(
|
|
119
|
-
{ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now, base_commit: 'abc123' },
|
|
120
|
-
decodeTaskMeta,
|
|
121
|
-
),
|
|
122
|
-
).toBe(true);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('accepts a meta record without base_commit (back-compat)', () => {
|
|
126
|
-
expect(
|
|
127
|
-
isOk({ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now }, decodeTaskMeta),
|
|
128
|
-
).toBe(true);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('rejects malformed meta records', () => {
|
|
132
|
-
expect(isOk({ _type: 'meta', title: 'Refactor' }, decodeTaskMeta)).toBe(false);
|
|
133
|
-
expect(
|
|
134
|
-
isOk(
|
|
135
|
-
{ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now },
|
|
136
|
-
decodeTaskMeta,
|
|
137
|
-
),
|
|
138
|
-
).toBe(false);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe('tasks.jsonl line schema (meta | task union)', () => {
|
|
143
|
-
test('discriminates meta from task', () => {
|
|
144
|
-
const meta = decodeTasksLine({ _type: 'meta', title: 'T', plan_name: 'p', created_at: now });
|
|
145
|
-
const task = decodeTasksLine({
|
|
146
|
-
_type: 'task',
|
|
147
|
-
id: 't-001',
|
|
148
|
-
description: 'Do work',
|
|
149
|
-
status: 'pending',
|
|
150
|
-
created_at: now,
|
|
151
|
-
updated_at: now,
|
|
152
|
-
});
|
|
153
|
-
expect(Either.isRight(meta) && Either.getOrThrow(meta)._type).toBe('meta');
|
|
154
|
-
expect(Either.isRight(task) && Either.getOrThrow(task)._type).toBe('task');
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test('round-trips a deferred discovered task', () => {
|
|
158
|
-
const decoded = decodeTasksLine({
|
|
159
|
-
_type: 'task',
|
|
160
|
-
id: 't-011',
|
|
161
|
-
description: 'Follow-up',
|
|
162
|
-
status: 'deferred',
|
|
163
|
-
origin: 'discovered',
|
|
164
|
-
created_at: now,
|
|
165
|
-
updated_at: now,
|
|
166
|
-
});
|
|
167
|
-
expect(Either.isRight(decoded)).toBe(true);
|
|
168
|
-
if (Either.isRight(decoded) && decoded.right._type === 'task') {
|
|
169
|
-
expect(decoded.right.status).toBe('deferred');
|
|
170
|
-
expect(decoded.right.origin).toBe('discovered');
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('rejects records with an unknown _type', () => {
|
|
175
|
-
expect(isOk({ _type: 'bogus' }, decodeTasksLine)).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe('plan manifest entry schema', () => {
|
|
180
|
-
test('accepts a valid entry with null completed_at', () => {
|
|
181
|
-
expect(
|
|
182
|
-
isOk(
|
|
183
|
-
{
|
|
184
|
-
_type: 'plan',
|
|
185
|
-
name: 'plan',
|
|
186
|
-
status: 'in-progress',
|
|
187
|
-
title: 'Plan',
|
|
188
|
-
created_at: now,
|
|
189
|
-
completed_at: null,
|
|
190
|
-
},
|
|
191
|
-
decodePlanManifestEntry,
|
|
192
|
-
),
|
|
193
|
-
).toBe(true);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('accepts terminal statuses (superseded / abandoned) with a reason', () => {
|
|
197
|
-
for (const status of ['done', 'superseded', 'abandoned'] as const) {
|
|
198
|
-
expect(
|
|
199
|
-
isOk(
|
|
200
|
-
{
|
|
201
|
-
_type: 'plan',
|
|
202
|
-
name: 'plan',
|
|
203
|
-
status,
|
|
204
|
-
title: 'Plan',
|
|
205
|
-
created_at: now,
|
|
206
|
-
completed_at: now,
|
|
207
|
-
reason: 'another plan shipped it',
|
|
208
|
-
},
|
|
209
|
-
decodePlanManifestEntry,
|
|
210
|
-
),
|
|
211
|
-
).toBe(true);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test('rejects an invalid status', () => {
|
|
216
|
-
expect(
|
|
217
|
-
isOk(
|
|
218
|
-
{
|
|
219
|
-
_type: 'plan',
|
|
220
|
-
name: 'plan',
|
|
221
|
-
status: 'paused',
|
|
222
|
-
title: 'Plan',
|
|
223
|
-
created_at: now,
|
|
224
|
-
completed_at: null,
|
|
225
|
-
},
|
|
226
|
-
decodePlanManifestEntry,
|
|
227
|
-
),
|
|
228
|
-
).toBe(false);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test('accepts optional initiative + plan-level depends_on (forward compat)', () => {
|
|
232
|
-
expect(
|
|
233
|
-
isOk(
|
|
234
|
-
{
|
|
235
|
-
_type: 'plan',
|
|
236
|
-
name: 'auth-jwt',
|
|
237
|
-
status: 'in-progress',
|
|
238
|
-
title: 'Auth JWT',
|
|
239
|
-
created_at: now,
|
|
240
|
-
completed_at: null,
|
|
241
|
-
initiative: 'auth-overhaul',
|
|
242
|
-
depends_on: ['auth-schema'],
|
|
243
|
-
},
|
|
244
|
-
decodePlanManifestEntry,
|
|
245
|
-
),
|
|
246
|
-
).toBe(true);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test('still accepts a legacy entry without the new optional fields (back compat)', () => {
|
|
250
|
-
expect(
|
|
251
|
-
isOk(
|
|
252
|
-
{
|
|
253
|
-
_type: 'plan',
|
|
254
|
-
name: 'legacy',
|
|
255
|
-
status: 'done',
|
|
256
|
-
title: 'Legacy',
|
|
257
|
-
created_at: now,
|
|
258
|
-
completed_at: now,
|
|
259
|
-
},
|
|
260
|
-
decodePlanManifestEntry,
|
|
261
|
-
),
|
|
262
|
-
).toBe(true);
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
describe('initiative manifest entry schema', () => {
|
|
267
|
-
test('accepts a valid in-progress initiative', () => {
|
|
268
|
-
expect(
|
|
269
|
-
isOk(
|
|
270
|
-
{
|
|
271
|
-
_type: 'initiative',
|
|
272
|
-
name: 'auth-overhaul',
|
|
273
|
-
status: 'in-progress',
|
|
274
|
-
title: 'Auth Overhaul',
|
|
275
|
-
created_at: now,
|
|
276
|
-
completed_at: null,
|
|
277
|
-
},
|
|
278
|
-
decodeInitiativeManifestEntry,
|
|
279
|
-
),
|
|
280
|
-
).toBe(true);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test('accepts terminal statuses with a reason', () => {
|
|
284
|
-
for (const status of ['done', 'superseded', 'abandoned'] as const) {
|
|
285
|
-
expect(
|
|
286
|
-
isOk(
|
|
287
|
-
{
|
|
288
|
-
_type: 'initiative',
|
|
289
|
-
name: 'auth-overhaul',
|
|
290
|
-
status,
|
|
291
|
-
title: 'Auth Overhaul',
|
|
292
|
-
created_at: now,
|
|
293
|
-
completed_at: now,
|
|
294
|
-
reason: 'shipped',
|
|
295
|
-
},
|
|
296
|
-
decodeInitiativeManifestEntry,
|
|
297
|
-
),
|
|
298
|
-
).toBe(true);
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
test('rejects a plan _type masquerading as an initiative', () => {
|
|
303
|
-
expect(
|
|
304
|
-
isOk(
|
|
305
|
-
{
|
|
306
|
-
_type: 'plan',
|
|
307
|
-
name: 'auth-overhaul',
|
|
308
|
-
status: 'in-progress',
|
|
309
|
-
title: 'Auth Overhaul',
|
|
310
|
-
created_at: now,
|
|
311
|
-
completed_at: null,
|
|
312
|
-
},
|
|
313
|
-
decodeInitiativeManifestEntry,
|
|
314
|
-
),
|
|
315
|
-
).toBe(false);
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
describe('exec pending config schema', () => {
|
|
320
|
-
test('accepts a valid config', () => {
|
|
321
|
-
expect(
|
|
322
|
-
isOk(
|
|
323
|
-
{ model: { provider: 'anthropic', id: 'opus' }, thinking: 'low' },
|
|
324
|
-
decodeExecPendingConfig,
|
|
325
|
-
),
|
|
326
|
-
).toBe(true);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('rejects a config missing the model id', () => {
|
|
330
|
-
expect(
|
|
331
|
-
isOk({ model: { provider: 'anthropic' }, thinking: 'low' }, decodeExecPendingConfig),
|
|
332
|
-
).toBe(false);
|
|
333
|
-
});
|
|
334
|
-
});
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
activeTasksResolved,
|
|
4
|
-
deferredTasks,
|
|
5
|
-
isPlanFinalizable,
|
|
6
|
-
reactivateForExecution,
|
|
7
|
-
} from '../task-status.js';
|
|
8
|
-
import type { TaskRecord, TaskStatus } from '../types.js';
|
|
9
|
-
|
|
10
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
11
|
-
let counter = 0;
|
|
12
|
-
const make = (status: TaskStatus, origin?: 'plan' | 'discovered'): TaskRecord => ({
|
|
13
|
-
_type: 'task',
|
|
14
|
-
id: `t-${String(++counter).padStart(3, '0')}`,
|
|
15
|
-
description: 'task',
|
|
16
|
-
status,
|
|
17
|
-
origin,
|
|
18
|
-
created_at: now,
|
|
19
|
-
updated_at: now,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('deferredTasks', () => {
|
|
23
|
-
test('returns only deferred tasks', () => {
|
|
24
|
-
const tasks = [make('done'), make('deferred', 'discovered'), make('pending')];
|
|
25
|
-
expect(deferredTasks(tasks).map((t) => t.status)).toEqual(['deferred']);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('activeTasksResolved', () => {
|
|
30
|
-
test('true when only done/skipped/deferred remain', () => {
|
|
31
|
-
expect(
|
|
32
|
-
activeTasksResolved([make('done'), make('skipped'), make('deferred', 'discovered')]),
|
|
33
|
-
).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('false when a task is still pending', () => {
|
|
37
|
-
expect(activeTasksResolved([make('done'), make('pending')])).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('false when a task is still blocked', () => {
|
|
41
|
-
expect(activeTasksResolved([make('done'), make('blocked')])).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('isPlanFinalizable', () => {
|
|
46
|
-
test('true when all work is done/skipped and nothing is deferred', () => {
|
|
47
|
-
expect(isPlanFinalizable([make('done'), make('skipped')])).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('false when a deferred follow-up awaits the user', () => {
|
|
51
|
-
expect(isPlanFinalizable([make('done'), make('deferred', 'discovered')])).toBe(false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('false when active work remains', () => {
|
|
55
|
-
expect(isPlanFinalizable([make('pending')])).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('reactivateForExecution', () => {
|
|
60
|
-
test('flips blocked and deferred tasks to pending and stamps updated_at', () => {
|
|
61
|
-
const tasks = [make('done'), make('blocked'), make('deferred', 'discovered')];
|
|
62
|
-
const ts = '2026-06-01T00:00:00.000Z';
|
|
63
|
-
const changed = reactivateForExecution(tasks, ts);
|
|
64
|
-
expect(changed).toBe(true);
|
|
65
|
-
expect(tasks.map((t) => t.status)).toEqual(['done', 'pending', 'pending']);
|
|
66
|
-
expect(tasks[1].updated_at).toBe(ts);
|
|
67
|
-
expect(tasks[2].updated_at).toBe(ts);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('returns false and leaves tasks untouched when nothing to reactivate', () => {
|
|
71
|
-
const tasks = [make('done'), make('pending')];
|
|
72
|
-
expect(reactivateForExecution(tasks, '2026-06-01T00:00:00.000Z')).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { Cause, Effect, Exit, Option } from 'effect';
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
7
|
-
import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
-
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
9
|
-
|
|
10
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
11
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
12
|
-
|
|
13
|
-
const runExit = <A, E>(program: Effect.Effect<A, E, FileSystem>) =>
|
|
14
|
-
Effect.runPromiseExit(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
15
|
-
|
|
16
|
-
const failureTag = <A, E>(exit: Exit.Exit<A, E>): string | undefined => {
|
|
17
|
-
if (!Exit.isFailure(exit)) return undefined;
|
|
18
|
-
const error = Option.getOrUndefined(Cause.failureOption(exit.cause)) as
|
|
19
|
-
| { _tag?: string }
|
|
20
|
-
| undefined;
|
|
21
|
-
return error?._tag;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
let dir: string;
|
|
25
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
26
|
-
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
27
|
-
const task: TaskRecord = {
|
|
28
|
-
_type: 'task',
|
|
29
|
-
id: 't-001',
|
|
30
|
-
description: 'Do work',
|
|
31
|
-
details: 'Details',
|
|
32
|
-
status: 'pending',
|
|
33
|
-
created_at: now,
|
|
34
|
-
updated_at: now,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
beforeEach(async () => {
|
|
38
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
|
|
39
|
-
});
|
|
40
|
-
afterEach(async () => {
|
|
41
|
-
await rm(dir, { recursive: true, force: true });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('tasks.jsonl storage', () => {
|
|
45
|
-
test('round trips meta and tasks', async () => {
|
|
46
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
47
|
-
await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('round trips base_commit on the meta record', async () => {
|
|
51
|
-
const metaWithCommit: TaskMeta = { ...meta, base_commit: 'deadbeefcafe' };
|
|
52
|
-
await run(writeTasksJsonl(dir, metaWithCommit, [task]));
|
|
53
|
-
const result = await run(readTasksJsonl(dir));
|
|
54
|
-
expect(result?.meta.base_commit).toBe('deadbeefcafe');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
58
|
-
const lightweight: TaskRecord = {
|
|
59
|
-
_type: 'task',
|
|
60
|
-
id: 't-002',
|
|
61
|
-
description: 'Quick fix',
|
|
62
|
-
status: 'pending',
|
|
63
|
-
created_at: now,
|
|
64
|
-
updated_at: now,
|
|
65
|
-
};
|
|
66
|
-
await run(writeTasksJsonl(dir, meta, [lightweight]));
|
|
67
|
-
const result = await run(readTasksJsonl(dir));
|
|
68
|
-
expect(result?.tasks[0]?.id).toBe('t-002');
|
|
69
|
-
expect(result?.tasks[0]?.details).toBeUndefined();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('missing file returns undefined', async () => {
|
|
73
|
-
await expect(run(readTasksJsonl(dir))).resolves.toBeUndefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('rejects corrupt lines with JsonlParseError', async () => {
|
|
77
|
-
await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
|
|
78
|
-
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('JsonlParseError');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('rejects empty files with MissingMetaRecord', async () => {
|
|
82
|
-
await Bun.write(join(dir, 'tasks.jsonl'), '');
|
|
83
|
-
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('MissingMetaRecord');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('updates a task by id and rewrites the snapshot', async () => {
|
|
87
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
88
|
-
const updated = await run(updateTask(dir, 't-001', { status: 'done', notes: 'finished' }));
|
|
89
|
-
|
|
90
|
-
expect(updated.status).toBe('done');
|
|
91
|
-
expect(updated.notes).toBe('finished');
|
|
92
|
-
expect((await run(readTasksJsonl(dir)))?.tasks[0]?.status).toBe('done');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('fails with TaskNotFound for an unknown task id', async () => {
|
|
96
|
-
await run(writeTasksJsonl(dir, meta, [task]));
|
|
97
|
-
expect(failureTag(await runExit(updateTask(dir, 't-999', { status: 'done' })))).toBe(
|
|
98
|
-
'TaskNotFound',
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
});
|