@dreki-gg/pi-plan-mode 0.15.1 → 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 (36) hide show
  1. package/CHANGELOG.md +19 -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__/html-render.test.ts +26 -35
  5. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  6. package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
  7. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
  8. package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
  9. package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
  10. package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
  11. package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
  12. package/extensions/plan-mode/constants.ts +2 -1
  13. package/extensions/plan-mode/effects/filesystem.ts +65 -0
  14. package/extensions/plan-mode/effects/runtime.ts +24 -0
  15. package/extensions/plan-mode/errors.ts +105 -0
  16. package/extensions/plan-mode/html/render.ts +10 -114
  17. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  18. package/extensions/plan-mode/index.ts +151 -48
  19. package/extensions/plan-mode/prompts.ts +5 -3
  20. package/extensions/plan-mode/resume.ts +19 -15
  21. package/extensions/plan-mode/schema.ts +57 -0
  22. package/extensions/plan-mode/storage/atomic-write.ts +19 -1
  23. package/extensions/plan-mode/storage/plan-storage.ts +57 -29
  24. package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
  25. package/extensions/plan-mode/storage/task-storage.ts +83 -45
  26. package/extensions/plan-mode/task-status.ts +46 -0
  27. package/extensions/plan-mode/tools/add-task.ts +95 -0
  28. package/extensions/plan-mode/tools/preview-prototype.ts +98 -0
  29. package/extensions/plan-mode/tools/submit-plan.ts +18 -16
  30. package/extensions/plan-mode/types.ts +8 -38
  31. package/extensions/plan-mode/utils.ts +20 -0
  32. package/package.json +2 -1
  33. package/skills/planning-context/SKILL.md +42 -0
  34. package/skills/visual-prototype/SKILL.md +45 -0
  35. package/extensions/plan-mode/__tests__/types.test.ts +0 -70
  36. package/extensions/plan-mode/html/templates/plan.pug +0 -69
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
11
+ ## 0.16.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 5c70b28: Reframe plan-mode HTML as a planning-phase visual aid instead of a finalization receipt.
16
+
17
+ - `submit_plan` no longer generates `plan.html`. It writes only `tasks.jsonl`, `HANDOFF.md`, and the manifest entry. The previous HTML duplicated the handoff and task list (already tracked elsewhere) and was never opened.
18
+ - Added a `preview_prototype` tool, available during planning. It renders self-contained Pug to a standalone HTML visual aid under `.plans/_prototypes/`, opens it, and notifies the path — so the user can react to a UI/component/style design _before_ the plan hardens.
19
+ - Added a bundled `visual-prototype` skill that routes UI/component/layout/style planning work to `preview_prototype` before `submit_plan`.
20
+ - Added a bundled `planning-context` skill that drives the living `context.md` deliberation discipline (intent, decisions, constraints, open questions, discarded options).
21
+
3
22
  ## 0.15.1
4
23
 
5
24
  ### Patch 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
  });
