@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.
- package/CHANGELOG.md +25 -0
- package/extensions/plan-mode/__tests__/git.test.ts +17 -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__/prompts.test.ts +23 -0
- 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/git.ts +21 -0
- package/extensions/plan-mode/index.ts +9 -7
- package/extensions/plan-mode/prompts.ts +6 -1
- 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 +5 -4
- package/extensions/plan-mode/resume.ts +7 -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 +9 -7
- package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
- package/extensions/plan-mode/tools/submit-plan.ts +18 -8
- 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 -51
- 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 -319
- package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
- package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -94
- 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 -97
- 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,57 +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
|
-
loadHandoff,
|
|
10
|
-
readAndClearExecPending,
|
|
11
|
-
saveHandoff,
|
|
12
|
-
writeExecPending,
|
|
13
|
-
} from '../storage/plan-storage.js';
|
|
14
|
-
import type { ExecPendingConfig } from '../types.js';
|
|
15
|
-
|
|
16
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
17
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
18
|
-
|
|
19
|
-
const originalCwd = process.cwd();
|
|
20
|
-
let dir: string;
|
|
21
|
-
const config: ExecPendingConfig = { model: { provider: 'anthropic', id: 'opus' }, thinking: 'low' };
|
|
22
|
-
|
|
23
|
-
beforeEach(async () => {
|
|
24
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-plan-'));
|
|
25
|
-
chdir(dir);
|
|
26
|
-
});
|
|
27
|
-
afterEach(async () => {
|
|
28
|
-
chdir(originalCwd);
|
|
29
|
-
await rm(dir, { recursive: true, force: true });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('handoff documents', () => {
|
|
33
|
-
test('round trips handoff content', async () => {
|
|
34
|
-
await run(saveHandoff('.plans/p', '# Handoff'));
|
|
35
|
-
await expect(run(loadHandoff('.plans/p'))).resolves.toBe('# Handoff');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('missing handoff returns undefined', async () => {
|
|
39
|
-
await expect(run(loadHandoff('.plans/missing'))).resolves.toBeUndefined();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('exec-pending markers', () => {
|
|
44
|
-
test('writes, reads, and clears a marker', async () => {
|
|
45
|
-
await run(writeExecPending('.plans/p', config));
|
|
46
|
-
const first = await run(readAndClearExecPending());
|
|
47
|
-
expect(first?.planDir).toBe('.plans/p');
|
|
48
|
-
expect(first?.config).toEqual(config);
|
|
49
|
-
|
|
50
|
-
// Marker is cleared after reading.
|
|
51
|
-
await expect(run(readAndClearExecPending())).resolves.toBeUndefined();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('returns undefined when .plans does not exist', async () => {
|
|
55
|
-
await expect(run(readAndClearExecPending())).resolves.toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
});
|
|
@@ -1,120 +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
|
-
readPlansManifest,
|
|
10
|
-
reconcilePlanStatus,
|
|
11
|
-
upsertPlanEntry,
|
|
12
|
-
writePlansManifest,
|
|
13
|
-
} from '../storage/plans-manifest.js';
|
|
14
|
-
|
|
15
|
-
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
16
|
-
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
17
|
-
|
|
18
|
-
const originalCwd = process.cwd();
|
|
19
|
-
let dir: string;
|
|
20
|
-
|
|
21
|
-
beforeEach(async () => {
|
|
22
|
-
dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-'));
|
|
23
|
-
chdir(dir);
|
|
24
|
-
});
|
|
25
|
-
afterEach(async () => {
|
|
26
|
-
chdir(originalCwd);
|
|
27
|
-
await rm(dir, { recursive: true, force: true });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('plans.jsonl manifest', () => {
|
|
31
|
-
test('round trips entries', async () => {
|
|
32
|
-
const entry = {
|
|
33
|
-
_type: 'plan' as const,
|
|
34
|
-
name: 'refactor',
|
|
35
|
-
status: 'in-progress' as const,
|
|
36
|
-
title: 'Refactor',
|
|
37
|
-
created_at: 'now',
|
|
38
|
-
completed_at: null,
|
|
39
|
-
};
|
|
40
|
-
await run(writePlansManifest([entry]));
|
|
41
|
-
await expect(run(readPlansManifest())).resolves.toEqual([entry]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('missing manifest returns an empty list', async () => {
|
|
45
|
-
await expect(run(readPlansManifest())).resolves.toEqual([]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('upserts new entries', async () => {
|
|
49
|
-
await run(upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' }));
|
|
50
|
-
const entries = await run(readPlansManifest());
|
|
51
|
-
expect(entries).toHaveLength(1);
|
|
52
|
-
expect(entries[0]?.name).toBe('new-plan');
|
|
53
|
-
expect(entries[0]?.title).toBe('New Plan');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('upserts existing entries without changing created_at', async () => {
|
|
57
|
-
await run(
|
|
58
|
-
writePlansManifest([
|
|
59
|
-
{
|
|
60
|
-
_type: 'plan',
|
|
61
|
-
name: 'p',
|
|
62
|
-
status: 'in-progress',
|
|
63
|
-
title: 'Old',
|
|
64
|
-
created_at: 'created',
|
|
65
|
-
completed_at: null,
|
|
66
|
-
},
|
|
67
|
-
]),
|
|
68
|
-
);
|
|
69
|
-
await run(upsertPlanEntry('p', { status: 'done', title: 'New' }));
|
|
70
|
-
const [entry] = await run(readPlansManifest());
|
|
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(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
|
|
78
|
-
await run(upsertPlanEntry('p', { status: 'superseded', reason: 'absorbed by q' }));
|
|
79
|
-
let [entry] = await run(readPlansManifest());
|
|
80
|
-
expect(entry.status).toBe('superseded');
|
|
81
|
-
expect(entry.reason).toBe('absorbed by q');
|
|
82
|
-
expect(entry.completed_at).toBeString();
|
|
83
|
-
|
|
84
|
-
// Reopening clears completed_at.
|
|
85
|
-
await run(upsertPlanEntry('p', { status: 'in-progress' }));
|
|
86
|
-
[entry] = await run(readPlansManifest());
|
|
87
|
-
expect(entry.status).toBe('in-progress');
|
|
88
|
-
expect(entry.completed_at).toBeNull();
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('reconcilePlanStatus (registry as a projection of task state)', () => {
|
|
93
|
-
test('flips in-progress → done when finalizable', async () => {
|
|
94
|
-
await run(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
|
|
95
|
-
await run(reconcilePlanStatus('p', true, 'P'));
|
|
96
|
-
const [entry] = await run(readPlansManifest());
|
|
97
|
-
expect(entry.status).toBe('done');
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('reopens done → in-progress when no longer finalizable', async () => {
|
|
101
|
-
await run(upsertPlanEntry('p', { status: 'done', title: 'P' }));
|
|
102
|
-
await run(reconcilePlanStatus('p', false, 'P'));
|
|
103
|
-
const [entry] = await run(readPlansManifest());
|
|
104
|
-
expect(entry.status).toBe('in-progress');
|
|
105
|
-
expect(entry.completed_at).toBeNull();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('never overrides a manually-closed terminal status', async () => {
|
|
109
|
-
await run(upsertPlanEntry('p', { status: 'abandoned', title: 'P', reason: 'rejected' }));
|
|
110
|
-
await run(reconcilePlanStatus('p', true, 'P'));
|
|
111
|
-
const [entry] = await run(readPlansManifest());
|
|
112
|
-
expect(entry.status).toBe('abandoned');
|
|
113
|
-
expect(entry.reason).toBe('rejected');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('is a no-op for an unknown plan (nothing created)', async () => {
|
|
117
|
-
await run(reconcilePlanStatus('ghost', true));
|
|
118
|
-
await expect(run(readPlansManifest())).resolves.toEqual([]);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -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,319 +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('rejects malformed meta records', () => {
|
|
117
|
-
expect(isOk({ _type: 'meta', title: 'Refactor' }, decodeTaskMeta)).toBe(false);
|
|
118
|
-
expect(
|
|
119
|
-
isOk(
|
|
120
|
-
{ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now },
|
|
121
|
-
decodeTaskMeta,
|
|
122
|
-
),
|
|
123
|
-
).toBe(false);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('tasks.jsonl line schema (meta | task union)', () => {
|
|
128
|
-
test('discriminates meta from task', () => {
|
|
129
|
-
const meta = decodeTasksLine({ _type: 'meta', title: 'T', plan_name: 'p', created_at: now });
|
|
130
|
-
const task = decodeTasksLine({
|
|
131
|
-
_type: 'task',
|
|
132
|
-
id: 't-001',
|
|
133
|
-
description: 'Do work',
|
|
134
|
-
status: 'pending',
|
|
135
|
-
created_at: now,
|
|
136
|
-
updated_at: now,
|
|
137
|
-
});
|
|
138
|
-
expect(Either.isRight(meta) && Either.getOrThrow(meta)._type).toBe('meta');
|
|
139
|
-
expect(Either.isRight(task) && Either.getOrThrow(task)._type).toBe('task');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('round-trips a deferred discovered task', () => {
|
|
143
|
-
const decoded = decodeTasksLine({
|
|
144
|
-
_type: 'task',
|
|
145
|
-
id: 't-011',
|
|
146
|
-
description: 'Follow-up',
|
|
147
|
-
status: 'deferred',
|
|
148
|
-
origin: 'discovered',
|
|
149
|
-
created_at: now,
|
|
150
|
-
updated_at: now,
|
|
151
|
-
});
|
|
152
|
-
expect(Either.isRight(decoded)).toBe(true);
|
|
153
|
-
if (Either.isRight(decoded) && decoded.right._type === 'task') {
|
|
154
|
-
expect(decoded.right.status).toBe('deferred');
|
|
155
|
-
expect(decoded.right.origin).toBe('discovered');
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('rejects records with an unknown _type', () => {
|
|
160
|
-
expect(isOk({ _type: 'bogus' }, decodeTasksLine)).toBe(false);
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe('plan manifest entry schema', () => {
|
|
165
|
-
test('accepts a valid entry with null completed_at', () => {
|
|
166
|
-
expect(
|
|
167
|
-
isOk(
|
|
168
|
-
{
|
|
169
|
-
_type: 'plan',
|
|
170
|
-
name: 'plan',
|
|
171
|
-
status: 'in-progress',
|
|
172
|
-
title: 'Plan',
|
|
173
|
-
created_at: now,
|
|
174
|
-
completed_at: null,
|
|
175
|
-
},
|
|
176
|
-
decodePlanManifestEntry,
|
|
177
|
-
),
|
|
178
|
-
).toBe(true);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('accepts terminal statuses (superseded / abandoned) with a reason', () => {
|
|
182
|
-
for (const status of ['done', 'superseded', 'abandoned'] as const) {
|
|
183
|
-
expect(
|
|
184
|
-
isOk(
|
|
185
|
-
{
|
|
186
|
-
_type: 'plan',
|
|
187
|
-
name: 'plan',
|
|
188
|
-
status,
|
|
189
|
-
title: 'Plan',
|
|
190
|
-
created_at: now,
|
|
191
|
-
completed_at: now,
|
|
192
|
-
reason: 'another plan shipped it',
|
|
193
|
-
},
|
|
194
|
-
decodePlanManifestEntry,
|
|
195
|
-
),
|
|
196
|
-
).toBe(true);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test('rejects an invalid status', () => {
|
|
201
|
-
expect(
|
|
202
|
-
isOk(
|
|
203
|
-
{
|
|
204
|
-
_type: 'plan',
|
|
205
|
-
name: 'plan',
|
|
206
|
-
status: 'paused',
|
|
207
|
-
title: 'Plan',
|
|
208
|
-
created_at: now,
|
|
209
|
-
completed_at: null,
|
|
210
|
-
},
|
|
211
|
-
decodePlanManifestEntry,
|
|
212
|
-
),
|
|
213
|
-
).toBe(false);
|
|
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
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
describe('exec pending config schema', () => {
|
|
305
|
-
test('accepts a valid config', () => {
|
|
306
|
-
expect(
|
|
307
|
-
isOk(
|
|
308
|
-
{ model: { provider: 'anthropic', id: 'opus' }, thinking: 'low' },
|
|
309
|
-
decodeExecPendingConfig,
|
|
310
|
-
),
|
|
311
|
-
).toBe(true);
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test('rejects a config missing the model id', () => {
|
|
315
|
-
expect(
|
|
316
|
-
isOk({ model: { provider: 'anthropic' }, thinking: 'low' }, decodeExecPendingConfig),
|
|
317
|
-
).toBe(false);
|
|
318
|
-
});
|
|
319
|
-
});
|