@dreki-gg/pi-plan-mode 0.15.0 → 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 +6 -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 +3 -1
- package/extensions/plan-mode/context-filter.ts +1 -4
- package/extensions/plan-mode/html/render.ts +33 -16
- package/extensions/plan-mode/index.ts +73 -15
- package/extensions/plan-mode/phase-transitions.ts +9 -5
- package/extensions/plan-mode/plan-storage.ts +6 -1
- package/extensions/plan-mode/prompts.ts +1 -3
- 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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.15.0
|
|
4
10
|
|
|
5
11
|
### Minor 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
|
});
|
|
@@ -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
|
|
|
@@ -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';
|
|
@@ -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
|
},
|
|
@@ -168,7 +182,11 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
168
182
|
pi.on('before_agent_start', async () => {
|
|
169
183
|
if (state.planEnabled) {
|
|
170
184
|
return {
|
|
171
|
-
message: {
|
|
185
|
+
message: {
|
|
186
|
+
customType: 'plan-mode-context',
|
|
187
|
+
content: buildPlanModePrompt(),
|
|
188
|
+
display: false,
|
|
189
|
+
},
|
|
172
190
|
};
|
|
173
191
|
}
|
|
174
192
|
if (state.executing && state.plan) {
|
|
@@ -194,17 +212,31 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
194
212
|
: `Task ${bs.id}: ${bs.description}`;
|
|
195
213
|
|
|
196
214
|
const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
|
|
197
|
-
'Skip this task',
|
|
215
|
+
'Skip this task',
|
|
216
|
+
'Provide instructions',
|
|
217
|
+
'Re-plan',
|
|
218
|
+
'Abort execution',
|
|
198
219
|
]);
|
|
199
220
|
|
|
200
221
|
if (choice === 'Skip this task') {
|
|
201
222
|
bs.status = 'skipped';
|
|
202
223
|
bs.updated_at = new Date().toISOString();
|
|
203
|
-
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
|
+
);
|
|
204
234
|
updateUI(state, ctx);
|
|
205
235
|
state.persist(pi);
|
|
206
236
|
if (state.plan.tasks.some((s) => s.status === 'pending')) {
|
|
207
|
-
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
|
+
});
|
|
208
240
|
}
|
|
209
241
|
} else if (choice === 'Provide instructions') {
|
|
210
242
|
const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
|
|
@@ -212,7 +244,16 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
212
244
|
bs.status = 'pending';
|
|
213
245
|
bs.notes = undefined;
|
|
214
246
|
bs.updated_at = new Date().toISOString();
|
|
215
|
-
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
|
+
);
|
|
216
257
|
updateUI(state, ctx);
|
|
217
258
|
state.persist(pi);
|
|
218
259
|
pi.sendUserMessage(
|
|
@@ -235,18 +276,28 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
235
276
|
}
|
|
236
277
|
|
|
237
278
|
// Check completion
|
|
238
|
-
const allResolved = state.plan.tasks.every(
|
|
279
|
+
const allResolved = state.plan.tasks.every(
|
|
280
|
+
(s) => s.status === 'done' || s.status === 'skipped',
|
|
281
|
+
);
|
|
239
282
|
if (allResolved) {
|
|
240
283
|
if (state.planDir) {
|
|
241
284
|
await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
|
|
242
|
-
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
|
+
);
|
|
243
295
|
}
|
|
244
296
|
const done = state.plan.tasks.filter((s) => s.status === 'done').length;
|
|
245
297
|
const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
|
|
246
298
|
const total = state.plan.tasks.length;
|
|
247
|
-
const stats =
|
|
248
|
-
? `${done}/${total} done, ${skipped} skipped`
|
|
249
|
-
: `${done}/${total} done`;
|
|
299
|
+
const stats =
|
|
300
|
+
skipped > 0 ? `${done}/${total} done, ${skipped} skipped` : `${done}/${total} done`;
|
|
250
301
|
|
|
251
302
|
// Build a summary of what was actually done from task notes
|
|
252
303
|
const changeSummary = state.plan.tasks
|
|
@@ -302,7 +353,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
302
353
|
state.planDir = pending.planDir;
|
|
303
354
|
{
|
|
304
355
|
const snapshot = await readTasksJsonl(pending.planDir);
|
|
305
|
-
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;
|
|
306
364
|
}
|
|
307
365
|
if (state.plan) {
|
|
308
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
|
}
|
|
@@ -50,9 +50,7 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
|
|
|
50
50
|
.join('\n\n');
|
|
51
51
|
|
|
52
52
|
const currentTask = remaining[0];
|
|
53
|
-
const currentDetails = currentTask.details
|
|
54
|
-
? `\nDetails: ${currentTask.details}`
|
|
55
|
-
: '';
|
|
53
|
+
const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
|
|
56
54
|
|
|
57
55
|
return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
|
|
58
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
|
}
|
package/package.json
CHANGED