@@ -1,43 +1,34 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { renderPlanHtml } from '../html/render.js';
3
- import type { PlanData } from '../types.js';
4
-
5
- const plan: PlanData = {
6
- title: 'Refactor Auth',
7
- planName: 'refactor-auth',
8
- handoff: '# Handoff\nShip carefully.',
9
- tasks: [
10
- {
11
- _type: 'task',
12
- id: 't-001',
13
- description: 'Add auth middleware',
14
- details: 'Implement the middleware.',
15
- status: 'pending',
16
- created_at: '2026-05-27T12:00:00.000Z',
17
- updated_at: '2026-05-27T12:00:00.000Z',
18
- },
19
- ],
20
- };
21
-
22
- describe('renderPlanHtml', () => {
23
- test('renders title, task count, handoff, and tasks', () => {
24
- const html = renderPlanHtml(plan);
25
-
26
- expect(html).toContain('Refactor Auth');
27
- expect(html).toContain('1 task');
28
- expect(html).toContain('Ship carefully.');
29
- expect(html).toContain('t-001');
30
- expect(html).toContain('Add auth middleware');
2
+ import { renderPrototypeHtml } from '../html/render.js';
3
+
4
+ describe('renderPrototypeHtml', () => {
5
+ test('renders title and intent in a minimal header', () => {
6
+ const html = renderPrototypeHtml('Sidebar redesign', 'Left-aligned nav with icons', '.nav Nav');
7
+
8
+ expect(html).toContain('Sidebar redesign');
9
+ expect(html).toContain('Left-aligned nav with icons');
10
+ expect(html).toContain('Prototype');
11
+ });
12
+
13
+ test('renders the Pug body markup', () => {
14
+ const html = renderPrototypeHtml('Card', 'A product card', '.card Product card');
15
+
16
+ expect(html).toContain('class="card"');
17
+ expect(html).toContain('Product card');
31
18
  });
32
19
 
33
- test('omits prototypes section when no prototype is provided', () => {
34
- expect(renderPlanHtml(plan)).not.toContain('Prototype');
20
+ test('does not render tasks or handoff sections', () => {
21
+ const html = renderPrototypeHtml('Card', 'A product card', '.card Hello');
22
+
23
+ expect(html).not.toContain('Handoff');
24
+ expect(html).not.toContain('Tasks');
25
+ expect(html).not.toContain('Depends on');
35
26
  });
36
27
 
37
- test('renders optional prototype pug markup', () => {
38
- const html = renderPlanHtml(plan, '.mockup Prototype card');
28
+ test('handles an empty prototype body without throwing', () => {
29
+ const html = renderPrototypeHtml('Empty', 'Nothing yet', '');
39
30
 
40
- expect(html).toContain('Prototype');
41
- expect(html).toContain('Prototype card');
31
+ expect(html).toContain('Empty');
32
+ expect(html).toContain('Nothing yet');
42
33
  });
43
34
  });
@@ -45,3 +45,54 @@ describe('bundled technical-options skill', () => {
45
45
  expect(content).toMatch(/challenge.*framing|framing.*challenge/i);
46
46
  });
47
47
  });
48
+
49
+ describe('bundled visual-prototype skill', () => {
50
+ const skillDir = join(PKG_ROOT, 'skills', 'visual-prototype');
51
+ const skillFile = join(skillDir, 'SKILL.md');
52
+
53
+ test('SKILL.md exists', () => {
54
+ expect(existsSync(skillFile)).toBe(true);
55
+ });
56
+
57
+ test('has valid frontmatter with name and description', () => {
58
+ const content = readFileSync(skillFile, 'utf-8');
59
+ expect(content).toMatch(/^---\n/);
60
+ expect(content).toMatch(/name:\s*visual-prototype/);
61
+ expect(content).toMatch(/description:/);
62
+ });
63
+
64
+ test('routes on visual work, not backend-only', () => {
65
+ const content = readFileSync(skillFile, 'utf-8');
66
+ expect(content).toMatch(/description:.*\b(UI|component|layout|style)\b/i);
67
+ expect(content).toMatch(/not for.*backend|backend-only/i);
68
+ });
69
+
70
+ test('directs use of preview_prototype before submit_plan', () => {
71
+ const content = readFileSync(skillFile, 'utf-8');
72
+ expect(content).toContain('preview_prototype');
73
+ expect(content).toContain('submit_plan');
74
+ });
75
+ });
76
+
77
+ describe('bundled planning-context skill', () => {
78
+ const skillDir = join(PKG_ROOT, 'skills', 'planning-context');
79
+ const skillFile = join(skillDir, 'SKILL.md');
80
+
81
+ test('SKILL.md exists', () => {
82
+ expect(existsSync(skillFile)).toBe(true);
83
+ });
84
+
85
+ test('has valid frontmatter with name and description', () => {
86
+ const content = readFileSync(skillFile, 'utf-8');
87
+ expect(content).toMatch(/^---\n/);
88
+ expect(content).toMatch(/name:\s*planning-context/);
89
+ expect(content).toMatch(/description:/);
90
+ });
91
+
92
+ test('covers context.md deliberation sections', () => {
93
+ const content = readFileSync(skillFile, 'utf-8');
94
+ expect(content).toContain('context.md');
95
+ expect(content).toMatch(/discarded options/i);
96
+ expect(content).toMatch(/open questions/i);
97
+ });
98
+ });
@@ -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
+ });