@dreki-gg/pi-plan-mode 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
  3. package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
  4. package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
  5. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
  6. package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
  7. package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
  8. package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
  9. package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
  10. package/extensions/plan-mode/constants.ts +1 -1
  11. package/extensions/plan-mode/effects/filesystem.ts +65 -0
  12. package/extensions/plan-mode/effects/runtime.ts +24 -0
  13. package/extensions/plan-mode/errors.ts +105 -0
  14. package/extensions/plan-mode/index.ts +147 -47
  15. package/extensions/plan-mode/prompts.ts +1 -0
  16. package/extensions/plan-mode/resume.ts +19 -15
  17. package/extensions/plan-mode/schema.ts +57 -0
  18. package/extensions/plan-mode/storage/atomic-write.ts +19 -1
  19. package/extensions/plan-mode/storage/plan-storage.ts +57 -29
  20. package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
  21. package/extensions/plan-mode/storage/task-storage.ts +83 -45
  22. package/extensions/plan-mode/task-status.ts +46 -0
  23. package/extensions/plan-mode/tools/add-task.ts +95 -0
  24. package/extensions/plan-mode/tools/preview-prototype.ts +11 -4
  25. package/extensions/plan-mode/tools/submit-plan.ts +16 -10
  26. package/extensions/plan-mode/types.ts +8 -38
  27. package/extensions/plan-mode/utils.ts +20 -0
  28. package/package.json +2 -1
  29. package/extensions/plan-mode/__tests__/types.test.ts +0 -70
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Refactor storage/domain layer to Effect (tagged errors, Schema-based JSONL validation, a FileSystem service + runtime layer) and add beads-style discovered follow-up tasks.
8
+
9
+ During execution the agent can now call `add_task` to capture worthwhile out-of-plan work as a `deferred` follow-up (with a reason), without implementing it. Discovered follow-ups are surfaced at checkpoints (blocked pause and when planned work finishes), keep the plan in-progress, and are picked up when you choose "Continue execution" via `/plan resume`.
10
+
3
11
  ## 0.16.0
4
12
 
