@dreki-gg/pi-plan-mode 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +44 -0
- package/extensions/plan-mode/__tests__/types.test.ts +55 -0
- package/extensions/plan-mode/constants.ts +2 -1
- package/extensions/plan-mode/html/render.ts +67 -0
- package/extensions/plan-mode/html/templates/plan.pug +58 -0
- package/extensions/plan-mode/index.ts +60 -46
- package/extensions/plan-mode/plan-storage.ts +1 -80
- package/extensions/plan-mode/prompts.ts +28 -32
- package/extensions/plan-mode/pug.d.ts +5 -0
- package/extensions/plan-mode/resume.ts +38 -37
- package/extensions/plan-mode/storage/atomic-write.ts +53 -0
- package/extensions/plan-mode/storage/plan-storage.ts +47 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
- package/extensions/plan-mode/storage/task-storage.ts +44 -0
- package/extensions/plan-mode/tools/submit-plan.ts +39 -81
- package/extensions/plan-mode/tools/update-task.ts +82 -0
- package/extensions/plan-mode/types.ts +56 -3
- package/extensions/plan-mode/ui.ts +10 -10
- package/package.json +3 -2
- package/extensions/plan-mode/plans-json.ts +0 -48
- package/extensions/plan-mode/tools/update-step.ts +0 -146
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Refactor plan-mode to conversational planning with JSONL task storage and HTML output. Replace steps with task records, add atomic writes, Pug-based plan.html generation, and migrate manifest to JSONL. Update subagent prompts.
|
|
8
|
+
|
|
3
9
|
## 0.13.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-atomic-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('writeFileAtomic', () => {
|
|
18
|
+
test('writes complete content to the target path', async () => {
|
|
19
|
+
const target = join(dir, 'data.txt');
|
|
20
|
+
|
|
21
|
+
await writeFileAtomic(target, 'hello world');
|
|
22
|
+
|
|
23
|
+
expect(await readFile(target, 'utf8')).toBe('hello world');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('replaces existing content atomically from the caller perspective', async () => {
|
|
27
|
+
const target = join(dir, 'data.txt');
|
|
28
|
+
await writeFile(target, 'old');
|
|
29
|
+
|
|
30
|
+
await writeFileAtomic(target, 'new');
|
|
31
|
+
|
|
32
|
+
expect(await readFile(target, 'utf8')).toBe('new');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('leaves target untouched when writing fails before rename', async () => {
|
|
36
|
+
const target = join(dir, 'data.txt');
|
|
37
|
+
await writeFile(target, 'original');
|
|
38
|
+
|
|
39
|
+
await expect(writeFileAtomic(target, 'next', { mode: 0o400 })).rejects.toThrow();
|
|
40
|
+
|
|
41
|
+
expect(await readFile(target, 'utf8')).toBe('original');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('omits prototypes section when no prototype is provided', () => {
|
|
34
|
+
expect(renderPlanHtml(plan)).not.toContain('Prototype');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('renders optional prototype pug markup', () => {
|
|
38
|
+
const html = renderPlanHtml(plan, '.mockup Prototype card');
|
|
39
|
+
|
|
40
|
+
expect(html).toContain('Prototype');
|
|
41
|
+
expect(html).toContain('Prototype card');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { readPlansManifest, upsertPlanEntry, writePlansManifest } from '../storage/plans-manifest.js';
|
|
7
|
+
|
|
8
|
+
const originalCwd = process.cwd();
|
|
9
|
+
let dir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-')); chdir(dir); });
|
|
12
|
+
afterEach(async () => { chdir(originalCwd); await rm(dir, { recursive: true, force: true }); });
|
|
13
|
+
|
|
14
|
+
describe('plans.jsonl manifest', () => {
|
|
15
|
+
test('round trips entries', async () => {
|
|
16
|
+
const entry = { _type: 'plan' as const, name: 'refactor', status: 'in-progress' as const, title: 'Refactor', created_at: 'now', completed_at: null };
|
|
17
|
+
await writePlansManifest([entry]);
|
|
18
|
+
await expect(readPlansManifest()).resolves.toEqual([entry]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('upserts new entries', async () => {
|
|
22
|
+
await upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' });
|
|
23
|
+
const entries = await readPlansManifest();
|
|
24
|
+
expect(entries).toHaveLength(1);
|
|
25
|
+
expect(entries[0]?.name).toBe('new-plan');
|
|
26
|
+
expect(entries[0]?.title).toBe('New Plan');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('upserts existing entries without changing created_at', async () => {
|
|
30
|
+
await writePlansManifest([{ _type: 'plan', name: 'p', status: 'in-progress', title: 'Old', created_at: 'created', completed_at: null }]);
|
|
31
|
+
await upsertPlanEntry('p', { status: 'done', title: 'New' });
|
|
32
|
+
const [entry] = await readPlansManifest();
|
|
33
|
+
expect(entry.created_at).toBe('created');
|
|
34
|
+
expect(entry.status).toBe('done');
|
|
35
|
+
expect(entry.completed_at).toBeString();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
|
|
6
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
10
|
+
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
11
|
+
const task: TaskRecord = { _type: 'task', id: 't-001', description: 'Do work', details: 'Details', status: 'pending', created_at: now, updated_at: now };
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-')); });
|
|
14
|
+
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
15
|
+
|
|
16
|
+
describe('tasks.jsonl storage', () => {
|
|
17
|
+
test('round trips meta and tasks', async () => {
|
|
18
|
+
await writeTasksJsonl(dir, meta, [task]);
|
|
19
|
+
await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('missing file returns undefined', async () => {
|
|
23
|
+
await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects corrupt lines', async () => {
|
|
27
|
+
await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
|
|
28
|
+
await expect(readTasksJsonl(dir)).rejects.toThrow(/Invalid JSONL/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('rejects empty files', async () => {
|
|
32
|
+
await Bun.write(join(dir, 'tasks.jsonl'), '');
|
|
33
|
+
await expect(readTasksJsonl(dir)).rejects.toThrow(/meta/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('updates a task by id and rewrites the snapshot', async () => {
|
|
37
|
+
await writeTasksJsonl(dir, meta, [task]);
|
|
38
|
+
const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
|
|
39
|
+
|
|
40
|
+
expect(updated.status).toBe('done');
|
|
41
|
+
expect(updated.notes).toBe('finished');
|
|
42
|
+
expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isTaskMeta, isTaskRecord } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
5
|
+
|
|
6
|
+
describe('task record type guards', () => {
|
|
7
|
+
test('accepts valid task records', () => {
|
|
8
|
+
expect(
|
|
9
|
+
isTaskRecord({
|
|
10
|
+
_type: 'task',
|
|
11
|
+
id: 't-001',
|
|
12
|
+
description: 'Do work',
|
|
13
|
+
details: 'Full instructions',
|
|
14
|
+
status: 'pending',
|
|
15
|
+
depends_on: ['t-000'],
|
|
16
|
+
notes: 'note',
|
|
17
|
+
created_at: now,
|
|
18
|
+
updated_at: now,
|
|
19
|
+
}),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('rejects malformed task records', () => {
|
|
24
|
+
expect(isTaskRecord({ _type: 'task', id: 't-001', status: 'pending' })).toBe(false);
|
|
25
|
+
expect(
|
|
26
|
+
isTaskRecord({
|
|
27
|
+
_type: 'task',
|
|
28
|
+
id: 't-001',
|
|
29
|
+
description: 'Do work',
|
|
30
|
+
details: 'Full instructions',
|
|
31
|
+
status: 'unknown',
|
|
32
|
+
created_at: now,
|
|
33
|
+
updated_at: now,
|
|
34
|
+
}),
|
|
35
|
+
).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('task meta type guard', () => {
|
|
40
|
+
test('accepts valid meta records', () => {
|
|
41
|
+
expect(
|
|
42
|
+
isTaskMeta({
|
|
43
|
+
_type: 'meta',
|
|
44
|
+
title: 'Refactor',
|
|
45
|
+
plan_name: 'refactor',
|
|
46
|
+
created_at: now,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rejects malformed meta records', () => {
|
|
52
|
+
expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
|
|
53
|
+
expect(isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now })).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -10,12 +10,13 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'write',
|
|
13
14
|
'questionnaire',
|
|
14
15
|
'search_skills',
|
|
15
16
|
'subagent',
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', '
|
|
19
|
+
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
|
|
19
20
|
|
|
20
21
|
// ── Model + thinking presets ─────────────────────────────────────────────────
|
|
21
22
|
export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pug from 'pug';
|
|
5
|
+
import type { PlanData } from '../types.js';
|
|
6
|
+
|
|
7
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'plan.pug');
|
|
8
|
+
|
|
9
|
+
export function renderPlanHtml(plan: PlanData, prototype?: string): string {
|
|
10
|
+
const template = readFileSync(templatePath, 'utf8');
|
|
11
|
+
const prototypeHtml = prototype?.trim() ? pug.render(prototype) : undefined;
|
|
12
|
+
|
|
13
|
+
return pug.render(template, {
|
|
14
|
+
filename: templatePath,
|
|
15
|
+
plan,
|
|
16
|
+
prototypeHtml,
|
|
17
|
+
handoffHtml: markdownToHtml(plan.handoff),
|
|
18
|
+
taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
|
|
19
|
+
generatedAt: new Date().toISOString(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function markdownToHtml(markdown: string): string {
|
|
24
|
+
const lines = markdown.split(/\r?\n/);
|
|
25
|
+
const html: string[] = [];
|
|
26
|
+
let inList = false;
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (line.startsWith('# ')) {
|
|
30
|
+
if (inList) {
|
|
31
|
+
html.push('</ul>');
|
|
32
|
+
inList = false;
|
|
33
|
+
}
|
|
34
|
+
html.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
|
|
35
|
+
} else if (line.startsWith('## ')) {
|
|
36
|
+
if (inList) {
|
|
37
|
+
html.push('</ul>');
|
|
38
|
+
inList = false;
|
|
39
|
+
}
|
|
40
|
+
html.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
|
|
41
|
+
} else if (line.startsWith('- ')) {
|
|
42
|
+
if (!inList) {
|
|
43
|
+
html.push('<ul>');
|
|
44
|
+
inList = true;
|
|
45
|
+
}
|
|
46
|
+
html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
|
|
47
|
+
} else if (line.trim()) {
|
|
48
|
+
if (inList) {
|
|
49
|
+
html.push('</ul>');
|
|
50
|
+
inList = false;
|
|
51
|
+
}
|
|
52
|
+
html.push(`<p>${escapeHtml(line)}</p>`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (inList) html.push('</ul>');
|
|
57
|
+
return html.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeHtml(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replaceAll('&', '&')
|
|
63
|
+
.replaceAll('<', '<')
|
|
64
|
+
.replaceAll('>', '>')
|
|
65
|
+
.replaceAll('"', '"')
|
|
66
|
+
.replaceAll("'", ''');
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
doctype html
|
|
2
|
+
html(lang="en")
|
|
3
|
+
head
|
|
4
|
+
meta(charset="utf-8")
|
|
5
|
+
meta(name="viewport" content="width=device-width, initial-scale=1")
|
|
6
|
+
title= plan.title
|
|
7
|
+
style.
|
|
8
|
+
:root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; --done:#33d69f; --blocked:#ff6b6b; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
11
|
+
main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
|
|
12
|
+
header { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:28px; }
|
|
13
|
+
h1 { font-size:34px; line-height:1.08; margin:0 0 10px; letter-spacing:-0.04em; }
|
|
14
|
+
h2 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:0 0 14px; }
|
|
15
|
+
.meta { color:var(--muted); display:flex; gap:12px; flex-wrap:wrap; }
|
|
16
|
+
.pill { border:1px solid var(--line); border-radius:999px; padding:6px 10px; background:rgba(255,255,255,0.03); }
|
|
17
|
+
section { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; margin:18px 0; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
|
|
18
|
+
.handoff :first-child { margin-top:0; }
|
|
19
|
+
.handoff :last-child { margin-bottom:0; }
|
|
20
|
+
.tasks { display:grid; gap:12px; }
|
|
21
|
+
.task { border:1px solid var(--line); border-radius:14px; padding:16px; background:#0f1218; }
|
|
22
|
+
.task-top { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
|
|
23
|
+
.id { color:var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
24
|
+
.status { margin-left:auto; color:var(--muted); }
|
|
25
|
+
.task.done .status { color:var(--done); }
|
|
26
|
+
.task.blocked .status { color:var(--blocked); }
|
|
27
|
+
.description { font-weight:650; }
|
|
28
|
+
.details { color:#c7ccda; margin:0; white-space:pre-wrap; }
|
|
29
|
+
.deps { color:var(--muted); margin-top:10px; font-size:12px; }
|
|
30
|
+
body
|
|
31
|
+
main
|
|
32
|
+
header
|
|
33
|
+
div
|
|
34
|
+
h1= plan.title
|
|
35
|
+
.meta
|
|
36
|
+
span.pill= plan.planName
|
|
37
|
+
span.pill= taskCountLabel
|
|
38
|
+
.meta
|
|
39
|
+
span.pill Generated #{generatedAt}
|
|
40
|
+
section.handoff
|
|
41
|
+
h2 Handoff
|
|
42
|
+
!= handoffHtml
|
|
43
|
+
section
|
|
44
|
+
h2 Tasks
|
|
45
|
+
.tasks
|
|
46
|
+
each task in plan.tasks
|
|
47
|
+
article.task(class=task.status)
|
|
48
|
+
.task-top
|
|
49
|
+
span.id= task.id
|
|
50
|
+
span.description= task.description
|
|
51
|
+
span.status= task.status
|
|
52
|
+
p.details= task.details
|
|
53
|
+
if task.depends_on && task.depends_on.length
|
|
54
|
+
.deps Depends on #{task.depends_on.join(', ')}
|
|
55
|
+
if prototypeHtml
|
|
56
|
+
section.prototype
|
|
57
|
+
h2 Prototype
|
|
58
|
+
!= prototypeHtml
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two-phase workflow:
|
|
5
5
|
* 1. PLAN phase — read-only tools + submit_plan tool + medium thinking
|
|
6
|
-
* 2. EXECUTE phase — full tools +
|
|
6
|
+
* 2. EXECUTE phase — full tools + update_task tool + low thinking
|
|
7
7
|
*
|
|
8
8
|
* Commands:
|
|
9
9
|
* /plan [prompt] — enter plan mode
|
|
@@ -21,14 +21,16 @@ import { Key } from '@earendil-works/pi-tui';
|
|
|
21
21
|
import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
|
|
22
22
|
import type { ThinkingLevel } from './types.js';
|
|
23
23
|
import { PlanModeState } from './state.js';
|
|
24
|
-
import {
|
|
24
|
+
import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
|
|
25
|
+
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
26
|
+
import { upsertPlanEntry } from './storage/plans-manifest.js';
|
|
25
27
|
import { updateUI } from './ui.js';
|
|
26
28
|
import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
|
|
27
29
|
import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
|
|
28
30
|
import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
|
|
29
31
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
30
32
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
31
|
-
import {
|
|
33
|
+
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
32
34
|
import { isSafeCommand } from './utils.js';
|
|
33
35
|
|
|
34
36
|
export default function planMode(pi: ExtensionAPI): void {
|
|
@@ -50,12 +52,20 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
50
52
|
},
|
|
51
53
|
});
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
registerUpdateTaskTool(pi, {
|
|
54
56
|
getPlan: () => state.plan,
|
|
55
|
-
|
|
56
|
-
if (!state.plan) return;
|
|
57
|
-
state.plan.
|
|
58
|
-
if (
|
|
57
|
+
onTaskUpdated: async (taskId, status, notes) => {
|
|
58
|
+
if (!state.plan || !state.planDir) return;
|
|
59
|
+
const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
|
|
60
|
+
if (!task) return;
|
|
61
|
+
task.status = status;
|
|
62
|
+
task.updated_at = new Date().toISOString();
|
|
63
|
+
if (notes) task.notes = notes;
|
|
64
|
+
await writeTasksJsonl(
|
|
65
|
+
state.planDir,
|
|
66
|
+
{ _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? task.updated_at },
|
|
67
|
+
state.plan.tasks,
|
|
68
|
+
);
|
|
59
69
|
state.persist(pi);
|
|
60
70
|
},
|
|
61
71
|
});
|
|
@@ -85,8 +95,9 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
85
95
|
ctx.ui.notify('No plan to execute.', 'error');
|
|
86
96
|
return;
|
|
87
97
|
}
|
|
88
|
-
const
|
|
89
|
-
const
|
|
98
|
+
const taskList = state.plan.tasks.map((task) => `${task.id}. ${task.description}`).join('\n');
|
|
99
|
+
const first = state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
|
|
100
|
+
const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
|
|
90
101
|
await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
|
|
91
102
|
},
|
|
92
103
|
});
|
|
@@ -94,13 +105,13 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
94
105
|
pi.registerCommand('todos', {
|
|
95
106
|
description: 'Show current plan progress',
|
|
96
107
|
handler: async (_args, ctx) => {
|
|
97
|
-
if (!state.plan || state.plan.
|
|
108
|
+
if (!state.plan || state.plan.tasks.length === 0) {
|
|
98
109
|
ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
|
|
99
110
|
return;
|
|
100
111
|
}
|
|
101
112
|
const statusIcon = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' } as const;
|
|
102
|
-
const list = state.plan.
|
|
103
|
-
.map((s
|
|
113
|
+
const list = state.plan.tasks
|
|
114
|
+
.map((s) => `${s.id}. ${statusIcon[s.status]} ${s.description}`)
|
|
104
115
|
.join('\n');
|
|
105
116
|
ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
|
|
106
117
|
},
|
|
@@ -157,42 +168,42 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
157
168
|
}
|
|
158
169
|
});
|
|
159
170
|
|
|
160
|
-
// ── Event: agent_end — blocked
|
|
171
|
+
// ── Event: agent_end — blocked tasks, completion, post-plan menu ──────────
|
|
161
172
|
pi.on('agent_end', async (_event, ctx) => {
|
|
162
|
-
// ── During execution: handle blocked
|
|
173
|
+
// ── During execution: handle blocked tasks and completion ──
|
|
163
174
|
if (state.executing && state.plan) {
|
|
164
|
-
const blocked = state.plan.
|
|
165
|
-
.map((s, i) => ({ ...s, num: i + 1 }))
|
|
166
|
-
.filter((s) => s.status === 'blocked');
|
|
175
|
+
const blocked = state.plan.tasks.filter((s) => s.status === 'blocked');
|
|
167
176
|
|
|
168
177
|
if (blocked.length > 0) {
|
|
169
178
|
const bs = blocked[0];
|
|
170
179
|
const info = bs.notes
|
|
171
|
-
? `
|
|
172
|
-
: `
|
|
180
|
+
? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
|
|
181
|
+
: `Task ${bs.id}: ${bs.description}`;
|
|
173
182
|
|
|
174
|
-
const choice = await ctx.ui.select(`
|
|
175
|
-
'Skip this
|
|
183
|
+
const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
|
|
184
|
+
'Skip this task', 'Provide instructions', 'Re-plan', 'Abort execution',
|
|
176
185
|
]);
|
|
177
186
|
|
|
178
|
-
if (choice === 'Skip this
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
if (choice === 'Skip this task') {
|
|
188
|
+
bs.status = 'skipped';
|
|
189
|
+
bs.updated_at = new Date().toISOString();
|
|
190
|
+
await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
|
|
181
191
|
updateUI(state, ctx);
|
|
182
192
|
state.persist(pi);
|
|
183
|
-
if (state.plan.
|
|
184
|
-
pi.sendUserMessage('The blocked
|
|
193
|
+
if (state.plan.tasks.some((s) => s.status === 'pending')) {
|
|
194
|
+
pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', { deliverAs: 'followUp' });
|
|
185
195
|
}
|
|
186
196
|
} else if (choice === 'Provide instructions') {
|
|
187
|
-
const instructions = await ctx.ui.editor('Instructions for the blocked
|
|
197
|
+
const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
|
|
188
198
|
if (instructions?.trim()) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
199
|
+
bs.status = 'pending';
|
|
200
|
+
bs.notes = undefined;
|
|
201
|
+
bs.updated_at = new Date().toISOString();
|
|
202
|
+
await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
|
|
192
203
|
updateUI(state, ctx);
|
|
193
204
|
state.persist(pi);
|
|
194
205
|
pi.sendUserMessage(
|
|
195
|
-
`Retry
|
|
206
|
+
`Retry task ${bs.id} with these additional instructions: ${instructions.trim()}`,
|
|
196
207
|
{ deliverAs: 'followUp' },
|
|
197
208
|
);
|
|
198
209
|
}
|
|
@@ -200,7 +211,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
200
211
|
} else if (choice === 'Re-plan') {
|
|
201
212
|
await enterPlanMode(state, pi, ctx);
|
|
202
213
|
pi.sendUserMessage(
|
|
203
|
-
`
|
|
214
|
+
`Task ${bs.id} was blocked: ${bs.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
|
|
204
215
|
{ deliverAs: 'followUp' },
|
|
205
216
|
);
|
|
206
217
|
return;
|
|
@@ -211,24 +222,24 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
// Check completion
|
|
214
|
-
const allResolved = state.plan.
|
|
225
|
+
const allResolved = state.plan.tasks.every((s) => s.status === 'done' || s.status === 'skipped');
|
|
215
226
|
if (allResolved) {
|
|
216
227
|
if (state.planDir) {
|
|
217
|
-
await
|
|
218
|
-
await
|
|
228
|
+
await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
|
|
229
|
+
await writeTasksJsonl(state.planDir, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString() }, state.plan.tasks);
|
|
219
230
|
}
|
|
220
|
-
const done = state.plan.
|
|
221
|
-
const skipped = state.plan.
|
|
222
|
-
const total = state.plan.
|
|
231
|
+
const done = state.plan.tasks.filter((s) => s.status === 'done').length;
|
|
232
|
+
const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
|
|
233
|
+
const total = state.plan.tasks.length;
|
|
223
234
|
const stats = skipped > 0
|
|
224
235
|
? `${done}/${total} done, ${skipped} skipped`
|
|
225
236
|
: `${done}/${total} done`;
|
|
226
237
|
|
|
227
|
-
// Build a summary of what was actually done from
|
|
228
|
-
const changeSummary = state.plan.
|
|
229
|
-
.map((s
|
|
238
|
+
// Build a summary of what was actually done from task notes
|
|
239
|
+
const changeSummary = state.plan.tasks
|
|
240
|
+
.map((s) => {
|
|
230
241
|
const icon = s.status === 'done' ? '✓' : '⊘';
|
|
231
|
-
const label = `${
|
|
242
|
+
const label = `${s.id}. ${icon} ${s.description}`;
|
|
232
243
|
return s.notes ? `${label}\n ${s.notes}` : label;
|
|
233
244
|
})
|
|
234
245
|
.join('\n');
|
|
@@ -276,8 +287,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
276
287
|
|
|
277
288
|
- Missing edge cases or error handling
|
|
278
289
|
- Incorrect assumptions about the codebase
|
|
279
|
-
-
|
|
280
|
-
- Missing dependencies between
|
|
290
|
+
- Tasks that are too vague or could be misinterpreted
|
|
291
|
+
- Missing dependencies between tasks
|
|
281
292
|
- Simpler alternatives that were overlooked
|
|
282
293
|
|
|
283
294
|
After your review, call submit_plan again with the improved plan.`,
|
|
@@ -302,7 +313,10 @@ After your review, call submit_plan again with the improved plan.`,
|
|
|
302
313
|
const pending = await readAndClearExecPending();
|
|
303
314
|
if (pending) {
|
|
304
315
|
state.planDir = pending.planDir;
|
|
305
|
-
|
|
316
|
+
{
|
|
317
|
+
const snapshot = await readTasksJsonl(pending.planDir);
|
|
318
|
+
state.plan = snapshot ? { title: snapshot.meta.title, planName: snapshot.meta.plan_name, handoff: (await loadHandoff(pending.planDir)) ?? '', tasks: snapshot.tasks } : undefined;
|
|
319
|
+
}
|
|
306
320
|
if (state.plan) {
|
|
307
321
|
state.executing = true;
|
|
308
322
|
state.planEnabled = false;
|