@dreki-gg/pi-plan-mode 0.14.5 → 0.15.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 +17 -0
- package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
- package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
- package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
- package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
- package/extensions/plan-mode/__tests__/types.test.ts +3 -1
- package/extensions/plan-mode/__tests__/utils.test.ts +61 -2
- package/extensions/plan-mode/context-filter.ts +1 -4
- package/extensions/plan-mode/html/render.ts +33 -16
- package/extensions/plan-mode/index.ts +88 -17
- package/extensions/plan-mode/phase-transitions.ts +9 -5
- package/extensions/plan-mode/plan-storage.ts +6 -1
- package/extensions/plan-mode/prompts.ts +3 -4
- package/extensions/plan-mode/resume.ts +20 -5
- package/extensions/plan-mode/state.ts +1 -3
- package/extensions/plan-mode/storage/atomic-write.ts +5 -1
- package/extensions/plan-mode/storage/plan-storage.ts +3 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
- package/extensions/plan-mode/storage/task-storage.ts +29 -6
- package/extensions/plan-mode/tools/submit-plan.ts +34 -10
- package/extensions/plan-mode/tools/update-task.ts +51 -11
- package/extensions/plan-mode/utils.ts +10 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.15.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix execution stopping after the final task is marked done. `update_task` no longer terminates the turn merely because the task queue is empty, so the agent can run its closing summary / validation pass before the `agent_end` completion handler takes over. The `blocked` branch still terminates to pause for user input.
|
|
8
|
+
|
|
9
|
+
## 0.15.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Restrict write/edit tools to .plans/ directory only during plan phase. Add isPlanPath utility. Update prompt to document --help/man support and write restrictions.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies []:
|
|
18
|
+
- @dreki-gg/pi-command-sandbox@0.3.0
|
|
19
|
+
|
|
3
20
|
## 0.14.5
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
|
@@ -102,9 +102,9 @@ describe('agent_end handler safety', () => {
|
|
|
102
102
|
if (violations.length > 0) {
|
|
103
103
|
throw new Error(
|
|
104
104
|
`Found sendUserMessage calls inside agent_end without deliverAs.\n` +
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
`The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
|
|
106
|
+
`Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
|
|
107
|
+
`Fix: add { deliverAs: 'followUp' } to each call.`,
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
});
|
|
@@ -4,10 +4,21 @@ import type { PlanData, TaskRecord } from '../types.js';
|
|
|
4
4
|
|
|
5
5
|
function makePlan(overrides?: Partial<PlanData>): PlanData {
|
|
6
6
|
const task: TaskRecord = {
|
|
7
|
-
_type: 'task',
|
|
8
|
-
|
|
7
|
+
_type: 'task',
|
|
8
|
+
id: 't-001',
|
|
9
|
+
description: 'Do work',
|
|
10
|
+
details: 'Details',
|
|
11
|
+
status: 'pending',
|
|
12
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
13
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
title: 'Test Plan',
|
|
17
|
+
planName: 'test-plan',
|
|
18
|
+
handoff: '# Handoff',
|
|
19
|
+
tasks: [task],
|
|
20
|
+
...overrides,
|
|
9
21
|
};
|
|
10
|
-
return { title: 'Test Plan', planName: 'test-plan', handoff: '# Handoff', tasks: [task], ...overrides };
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
describe('PlanModeState', () => {
|
|
@@ -3,17 +3,34 @@ import { chdir } from 'node:process';
|
|
|
3
3
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
readPlansManifest,
|
|
8
|
+
upsertPlanEntry,
|
|
9
|
+
writePlansManifest,
|
|
10
|
+
} from '../storage/plans-manifest.js';
|
|
7
11
|
|
|
8
12
|
const originalCwd = process.cwd();
|
|
9
13
|
let dir: string;
|
|
10
14
|
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-'));
|
|
17
|
+
chdir(dir);
|
|
18
|
+
});
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
chdir(originalCwd);
|
|
21
|
+
await rm(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
13
23
|
|
|
14
24
|
describe('plans.jsonl manifest', () => {
|
|
15
25
|
test('round trips entries', async () => {
|
|
16
|
-
const entry = {
|
|
26
|
+
const entry = {
|
|
27
|
+
_type: 'plan' as const,
|
|
28
|
+
name: 'refactor',
|
|
29
|
+
status: 'in-progress' as const,
|
|
30
|
+
title: 'Refactor',
|
|
31
|
+
created_at: 'now',
|
|
32
|
+
completed_at: null,
|
|
33
|
+
};
|
|
17
34
|
await writePlansManifest([entry]);
|
|
18
35
|
await expect(readPlansManifest()).resolves.toEqual([entry]);
|
|
19
36
|
});
|
|
@@ -27,7 +44,16 @@ describe('plans.jsonl manifest', () => {
|
|
|
27
44
|
});
|
|
28
45
|
|
|
29
46
|
test('upserts existing entries without changing created_at', async () => {
|
|
30
|
-
await writePlansManifest([
|
|
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
|
+
]);
|
|
31
57
|
await upsertPlanEntry('p', { status: 'done', title: 'New' });
|
|
32
58
|
const [entry] = await readPlansManifest();
|
|
33
59
|
expect(entry.created_at).toBe('created');
|
|
@@ -5,7 +5,15 @@ import type { PlanData, TaskRecord } from '../types.js';
|
|
|
5
5
|
|
|
6
6
|
const now = '2026-01-01T00:00:00Z';
|
|
7
7
|
function makeTask(overrides?: Partial<TaskRecord>): TaskRecord {
|
|
8
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
_type: 'task',
|
|
10
|
+
id: 't-001',
|
|
11
|
+
description: 'Do work',
|
|
12
|
+
status: 'pending',
|
|
13
|
+
created_at: now,
|
|
14
|
+
updated_at: now,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
describe('buildPlanModePrompt', () => {
|
|
@@ -54,13 +62,23 @@ describe('buildExecutionPrompt', () => {
|
|
|
54
62
|
});
|
|
55
63
|
|
|
56
64
|
test('includes Details line when task has details', () => {
|
|
57
|
-
const plan: PlanData = {
|
|
65
|
+
const plan: PlanData = {
|
|
66
|
+
title: 'Test',
|
|
67
|
+
planName: 'test',
|
|
68
|
+
handoff: '# H',
|
|
69
|
+
tasks: [makeTask({ details: 'Full instructions here' })],
|
|
70
|
+
};
|
|
58
71
|
const prompt = buildExecutionPrompt(plan)!;
|
|
59
72
|
expect(prompt).toContain('Details: Full instructions here');
|
|
60
73
|
});
|
|
61
74
|
|
|
62
75
|
test('returns undefined when no pending tasks', () => {
|
|
63
|
-
const plan: PlanData = {
|
|
76
|
+
const plan: PlanData = {
|
|
77
|
+
title: 'Test',
|
|
78
|
+
planName: 'test',
|
|
79
|
+
handoff: '# H',
|
|
80
|
+
tasks: [makeTask({ status: 'done' })],
|
|
81
|
+
};
|
|
64
82
|
expect(buildExecutionPrompt(plan)).toBeUndefined();
|
|
65
83
|
});
|
|
66
84
|
});
|
|
@@ -8,10 +8,22 @@ import type { TaskMeta, TaskRecord } from '../types.js';
|
|
|
8
8
|
let dir: string;
|
|
9
9
|
const now = '2026-05-27T12:00:00.000Z';
|
|
10
10
|
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
11
|
-
const task: TaskRecord = {
|
|
11
|
+
const task: TaskRecord = {
|
|
12
|
+
_type: 'task',
|
|
13
|
+
id: 't-001',
|
|
14
|
+
description: 'Do work',
|
|
15
|
+
details: 'Details',
|
|
16
|
+
status: 'pending',
|
|
17
|
+
created_at: now,
|
|
18
|
+
updated_at: now,
|
|
19
|
+
};
|
|
12
20
|
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
|
|
23
|
+
});
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
describe('tasks.jsonl storage', () => {
|
|
17
29
|
test('round trips meta and tasks', async () => {
|
|
@@ -20,7 +32,14 @@ describe('tasks.jsonl storage', () => {
|
|
|
20
32
|
});
|
|
21
33
|
|
|
22
34
|
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
23
|
-
const lightweight: TaskRecord = {
|
|
35
|
+
const lightweight: TaskRecord = {
|
|
36
|
+
_type: 'task',
|
|
37
|
+
id: 't-002',
|
|
38
|
+
description: 'Quick fix',
|
|
39
|
+
status: 'pending',
|
|
40
|
+
created_at: now,
|
|
41
|
+
updated_at: now,
|
|
42
|
+
};
|
|
24
43
|
await writeTasksJsonl(dir, meta, [lightweight]);
|
|
25
44
|
const result = await readTasksJsonl(dir);
|
|
26
45
|
expect(result?.tasks[0]?.id).toBe('t-002');
|
|
@@ -63,6 +63,8 @@ describe('task meta type guard', () => {
|
|
|
63
63
|
|
|
64
64
|
test('rejects malformed meta records', () => {
|
|
65
65
|
expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
|
|
66
|
-
expect(
|
|
66
|
+
expect(
|
|
67
|
+
isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now }),
|
|
68
|
+
).toBe(false);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { isSafeCommand } from '../utils.js';
|
|
2
|
+
import { isSafeCommand, isPlanPath } from '../utils.js';
|
|
3
3
|
|
|
4
4
|
describe('isSafeCommand', () => {
|
|
5
5
|
// ── Commands that SHOULD be allowed ──────────────────────────────────────
|
|
@@ -14,7 +14,9 @@ describe('isSafeCommand', () => {
|
|
|
14
14
|
|
|
15
15
|
test('command with 2>/dev/null stderr redirect', () => {
|
|
16
16
|
expect(
|
|
17
|
-
isSafeCommand(
|
|
17
|
+
isSafeCommand(
|
|
18
|
+
'cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null',
|
|
19
|
+
),
|
|
18
20
|
).toBe(true);
|
|
19
21
|
});
|
|
20
22
|
|
|
@@ -113,4 +115,61 @@ describe('isSafeCommand', () => {
|
|
|
113
115
|
expect(isSafeCommand('touch newfile.ts')).toBe(false);
|
|
114
116
|
});
|
|
115
117
|
});
|
|
118
|
+
|
|
119
|
+
// ── Help/version commands via command-sandbox ────────────────────────────
|
|
120
|
+
describe('help and version commands', () => {
|
|
121
|
+
test('bun --help is allowed', () => {
|
|
122
|
+
expect(isSafeCommand('bun --help')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('man git is allowed', () => {
|
|
126
|
+
expect(isSafeCommand('man git')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('npm --version is allowed', () => {
|
|
130
|
+
expect(isSafeCommand('npm --version')).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('rm --help is still blocked', () => {
|
|
134
|
+
expect(isSafeCommand('rm --help')).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('isPlanPath', () => {
|
|
140
|
+
test('relative .plans/ path', () => {
|
|
141
|
+
expect(isPlanPath('.plans/my-plan/context.md')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('absolute path containing .plans/', () => {
|
|
145
|
+
expect(isPlanPath('/Users/me/project/.plans/my-plan/context.md')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('just .plans/', () => {
|
|
149
|
+
expect(isPlanPath('.plans/foo')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('windows-style backslashes', () => {
|
|
153
|
+
expect(isPlanPath('.plans\\my-plan\\context.md')).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('src/ path is blocked', () => {
|
|
157
|
+
expect(isPlanPath('src/index.ts')).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('root file is blocked', () => {
|
|
161
|
+
expect(isPlanPath('README.md')).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('package.json is blocked', () => {
|
|
165
|
+
expect(isPlanPath('package.json')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('path with plans but not .plans is blocked', () => {
|
|
169
|
+
expect(isPlanPath('src/plans/something.ts')).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('path ending in .plans without slash is blocked', () => {
|
|
173
|
+
expect(isPlanPath('.plans')).toBe(false);
|
|
174
|
+
});
|
|
116
175
|
});
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** Drop everything before the execution start index. */
|
|
7
|
-
export function filterExecutionMessages<T>(
|
|
8
|
-
messages: T[],
|
|
9
|
-
executionStartIdx: number,
|
|
10
|
-
): T[] {
|
|
7
|
+
export function filterExecutionMessages<T>(messages: T[], executionStartIdx: number): T[] {
|
|
11
8
|
return messages.filter((_m, i) => i >= executionStartIdx);
|
|
12
9
|
}
|
|
13
10
|
|
|
@@ -32,7 +32,10 @@ function markdownToHtml(markdown: string): string {
|
|
|
32
32
|
// Fenced code blocks
|
|
33
33
|
if (line.startsWith('```')) {
|
|
34
34
|
if (!inCode) {
|
|
35
|
-
if (inList) {
|
|
35
|
+
if (inList) {
|
|
36
|
+
html.push('</ul>');
|
|
37
|
+
inList = false;
|
|
38
|
+
}
|
|
36
39
|
inCode = true;
|
|
37
40
|
codeLang = line.slice(3).trim();
|
|
38
41
|
codeLines.length = 0;
|
|
@@ -53,7 +56,10 @@ function markdownToHtml(markdown: string): string {
|
|
|
53
56
|
// Headings
|
|
54
57
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
55
58
|
if (headingMatch) {
|
|
56
|
-
if (inList) {
|
|
59
|
+
if (inList) {
|
|
60
|
+
html.push('</ul>');
|
|
61
|
+
inList = false;
|
|
62
|
+
}
|
|
57
63
|
const level = headingMatch[1].length;
|
|
58
64
|
html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
|
|
59
65
|
continue;
|
|
@@ -62,19 +68,28 @@ function markdownToHtml(markdown: string): string {
|
|
|
62
68
|
// List items (- or *)
|
|
63
69
|
const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
|
64
70
|
if (listMatch) {
|
|
65
|
-
if (!inList) {
|
|
71
|
+
if (!inList) {
|
|
72
|
+
html.push('<ul>');
|
|
73
|
+
inList = true;
|
|
74
|
+
}
|
|
66
75
|
html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
|
|
67
76
|
continue;
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
// Blank line
|
|
71
80
|
if (!line.trim()) {
|
|
72
|
-
if (inList) {
|
|
81
|
+
if (inList) {
|
|
82
|
+
html.push('</ul>');
|
|
83
|
+
inList = false;
|
|
84
|
+
}
|
|
73
85
|
continue;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
// Paragraph
|
|
77
|
-
if (inList) {
|
|
89
|
+
if (inList) {
|
|
90
|
+
html.push('</ul>');
|
|
91
|
+
inList = false;
|
|
92
|
+
}
|
|
78
93
|
html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
|
|
79
94
|
}
|
|
80
95
|
|
|
@@ -88,17 +103,19 @@ function markdownToHtml(markdown: string): string {
|
|
|
88
103
|
|
|
89
104
|
/** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
|
|
90
105
|
function inlineMarkdown(text: string): string {
|
|
91
|
-
return
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
return (
|
|
107
|
+
text
|
|
108
|
+
// Inline code: `code`
|
|
109
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
110
|
+
// Bold: **text** or __text__
|
|
111
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
112
|
+
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
113
|
+
// Italic: *text* or _text_
|
|
114
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
115
|
+
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
116
|
+
// Links: [text](url)
|
|
117
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
118
|
+
);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
function escapeHtml(value: string): string {
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
20
20
|
import { Key } from '@earendil-works/pi-tui';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
PLAN_TOOLS,
|
|
23
|
+
EXEC_TOOLS,
|
|
24
|
+
PLAN_MODEL,
|
|
25
|
+
PLAN_THINKING,
|
|
26
|
+
EXEC_MODEL,
|
|
27
|
+
EXEC_THINKING,
|
|
28
|
+
} from './constants.js';
|
|
22
29
|
import type { ThinkingLevel } from './types.js';
|
|
23
30
|
import { PlanModeState } from './state.js';
|
|
24
31
|
import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
|
|
@@ -31,7 +38,7 @@ import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js
|
|
|
31
38
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
32
39
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
33
40
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
34
|
-
import { isSafeCommand } from './utils.js';
|
|
41
|
+
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
35
42
|
|
|
36
43
|
export default function planMode(pi: ExtensionAPI): void {
|
|
37
44
|
const state = new PlanModeState();
|
|
@@ -63,7 +70,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
63
70
|
if (notes) task.notes = notes;
|
|
64
71
|
await writeTasksJsonl(
|
|
65
72
|
state.planDir,
|
|
66
|
-
{
|
|
73
|
+
{
|
|
74
|
+
_type: 'meta',
|
|
75
|
+
title: state.plan.title,
|
|
76
|
+
plan_name: state.plan.planName,
|
|
77
|
+
created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
|
|
78
|
+
},
|
|
67
79
|
state.plan.tasks,
|
|
68
80
|
);
|
|
69
81
|
state.persist(pi);
|
|
@@ -72,7 +84,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
72
84
|
|
|
73
85
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
74
86
|
pi.registerCommand('plan', {
|
|
75
|
-
description:
|
|
87
|
+
description:
|
|
88
|
+
'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
|
|
76
89
|
handler: async (args, ctx) => {
|
|
77
90
|
const trimmed = args?.trim();
|
|
78
91
|
if (trimmed === 'resume') {
|
|
@@ -96,7 +109,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
96
109
|
return;
|
|
97
110
|
}
|
|
98
111
|
const taskList = state.plan.tasks.map((task) => `${task.id}. ${task.description}`).join('\n');
|
|
99
|
-
const first =
|
|
112
|
+
const first =
|
|
113
|
+
state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
|
|
100
114
|
const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
|
|
101
115
|
await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
|
|
102
116
|
},
|
|
@@ -128,9 +142,11 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
128
142
|
},
|
|
129
143
|
});
|
|
130
144
|
|
|
131
|
-
// ── Event: block destructive bash in plan mode
|
|
145
|
+
// ── Event: block destructive bash + restrict writes in plan mode ──────────
|
|
132
146
|
pi.on('tool_call', async (event) => {
|
|
133
147
|
if (!state.planEnabled) return;
|
|
148
|
+
|
|
149
|
+
// Block destructive bash commands
|
|
134
150
|
if (event.toolName === 'bash') {
|
|
135
151
|
const command = event.input.command as string;
|
|
136
152
|
if (!isSafeCommand(command)) {
|
|
@@ -140,6 +156,17 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
140
156
|
};
|
|
141
157
|
}
|
|
142
158
|
}
|
|
159
|
+
|
|
160
|
+
// Restrict write to .plans/ directory only
|
|
161
|
+
if (event.toolName === 'write' || event.toolName === 'edit') {
|
|
162
|
+
const path = event.input.path as string;
|
|
163
|
+
if (!isPlanPath(path)) {
|
|
164
|
+
return {
|
|
165
|
+
block: true,
|
|
166
|
+
reason: `Plan mode: writes are restricted to .plans/ directory only.\nPath: ${path}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
143
170
|
});
|
|
144
171
|
|
|
145
172
|
// ── Event: filter context ─────────────────────────────────────────────────
|
|
@@ -155,7 +182,11 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
155
182
|
pi.on('before_agent_start', async () => {
|
|
156
183
|
if (state.planEnabled) {
|
|
157
184
|
return {
|
|
158
|
-
message: {
|
|
185
|
+
message: {
|
|
186
|
+
customType: 'plan-mode-context',
|
|
187
|
+
content: buildPlanModePrompt(),
|
|
188
|
+
display: false,
|
|
189
|
+
},
|
|
159
190
|
};
|
|
160
191
|
}
|
|
161
192
|
if (state.executing && state.plan) {
|
|
@@ -181,17 +212,31 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
181
212
|
: `Task ${bs.id}: ${bs.description}`;
|
|
182
213
|
|
|
183
214
|
const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
|
|
184
|
-
'Skip this task',
|
|
215
|
+
'Skip this task',
|
|
216
|
+
'Provide instructions',
|
|
217
|
+
'Re-plan',
|
|
218
|
+
'Abort execution',
|
|
185
219
|
]);
|
|
186
220
|
|
|
187
221
|
if (choice === 'Skip this task') {
|
|
188
222
|
bs.status = 'skipped';
|
|
189
223
|
bs.updated_at = new Date().toISOString();
|
|
190
|
-
await writeTasksJsonl(
|
|
224
|
+
await writeTasksJsonl(
|
|
225
|
+
state.planDir!,
|
|
226
|
+
{
|
|
227
|
+
_type: 'meta',
|
|
228
|
+
title: state.plan.title,
|
|
229
|
+
plan_name: state.plan.planName,
|
|
230
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
231
|
+
},
|
|
232
|
+
state.plan.tasks,
|
|
233
|
+
);
|
|
191
234
|
updateUI(state, ctx);
|
|
192
235
|
state.persist(pi);
|
|
193
236
|
if (state.plan.tasks.some((s) => s.status === 'pending')) {
|
|
194
|
-
pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', {
|
|
237
|
+
pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', {
|
|
238
|
+
deliverAs: 'followUp',
|
|
239
|
+
});
|
|
195
240
|
}
|
|
196
241
|
} else if (choice === 'Provide instructions') {
|
|
197
242
|
const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
|
|
@@ -199,7 +244,16 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
199
244
|
bs.status = 'pending';
|
|
200
245
|
bs.notes = undefined;
|
|
201
246
|
bs.updated_at = new Date().toISOString();
|
|
202
|
-
await writeTasksJsonl(
|
|
247
|
+
await writeTasksJsonl(
|
|
248
|
+
state.planDir!,
|
|
249
|
+
{
|
|
250
|
+
_type: 'meta',
|
|
251
|
+
title: state.plan.title,
|
|
252
|
+
plan_name: state.plan.planName,
|
|
253
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
254
|
+
},
|
|
255
|
+
state.plan.tasks,
|
|
256
|
+
);
|
|
203
257
|
updateUI(state, ctx);
|
|
204
258
|
state.persist(pi);
|
|
205
259
|
pi.sendUserMessage(
|
|
@@ -222,18 +276,28 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
222
276
|
}
|
|
223
277
|
|
|
224
278
|
// Check completion
|
|
225
|
-
const allResolved = state.plan.tasks.every(
|
|
279
|
+
const allResolved = state.plan.tasks.every(
|
|
280
|
+
(s) => s.status === 'done' || s.status === 'skipped',
|
|
281
|
+
);
|
|
226
282
|
if (allResolved) {
|
|
227
283
|
if (state.planDir) {
|
|
228
284
|
await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
|
|
229
|
-
await writeTasksJsonl(
|
|
285
|
+
await writeTasksJsonl(
|
|
286
|
+
state.planDir,
|
|
287
|
+
{
|
|
288
|
+
_type: 'meta',
|
|
289
|
+
title: state.plan.title,
|
|
290
|
+
plan_name: state.plan.planName,
|
|
291
|
+
created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
|
|
292
|
+
},
|
|
293
|
+
state.plan.tasks,
|
|
294
|
+
);
|
|
230
295
|
}
|
|
231
296
|
const done = state.plan.tasks.filter((s) => s.status === 'done').length;
|
|
232
297
|
const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
|
|
233
298
|
const total = state.plan.tasks.length;
|
|
234
|
-
const stats =
|
|
235
|
-
? `${done}/${total} done, ${skipped} skipped`
|
|
236
|
-
: `${done}/${total} done`;
|
|
299
|
+
const stats =
|
|
300
|
+
skipped > 0 ? `${done}/${total} done, ${skipped} skipped` : `${done}/${total} done`;
|
|
237
301
|
|
|
238
302
|
// Build a summary of what was actually done from task notes
|
|
239
303
|
const changeSummary = state.plan.tasks
|
|
@@ -289,7 +353,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
289
353
|
state.planDir = pending.planDir;
|
|
290
354
|
{
|
|
291
355
|
const snapshot = await readTasksJsonl(pending.planDir);
|
|
292
|
-
state.plan = snapshot
|
|
356
|
+
state.plan = snapshot
|
|
357
|
+
? {
|
|
358
|
+
title: snapshot.meta.title,
|
|
359
|
+
planName: snapshot.meta.plan_name,
|
|
360
|
+
handoff: (await loadHandoff(pending.planDir)) ?? '',
|
|
361
|
+
tasks: snapshot.tasks,
|
|
362
|
+
}
|
|
363
|
+
: undefined;
|
|
293
364
|
}
|
|
294
365
|
if (state.plan) {
|
|
295
366
|
state.executing = true;
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import type { PlanModeState } from './state.js';
|
|
7
7
|
import type { ThinkingLevel } from './types.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
PLAN_TOOLS,
|
|
10
|
+
EXEC_TOOLS,
|
|
11
|
+
PLAN_MODEL,
|
|
12
|
+
PLAN_THINKING,
|
|
13
|
+
EXEC_MODEL,
|
|
14
|
+
EXEC_THINKING,
|
|
15
|
+
} from './constants.js';
|
|
9
16
|
import { updateUI } from './ui.js';
|
|
10
17
|
|
|
11
18
|
export async function switchModel(
|
|
@@ -40,10 +47,7 @@ export async function enterPlanMode(
|
|
|
40
47
|
pi.setActiveTools(PLAN_TOOLS);
|
|
41
48
|
await switchModel(pi, ctx, PLAN_MODEL);
|
|
42
49
|
pi.setThinkingLevel(PLAN_THINKING);
|
|
43
|
-
ctx.ui.notify(
|
|
44
|
-
`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
|
|
45
|
-
'info',
|
|
46
|
-
);
|
|
50
|
+
ctx.ui.notify(`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`, 'info');
|
|
47
51
|
updateUI(state, ctx);
|
|
48
52
|
state.persist(pi);
|
|
49
53
|
}
|
|
@@ -11,7 +11,8 @@ You are in conversational plan mode — a planning dialogue with strict bash res
|
|
|
11
11
|
|
|
12
12
|
Restrictions:
|
|
13
13
|
- Available tools: ${PLAN_TOOLS.join(', ')}
|
|
14
|
-
- Bash is restricted to read-only commands (ls, grep, git status, etc.)
|
|
14
|
+
- Bash is restricted to read-only commands (ls, grep, git status, etc.) and info commands (--help, -h, --version, man)
|
|
15
|
+
- The write tool is restricted to .plans/ directory only — no codebase file creation or modification
|
|
15
16
|
- Do NOT make product code changes during planning.
|
|
16
17
|
|
|
17
18
|
Your job is to reach shared understanding before formalizing a plan:
|
|
@@ -49,9 +50,7 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
|
|
|
49
50
|
.join('\n\n');
|
|
50
51
|
|
|
51
52
|
const currentTask = remaining[0];
|
|
52
|
-
const currentDetails = currentTask.details
|
|
53
|
-
? `\nDetails: ${currentTask.details}`
|
|
54
|
-
: '';
|
|
53
|
+
const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
|
|
55
54
|
|
|
56
55
|
return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
|
|
57
56
|
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* Resume and execution handoff — pick up in-progress plans, model picker, new session handoff.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
10
|
import type { PlanModeState } from './state.js';
|
|
7
11
|
import type { PlanData } from './types.js';
|
|
8
12
|
import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
|
|
@@ -11,7 +15,9 @@ import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
|
|
|
11
15
|
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
12
16
|
import { enterPlanMode } from './phase-transitions.js';
|
|
13
17
|
|
|
14
|
-
export async function pickExecutionModel(
|
|
18
|
+
export async function pickExecutionModel(
|
|
19
|
+
ctx: ExtensionContext,
|
|
20
|
+
): Promise<{ provider: string; id: string } | undefined> {
|
|
15
21
|
const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
|
|
16
22
|
const choice = await ctx.ui.select('Execute with:', labels);
|
|
17
23
|
if (!choice) return undefined;
|
|
@@ -38,7 +44,11 @@ export async function executeInNewSession(
|
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
export async function resumePlan(
|
|
47
|
+
export async function resumePlan(
|
|
48
|
+
state: PlanModeState,
|
|
49
|
+
pi: ExtensionAPI,
|
|
50
|
+
ctx: ExtensionCommandContext,
|
|
51
|
+
): Promise<void> {
|
|
42
52
|
const manifest = await readPlansManifest();
|
|
43
53
|
const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
|
|
44
54
|
|
|
@@ -70,12 +80,17 @@ export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: Ex
|
|
|
70
80
|
tasks: snapshot.tasks,
|
|
71
81
|
};
|
|
72
82
|
|
|
73
|
-
const doneCount = state.plan.tasks.filter(
|
|
83
|
+
const doneCount = state.plan.tasks.filter(
|
|
84
|
+
(task) => task.status === 'done' || task.status === 'skipped',
|
|
85
|
+
).length;
|
|
74
86
|
const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
|
|
75
87
|
const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
|
|
76
88
|
|
|
77
89
|
if (pendingCount === 0 && blockedCount === 0) {
|
|
78
|
-
ctx.ui.notify(
|
|
90
|
+
ctx.ui.notify(
|
|
91
|
+
`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`,
|
|
92
|
+
'info',
|
|
93
|
+
);
|
|
79
94
|
state.plan = undefined;
|
|
80
95
|
state.planDir = undefined;
|
|
81
96
|
return;
|
|
@@ -25,9 +25,7 @@ export class PlanModeState {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
restore(entries: Array<{ type: string; customType?: string; data?: PersistedState }>): void {
|
|
28
|
-
const saved = entries
|
|
29
|
-
.filter((e) => e.type === 'custom' && e.customType === 'plan-mode')
|
|
30
|
-
.pop();
|
|
28
|
+
const saved = entries.filter((e) => e.type === 'custom' && e.customType === 'plan-mode').pop();
|
|
31
29
|
if (saved?.data) {
|
|
32
30
|
this.planEnabled = saved.data.planEnabled ?? this.planEnabled;
|
|
33
31
|
this.executing = saved.data.executing ?? this.executing;
|
|
@@ -8,7 +8,11 @@ export interface AtomicWriteOptions {
|
|
|
8
8
|
mode?: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export async function writeFileAtomic(
|
|
11
|
+
export async function writeFileAtomic(
|
|
12
|
+
path: string,
|
|
13
|
+
data: string | Buffer,
|
|
14
|
+
options: AtomicWriteOptions = {},
|
|
15
|
+
): Promise<void> {
|
|
12
16
|
const dir = dirname(path);
|
|
13
17
|
const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
|
|
14
18
|
let completed = false;
|
|
@@ -11,7 +11,9 @@ export async function writeExecPending(dir: string, config: ExecPendingConfig):
|
|
|
11
11
|
await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export async function readAndClearExecPending(): Promise<
|
|
14
|
+
export async function readAndClearExecPending(): Promise<
|
|
15
|
+
{ planDir: string; config: ExecPendingConfig } | undefined
|
|
16
|
+
> {
|
|
15
17
|
try {
|
|
16
18
|
const entries = await readdir('.plans', { withFileTypes: true });
|
|
17
19
|
for (const entry of entries) {
|
|
@@ -14,13 +14,22 @@ export interface PlanManifestEntry {
|
|
|
14
14
|
|
|
15
15
|
export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
|
|
16
16
|
let text: string;
|
|
17
|
-
try {
|
|
17
|
+
try {
|
|
18
|
+
text = await readFile(MANIFEST_PATH, 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
18
22
|
const entries: PlanManifestEntry[] = [];
|
|
19
23
|
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
20
24
|
if (!raw.trim()) continue;
|
|
21
25
|
let parsed: unknown;
|
|
22
|
-
try {
|
|
23
|
-
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`);
|
|
30
|
+
}
|
|
31
|
+
if (!isPlanManifestEntry(parsed))
|
|
32
|
+
throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
|
|
24
33
|
entries.push(parsed);
|
|
25
34
|
}
|
|
26
35
|
return entries;
|
|
@@ -28,11 +37,15 @@ export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
|
|
|
28
37
|
|
|
29
38
|
export async function writePlansManifest(entries: PlanManifestEntry[]): Promise<void> {
|
|
30
39
|
await mkdir('.plans', { recursive: true });
|
|
31
|
-
const content =
|
|
40
|
+
const content =
|
|
41
|
+
entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
|
|
32
42
|
await writeFileAtomic(MANIFEST_PATH, content);
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
export async function upsertPlanEntry(
|
|
45
|
+
export async function upsertPlanEntry(
|
|
46
|
+
name: string,
|
|
47
|
+
updates: { status: 'in-progress' | 'done'; title?: string },
|
|
48
|
+
): Promise<void> {
|
|
36
49
|
const entries = await readPlansManifest();
|
|
37
50
|
const now = new Date().toISOString();
|
|
38
51
|
const index = entries.findIndex((entry) => entry.name === name);
|
|
@@ -53,5 +66,12 @@ export async function upsertPlanEntry(name: string, updates: { status: 'in-progr
|
|
|
53
66
|
function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
|
|
54
67
|
if (typeof value !== 'object' || value === null) return false;
|
|
55
68
|
const record = value as Record<string, unknown>;
|
|
56
|
-
return
|
|
69
|
+
return (
|
|
70
|
+
record._type === 'plan' &&
|
|
71
|
+
typeof record.name === 'string' &&
|
|
72
|
+
(record.status === 'in-progress' || record.status === 'done') &&
|
|
73
|
+
typeof record.title === 'string' &&
|
|
74
|
+
typeof record.created_at === 'string' &&
|
|
75
|
+
(record.completed_at === null || typeof record.completed_at === 'string')
|
|
76
|
+
);
|
|
57
77
|
}
|
|
@@ -5,11 +5,18 @@ import { writeFileAtomic } from './atomic-write.js';
|
|
|
5
5
|
|
|
6
6
|
const TASKS_FILE = 'tasks.jsonl';
|
|
7
7
|
|
|
8
|
-
export interface TasksSnapshot {
|
|
8
|
+
export interface TasksSnapshot {
|
|
9
|
+
meta: TaskMeta;
|
|
10
|
+
tasks: TaskRecord[];
|
|
11
|
+
}
|
|
9
12
|
|
|
10
13
|
export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | undefined> {
|
|
11
14
|
let text: string;
|
|
12
|
-
try {
|
|
15
|
+
try {
|
|
16
|
+
text = await readFile(join(planDir, TASKS_FILE), 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
13
20
|
if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
|
|
14
21
|
|
|
15
22
|
let meta: TaskMeta | undefined;
|
|
@@ -17,7 +24,11 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
|
|
|
17
24
|
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
18
25
|
if (!raw.trim()) continue;
|
|
19
26
|
let parsed: unknown;
|
|
20
|
-
try {
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`);
|
|
31
|
+
}
|
|
21
32
|
if (isTaskMeta(parsed)) meta = parsed;
|
|
22
33
|
else if (isTaskRecord(parsed)) tasks.push(parsed);
|
|
23
34
|
else throw new Error(`Invalid tasks.jsonl record at line ${index + 1}`);
|
|
@@ -26,18 +37,30 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
|
|
|
26
37
|
return { meta, tasks };
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
export async function writeTasksJsonl(
|
|
40
|
+
export async function writeTasksJsonl(
|
|
41
|
+
planDir: string,
|
|
42
|
+
meta: TaskMeta,
|
|
43
|
+
tasks: TaskRecord[],
|
|
44
|
+
): Promise<void> {
|
|
30
45
|
await mkdir(planDir, { recursive: true });
|
|
31
46
|
const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
|
|
32
47
|
await writeFileAtomic(join(planDir, TASKS_FILE), content);
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
export async function updateTask(
|
|
50
|
+
export async function updateTask(
|
|
51
|
+
planDir: string,
|
|
52
|
+
taskId: string,
|
|
53
|
+
updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>,
|
|
54
|
+
): Promise<TaskRecord> {
|
|
36
55
|
const snapshot = await readTasksJsonl(planDir);
|
|
37
56
|
if (!snapshot) throw new Error(`No tasks.jsonl found in ${planDir}`);
|
|
38
57
|
const index = snapshot.tasks.findIndex((task) => task.id === taskId);
|
|
39
58
|
if (index === -1) throw new Error(`Task not found: ${taskId}`);
|
|
40
|
-
const updated: TaskRecord = {
|
|
59
|
+
const updated: TaskRecord = {
|
|
60
|
+
...snapshot.tasks[index],
|
|
61
|
+
...updates,
|
|
62
|
+
updated_at: new Date().toISOString(),
|
|
63
|
+
};
|
|
41
64
|
snapshot.tasks[index] = updated;
|
|
42
65
|
await writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
43
66
|
return updated;
|
|
@@ -21,36 +21,54 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
21
21
|
pi.registerTool({
|
|
22
22
|
name: 'submit_plan',
|
|
23
23
|
label: 'Submit Plan',
|
|
24
|
-
description:
|
|
25
|
-
|
|
24
|
+
description:
|
|
25
|
+
'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
|
|
26
|
+
promptSnippet:
|
|
27
|
+
'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
|
|
26
28
|
promptGuidelines: [
|
|
27
29
|
'Only call submit_plan after shared understanding has been reached with the user.',
|
|
28
30
|
'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
|
|
29
|
-
|
|
31
|
+
"When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
|
|
30
32
|
'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
|
|
31
33
|
'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
|
|
32
34
|
],
|
|
33
35
|
parameters: Type.Object({
|
|
34
|
-
name: Type.String({
|
|
36
|
+
name: Type.String({
|
|
37
|
+
description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
|
|
38
|
+
}),
|
|
35
39
|
title: Type.String({ description: 'Human-readable plan title' }),
|
|
36
40
|
handoff: Type.String({ description: 'Markdown content for HANDOFF.md' }),
|
|
37
41
|
tasks: Type.Array(
|
|
38
42
|
Type.Object({
|
|
39
43
|
id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
|
|
40
|
-
description: Type.String({
|
|
41
|
-
|
|
44
|
+
description: Type.String({
|
|
45
|
+
description: 'Short task label for progress display (≤60 chars)',
|
|
46
|
+
}),
|
|
47
|
+
details: Type.Optional(
|
|
48
|
+
Type.String({
|
|
49
|
+
description:
|
|
50
|
+
'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.',
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
42
53
|
depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
|
|
43
54
|
}),
|
|
44
55
|
{ minItems: 1 },
|
|
45
56
|
),
|
|
46
|
-
prototype: Type.Optional(
|
|
57
|
+
prototype: Type.Optional(
|
|
58
|
+
Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' }),
|
|
59
|
+
),
|
|
47
60
|
}),
|
|
48
61
|
|
|
49
62
|
async execute(_toolCallId, params) {
|
|
50
63
|
const planName = toKebabCase(params.name);
|
|
51
64
|
const planDir = `.plans/${planName}`;
|
|
52
65
|
const now = new Date().toISOString();
|
|
53
|
-
const meta: TaskMeta = {
|
|
66
|
+
const meta: TaskMeta = {
|
|
67
|
+
_type: 'meta',
|
|
68
|
+
title: params.title,
|
|
69
|
+
plan_name: planName,
|
|
70
|
+
created_at: now,
|
|
71
|
+
};
|
|
54
72
|
const tasks: TaskRecord[] = params.tasks.map((task) => ({
|
|
55
73
|
_type: 'task',
|
|
56
74
|
id: task.id,
|
|
@@ -72,7 +90,12 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
72
90
|
callbacks.onPlanSubmitted(planDir, plan);
|
|
73
91
|
|
|
74
92
|
return {
|
|
75
|
-
content: [
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: 'text' as const,
|
|
96
|
+
text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.`,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
76
99
|
details: { planDir, plan },
|
|
77
100
|
terminate: true,
|
|
78
101
|
};
|
|
@@ -91,7 +114,8 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
91
114
|
const plan = (result.details as { plan?: PlanData } | undefined)?.plan;
|
|
92
115
|
if (!plan) return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
|
|
93
116
|
const lines = [theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)), ''];
|
|
94
|
-
for (const task of plan.tasks)
|
|
117
|
+
for (const task of plan.tasks)
|
|
118
|
+
lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
|
|
95
119
|
return new Text(lines.join('\n'), 0, 0);
|
|
96
120
|
},
|
|
97
121
|
});
|
|
@@ -10,14 +10,19 @@ import type { PlanData, TaskStatus } from '../types.js';
|
|
|
10
10
|
|
|
11
11
|
export interface UpdateTaskCallbacks {
|
|
12
12
|
getPlan: () => PlanData | undefined;
|
|
13
|
-
onTaskUpdated: (
|
|
13
|
+
onTaskUpdated: (
|
|
14
|
+
taskId: string,
|
|
15
|
+
status: Exclude<TaskStatus, 'pending'>,
|
|
16
|
+
notes?: string,
|
|
17
|
+
) => void | Promise<void>;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
|
|
17
21
|
pi.registerTool({
|
|
18
22
|
name: 'update_task',
|
|
19
23
|
label: 'Update Task',
|
|
20
|
-
description:
|
|
24
|
+
description:
|
|
25
|
+
'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
|
|
21
26
|
promptSnippet: 'Mark a plan task as done, skipped, or blocked',
|
|
22
27
|
promptGuidelines: [
|
|
23
28
|
'Call update_task after completing each plan task before moving to the next.',
|
|
@@ -27,7 +32,9 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
27
32
|
parameters: Type.Object({
|
|
28
33
|
task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
|
|
29
34
|
status: StringEnum(['done', 'skipped', 'blocked'] as const),
|
|
30
|
-
notes: Type.Optional(
|
|
35
|
+
notes: Type.Optional(
|
|
36
|
+
Type.String({ description: 'What was done, why skipped, or why blocked' }),
|
|
37
|
+
),
|
|
31
38
|
}),
|
|
32
39
|
|
|
33
40
|
async execute(_toolCallId, params) {
|
|
@@ -37,15 +44,27 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
37
44
|
const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
|
|
38
45
|
if (!task) throw new Error(`Task not found: ${params.task_id}`);
|
|
39
46
|
if (task.status !== 'pending') {
|
|
40
|
-
throw new Error(
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`,
|
|
49
|
+
);
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
|
|
44
53
|
|
|
45
|
-
const details = {
|
|
54
|
+
const details = {
|
|
55
|
+
task_id: params.task_id,
|
|
56
|
+
status: params.status,
|
|
57
|
+
notes: params.notes,
|
|
58
|
+
description: task.description,
|
|
59
|
+
};
|
|
46
60
|
if (params.status === 'blocked') {
|
|
47
61
|
return {
|
|
48
|
-
content: [
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'text' as const,
|
|
65
|
+
text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
49
68
|
details,
|
|
50
69
|
terminate: true,
|
|
51
70
|
};
|
|
@@ -60,7 +79,12 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
60
79
|
if (params.notes) text += ` — ${params.notes}`;
|
|
61
80
|
text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
// Do not terminate the turn just because the queue is empty: that would cut off
|
|
83
|
+
// the agent's final pass (closing summary, validation, follow-up) after the last
|
|
84
|
+
// task is marked done. Completion is handled out-of-band by the `agent_end`
|
|
85
|
+
// handler in index.ts. Only the `blocked` branch above terminates, to pause for
|
|
86
|
+
// user input.
|
|
87
|
+
return { content: [{ type: 'text' as const, text }], details };
|
|
64
88
|
},
|
|
65
89
|
|
|
66
90
|
renderCall(args, theme) {
|
|
@@ -68,15 +92,31 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
68
92
|
const status = (args as { status?: string }).status ?? '';
|
|
69
93
|
let content = theme.fg('toolTitle', theme.bold('update_task '));
|
|
70
94
|
content += theme.fg('muted', taskId);
|
|
71
|
-
if (status)
|
|
95
|
+
if (status)
|
|
96
|
+
content +=
|
|
97
|
+
' ' +
|
|
98
|
+
theme.fg(
|
|
99
|
+
status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error',
|
|
100
|
+
status,
|
|
101
|
+
);
|
|
72
102
|
return new Text(content, 0, 0);
|
|
73
103
|
},
|
|
74
104
|
|
|
75
105
|
renderResult(result, _options, theme) {
|
|
76
|
-
const details = result.details as
|
|
106
|
+
const details = result.details as
|
|
107
|
+
| { task_id?: string; status?: string; description?: string }
|
|
108
|
+
| undefined;
|
|
77
109
|
if (!details) return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
78
|
-
const statusMap: Record<string, string> = {
|
|
79
|
-
|
|
110
|
+
const statusMap: Record<string, string> = {
|
|
111
|
+
done: theme.fg('success', '✓'),
|
|
112
|
+
skipped: theme.fg('warning', '⊘'),
|
|
113
|
+
blocked: theme.fg('error', '✗'),
|
|
114
|
+
};
|
|
115
|
+
return new Text(
|
|
116
|
+
`${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`,
|
|
117
|
+
0,
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
80
120
|
},
|
|
81
121
|
});
|
|
82
122
|
}
|
|
@@ -23,6 +23,16 @@ function isMkdirPlans(command: string): boolean {
|
|
|
23
23
|
return /^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Check if a file path is inside the .plans/ directory.
|
|
28
|
+
*
|
|
29
|
+
* Accepts both relative (.plans/foo) and absolute paths containing .plans/.
|
|
30
|
+
*/
|
|
31
|
+
export function isPlanPath(filePath: string): boolean {
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
33
|
+
return /(?:^|\/)?\.plans\//.test(normalized);
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
// ── Plan name utilities ─────────────────────────────────────────────────────
|
|
27
37
|
|
|
28
38
|
export function toKebabCase(name: string): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
]
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@dreki-gg/pi-command-sandbox": "^0.
|
|
43
|
+
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
44
|
"pug": "^3.0.3"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|