5
13
  ### Minor Changes
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerAddTaskTool } from '../tools/add-task.js';
3
+ import type { PlanData, TaskRecord } from '../types.js';
4
+
5
+ const now = '2026-05-27T12:00:00.000Z';
6
+
7
+ interface CapturedTool {
8
+ execute: (
9
+ id: string,
10
+ params: { description: string; reason: string; details?: string; depends_on?: string[] },
11
+ ) => Promise<{ details?: unknown }>;
12
+ }
13
+
14
+ function setup(plan: PlanData | undefined) {
15
+ let tool: CapturedTool | undefined;
16
+ const added: TaskRecord[] = [];
17
+ const pi = {
18
+ registerTool: (config: CapturedTool) => {
19
+ tool = config;
20
+ },
21
+ } as unknown as Parameters<typeof registerAddTaskTool>[0];
22
+
23
+ registerAddTaskTool(pi, {
24
+ getPlan: () => plan,
25
+ onTaskAdded: (task) => {
26
+ added.push(task);
27
+ },
28
+ });
29
+
30
+ return { tool: tool!, added };
31
+ }
32
+
33
+ const basePlan = (tasks: TaskRecord[]): PlanData => ({
34
+ title: 'Plan',
35
+ planName: 'plan',
36
+ handoff: '',
37
+ tasks,
38
+ });
39
+
40
+ const planTask = (id: string): TaskRecord => ({
41
+ _type: 'task',
42
+ id,
43
+ description: 'planned',
44
+ status: 'done',
45
+ origin: 'plan',
46
+ created_at: now,
47
+ updated_at: now,
48
+ });
49
+
50
+ describe('add_task tool', () => {
51
+ test('captures a deferred discovered task with a generated id and reason as notes', async () => {
52
+ const plan = basePlan([planTask('t-001'), planTask('t-002')]);
53
+ const { tool, added } = setup(plan);
54
+
55
+ await tool.execute('call-1', {
56
+ description: 'Extract shared helper',
57
+ reason: 'noticed duplication while editing',
58
+ });
59
+
60
+ expect(added).toHaveLength(1);
61
+ const task = added[0];
62
+ expect(task.id).toBe('t-003');
63
+ expect(task.status).toBe('deferred');
64
+ expect(task.origin).toBe('discovered');
65
+ expect(task.notes).toBe('noticed duplication while editing');
66
+ expect(task.description).toBe('Extract shared helper');
67
+ });
68
+
69
+ test('throws when there is no active plan', async () => {
70
+ const { tool } = setup(undefined);
71
+ await expect(tool.execute('call-1', { description: 'x', reason: 'y' })).rejects.toThrow(
72
+ /No active plan/,
73
+ );
74
+ });
75
+ });
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { Effect, Exit } from 'effect';
2
3
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { tmpdir } from 'node:os';
@@ -18,7 +19,7 @@ describe('writeFileAtomic', () => {
18
19
  test('writes complete content to the target path', async () => {
19
20
  const target = join(dir, 'data.txt');
20
21
 
21
- await writeFileAtomic(target, 'hello world');
22
+ await Effect.runPromise(writeFileAtomic(target, 'hello world'));
22
23
 
23
24
  expect(await readFile(target, 'utf8')).toBe('hello world');
24
25
  });
@@ -27,17 +28,18 @@ describe('writeFileAtomic', () => {
27
28
  const target = join(dir, 'data.txt');
28
29
  await writeFile(target, 'old');
29
30
 
30
- await writeFileAtomic(target, 'new');
31
+ await Effect.runPromise(writeFileAtomic(target, 'new'));
31
32
 
32
33
  expect(await readFile(target, 'utf8')).toBe('new');
33
34
  });
34
35
 
35
- test('leaves target untouched when writing fails before rename', async () => {
36
+ test('fails with PlanWriteError and leaves target untouched when the write fails', async () => {
36
37
  const target = join(dir, 'data.txt');
37
38
  await writeFile(target, 'original');
38
39
 
39
- await expect(writeFileAtomic(target, 'next', { mode: 0o400 })).rejects.toThrow();
40
+ const exit = await Effect.runPromiseExit(writeFileAtomic(target, 'next', { mode: 0o400 }));
40
41
 
42
+ expect(Exit.isFailure(exit)).toBe(true);
41
43
  expect(await readFile(target, 'utf8')).toBe('original');
42
44
  });
43
45
  });
@@ -0,0 +1,57 @@
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,14 +1,19 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { Effect } from 'effect';
2
3
  import { chdir } from 'node:process';
3
4
  import { mkdtemp, rm } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
5
6
  import { tmpdir } from 'node:os';
7
+ import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
6
8
  import {
7
9
  readPlansManifest,
8
10
  upsertPlanEntry,
9
11
  writePlansManifest,
10
12
  } from '../storage/plans-manifest.js';
11
13
 
14
+ const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
15
+ Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
16
+
12
17
  const originalCwd = process.cwd();
13
18
  let dir: string;
14
19
 
@@ -31,31 +36,37 @@ describe('plans.jsonl manifest', () => {
31
36
  created_at: 'now',
32
37
  completed_at: null,
33
38
  };
34
- await writePlansManifest([entry]);
35
- await expect(readPlansManifest()).resolves.toEqual([entry]);
39
+ await run(writePlansManifest([entry]));
40
+ await expect(run(readPlansManifest())).resolves.toEqual([entry]);
41
+ });
42
+
43
+ test('missing manifest returns an empty list', async () => {
44
+ await expect(run(readPlansManifest())).resolves.toEqual([]);
36
45
  });
37
46
 
38
47
  test('upserts new entries', async () => {
39
- await upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' });
40
- const entries = await readPlansManifest();
48
+ await run(upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' }));
49
+ const entries = await run(readPlansManifest());
41
50
  expect(entries).toHaveLength(1);
42
51
  expect(entries[0]?.name).toBe('new-plan');
43
52
  expect(entries[0]?.title).toBe('New Plan');
44
53
  });
45
54
 
46
55
  test('upserts existing entries without changing created_at', async () => {
47
- await writePlansManifest([
48
- {
49
- _type: 'plan',
50
- name: 'p',
51
- status: 'in-progress',
52
- title: 'Old',
53
- created_at: 'created',
54
- completed_at: null,
55
- },
56
- ]);
57
- await upsertPlanEntry('p', { status: 'done', title: 'New' });
58
- const [entry] = await readPlansManifest();
56
+ await run(
57
+ writePlansManifest([
58
+ {
59
+ _type: 'plan',
60
+ name: 'p',
61
+ status: 'in-progress',
62
+ title: 'Old',
63
+ created_at: 'created',
64
+ completed_at: null,
65
+ },
66
+ ]),
67
+ );
68
+ await run(upsertPlanEntry('p', { status: 'done', title: 'New' }));
69
+ const [entry] = await run(readPlansManifest());
59
70
  expect(entry.created_at).toBe('created');
60
71
  expect(entry.status).toBe('done');
61
72
  expect(entry.completed_at).toBeString();
@@ -0,0 +1,212 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { Either } from 'effect';
3
+ import {
4
+ decodeExecPendingConfig,
5
+ decodePlanManifestEntry,
6
+ decodeTaskMeta,
7
+ decodeTaskRecord,
8
+ decodeTasksLine,
9
+ } from '../schema.js';
10
+
11
+ const now = '2026-05-27T12:00:00.000Z';
12
+
13
+ const isOk = (value: unknown, decode: (v: unknown) => Either.Either<unknown, unknown>): boolean =>
14
+ Either.isRight(decode(value));
15
+
16
+ describe('task record schema', () => {
17
+ test('accepts a full task record', () => {
18
+ expect(
19
+ isOk(
20
+ {
21
+ _type: 'task',
22
+ id: 't-001',
23
+ description: 'Do work',
24
+ details: 'Full instructions',
25
+ status: 'pending',
26
+ depends_on: ['t-000'],
27
+ notes: 'note',
28
+ created_at: now,
29
+ updated_at: now,
30
+ },
31
+ decodeTaskRecord,
32
+ ),
33
+ ).toBe(true);
34
+ });
35
+
36
+ test('accepts a lightweight task record without details', () => {
37
+ expect(
38
+ isOk(
39
+ {
40
+ _type: 'task',
41
+ id: 't-001',
42
+ description: 'Do work',
43
+ status: 'pending',
44
+ created_at: now,
45
+ updated_at: now,
46
+ },
47
+ decodeTaskRecord,
48
+ ),
49
+ ).toBe(true);
50
+ });
51
+
52
+ test('accepts a deferred discovered task', () => {
53
+ expect(
54
+ isOk(
55
+ {
56
+ _type: 'task',
57
+ id: 't-011',
58
+ description: 'Follow-up discovered mid-run',
59
+ status: 'deferred',
60
+ origin: 'discovered',
61
+ notes: 'noticed while implementing',
62
+ created_at: now,
63
+ updated_at: now,
64
+ },
65
+ decodeTaskRecord,
66
+ ),
67
+ ).toBe(true);
68
+ });
69
+
70
+ test('rejects an unknown origin', () => {
71
+ expect(
72
+ isOk(
73
+ {
74
+ _type: 'task',
75
+ id: 't-001',
76
+ description: 'Do work',
77
+ status: 'pending',
78
+ origin: 'bogus',
79
+ created_at: now,
80
+ updated_at: now,
81
+ },
82
+ decodeTaskRecord,
83
+ ),
84
+ ).toBe(false);
85
+ });
86
+
87
+ test('rejects missing fields and unknown status', () => {
88
+ expect(isOk({ _type: 'task', id: 't-001', status: 'pending' }, decodeTaskRecord)).toBe(false);
89
+ expect(
90
+ isOk(
91
+ {
92
+ _type: 'task',
93
+ id: 't-001',
94
+ description: 'Do work',
95
+ status: 'unknown',
96
+ created_at: now,
97
+ updated_at: now,
98
+ },
99
+ decodeTaskRecord,
100
+ ),
101
+ ).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe('task meta schema', () => {
106
+ test('accepts a valid meta record', () => {
107
+ expect(
108
+ isOk(
109
+ { _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now },
110
+ decodeTaskMeta,
111
+ ),
112
+ ).toBe(true);
113
+ });
114
+
115
+ test('rejects malformed meta records', () => {
116
+ expect(isOk({ _type: 'meta', title: 'Refactor' }, decodeTaskMeta)).toBe(false);
117
+ expect(
118
+ isOk(
119
+ { _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now },
120
+ decodeTaskMeta,
121
+ ),
122
+ ).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('tasks.jsonl line schema (meta | task union)', () => {
127
+ test('discriminates meta from task', () => {
128
+ const meta = decodeTasksLine({ _type: 'meta', title: 'T', plan_name: 'p', created_at: now });
129
+ const task = decodeTasksLine({
130
+ _type: 'task',
131
+ id: 't-001',
132
+ description: 'Do work',
133
+ status: 'pending',
134
+ created_at: now,
135
+ updated_at: now,
136
+ });
137
+ expect(Either.isRight(meta) && Either.getOrThrow(meta)._type).toBe('meta');
138
+ expect(Either.isRight(task) && Either.getOrThrow(task)._type).toBe('task');
139
+ });
140
+
141
+ test('round-trips a deferred discovered task', () => {
142
+ const decoded = decodeTasksLine({
143
+ _type: 'task',
144
+ id: 't-011',
145
+ description: 'Follow-up',
146
+ status: 'deferred',
147
+ origin: 'discovered',
148
+ created_at: now,
149
+ updated_at: now,
150
+ });
151
+ expect(Either.isRight(decoded)).toBe(true);
152
+ if (Either.isRight(decoded) && decoded.right._type === 'task') {
153
+ expect(decoded.right.status).toBe('deferred');
154
+ expect(decoded.right.origin).toBe('discovered');
155
+ }
156
+ });
157
+
158
+ test('rejects records with an unknown _type', () => {
159
+ expect(isOk({ _type: 'bogus' }, decodeTasksLine)).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('plan manifest entry schema', () => {
164
+ test('accepts a valid entry with null completed_at', () => {
165
+ expect(
166
+ isOk(
167
+ {
168
+ _type: 'plan',
169
+ name: 'plan',
170
+ status: 'in-progress',
171
+ title: 'Plan',
172
+ created_at: now,
173
+ completed_at: null,
174
+ },
175
+ decodePlanManifestEntry,
176
+ ),
177
+ ).toBe(true);
178
+ });
179
+
180
+ test('rejects an invalid status', () => {
181
+ expect(
182
+ isOk(
183
+ {
184
+ _type: 'plan',
185
+ name: 'plan',
186
+ status: 'paused',
187
+ title: 'Plan',
188
+ created_at: now,
189
+ completed_at: null,
190
+ },
191
+ decodePlanManifestEntry,
192
+ ),
193
+ ).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe('exec pending config schema', () => {
198
+ test('accepts a valid config', () => {
199
+ expect(
200
+ isOk(
201
+ { model: { provider: 'anthropic', id: 'opus' }, thinking: 'low' },
202
+ decodeExecPendingConfig,
203
+ ),
204
+ ).toBe(true);
205
+ });
206
+
207
+ test('rejects a config missing the model id', () => {
208
+ expect(
209
+ isOk({ model: { provider: 'anthropic' }, thinking: 'low' }, decodeExecPendingConfig),
210
+ ).toBe(false);
211
+ });
212
+ });
@@ -0,0 +1,74 @@
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,10 +1,26 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { Cause, Effect, Exit, Option } from 'effect';
2
3
  import { mkdtemp, rm } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { tmpdir } from 'node:os';
6
+ import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
5
7
  import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
6
8
  import type { TaskMeta, TaskRecord } from '../types.js';
7
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
+
8
24
  let dir: string;
9
25
  const now = '2026-05-27T12:00:00.000Z';
10
26
  const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
@@ -27,8 +43,8 @@ afterEach(async () => {
27
43
 
28
44
  describe('tasks.jsonl storage', () => {
29
45
  test('round trips meta and tasks', async () => {
30
- await writeTasksJsonl(dir, meta, [task]);
31
- await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
46
+ await run(writeTasksJsonl(dir, meta, [task]));
47
+ await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
32
48
  });
33
49
 
34
50
  test('round trips tasks without details (lightweight checklist)', async () => {
@@ -40,32 +56,39 @@ describe('tasks.jsonl storage', () => {
40
56
  created_at: now,
41
57
  updated_at: now,
42
58
  };
43
- await writeTasksJsonl(dir, meta, [lightweight]);
44
- const result = await readTasksJsonl(dir);
59
+ await run(writeTasksJsonl(dir, meta, [lightweight]));
60
+ const result = await run(readTasksJsonl(dir));
45
61
  expect(result?.tasks[0]?.id).toBe('t-002');
46
62
  expect(result?.tasks[0]?.details).toBeUndefined();
47
63
  });
48
64
 
49
65
  test('missing file returns undefined', async () => {
50
- await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
66
+ await expect(run(readTasksJsonl(dir))).resolves.toBeUndefined();
51
67
  });
52
68
 
53
- test('rejects corrupt lines', async () => {
69
+ test('rejects corrupt lines with JsonlParseError', async () => {
54
70
  await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
55
- await expect(readTasksJsonl(dir)).rejects.toThrow(/Invalid JSONL/);
71
+ expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('JsonlParseError');
56
72
  });
57
73
 
58
- test('rejects empty files', async () => {
74
+ test('rejects empty files with MissingMetaRecord', async () => {
59
75
  await Bun.write(join(dir, 'tasks.jsonl'), '');
60
- await expect(readTasksJsonl(dir)).rejects.toThrow(/meta/);
76
+ expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('MissingMetaRecord');
61
77
  });
62
78
 
63
79
  test('updates a task by id and rewrites the snapshot', async () => {
64
- await writeTasksJsonl(dir, meta, [task]);
65
- const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
80
+ await run(writeTasksJsonl(dir, meta, [task]));
81
+ const updated = await run(updateTask(dir, 't-001', { status: 'done', notes: 'finished' }));
66
82
 
67
83
  expect(updated.status).toBe('done');
68
84
  expect(updated.notes).toBe('finished');
69
- expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
85
+ expect((await run(readTasksJsonl(dir)))?.tasks[0]?.status).toBe('done');
86
+ });
87
+
88
+ test('fails with TaskNotFound for an unknown task id', async () => {
89
+ await run(writeTasksJsonl(dir, meta, [task]));
90
+ expect(failureTag(await runExit(updateTask(dir, 't-999', { status: 'done' })))).toBe(
91
+ 'TaskNotFound',
92
+ );
70
93
  });
71
94
  });
@@ -1,5 +1,23 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { isSafeCommand, isPlanPath } from '../utils.js';
2
+ import { isSafeCommand, isPlanPath, nextTaskId } from '../utils.js';
3
+
4
+ describe('nextTaskId', () => {
5
+ test('increments the max numeric suffix', () => {
6
+ expect(nextTaskId(['t-001', 't-002', 't-003'])).toBe('t-004');
7
+ });
8
+
9
+ test('uses the max even when ids are unordered or sparse', () => {
10
+ expect(nextTaskId(['t-003', 't-001', 't-010'])).toBe('t-011');
11
+ });
12
+
13
+ test('starts at t-001 for an empty plan', () => {
14
+ expect(nextTaskId([])).toBe('t-001');
15
+ });
16
+
17
+ test('falls back to count+1 when no ids match the pattern', () => {
18
+ expect(nextTaskId(['setup', 'cleanup'])).toBe('t-003');
19
+ });
20
+ });
3
21
 
4
22
  describe('isSafeCommand', () => {
5
23
  // ── Commands that SHOULD be allowed ──────────────────────────────────────
@@ -17,7 +17,7 @@ export const PLAN_TOOLS = [
17
17
  'subagent',
18
18
  ];
19
19
 
20
- export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
20
+ export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task', 'add_task'];
21
21
 
22
22
  // ── Model + thinking presets ─────────────────────────────────────────────────
23
23
  export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;