@dreki-gg/pi-plan-mode 0.16.0 → 0.17.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 +14 -0
- package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
- package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
- package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
- package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
- package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
- package/extensions/plan-mode/constants.ts +1 -1
- package/extensions/plan-mode/effects/filesystem.ts +65 -0
- package/extensions/plan-mode/effects/runtime.ts +24 -0
- package/extensions/plan-mode/errors.ts +105 -0
- package/extensions/plan-mode/index.ts +147 -47
- package/extensions/plan-mode/prompts.ts +1 -0
- package/extensions/plan-mode/resume.ts +19 -15
- package/extensions/plan-mode/schema.ts +57 -0
- package/extensions/plan-mode/storage/atomic-write.ts +19 -1
- package/extensions/plan-mode/storage/plan-storage.ts +57 -29
- package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
- package/extensions/plan-mode/storage/task-storage.ts +83 -45
- package/extensions/plan-mode/task-status.ts +46 -0
- package/extensions/plan-mode/tools/add-task.ts +95 -0
- package/extensions/plan-mode/tools/preview-prototype.ts +11 -4
- package/extensions/plan-mode/tools/submit-plan.ts +16 -11
- package/extensions/plan-mode/types.ts +8 -38
- package/extensions/plan-mode/utils.ts +20 -0
- package/package.json +2 -1
- package/extensions/plan-mode/__tests__/types.test.ts +0 -70
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSystem service — the single seam for plan-mode disk I/O.
|
|
3
|
+
*
|
|
4
|
+
* Storage programs depend on this `Context.Tag` rather than touching
|
|
5
|
+
* `node:fs/promises` directly, which makes them trivially testable and keeps
|
|
6
|
+
* all failure modes typed (`PlanReadError` / `PlanWriteError`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Context, Effect } from 'effect';
|
|
10
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { PlanReadError, PlanWriteError } from '../errors.js';
|
|
12
|
+
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
13
|
+
|
|
14
|
+
export interface FileSystemService {
|
|
15
|
+
readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
|
|
16
|
+
readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
17
|
+
readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
18
|
+
readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
19
|
+
readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
|
|
20
|
+
readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
|
|
24
|
+
FileSystem,
|
|
25
|
+
FileSystemService
|
|
26
|
+
>() {}
|
|
27
|
+
|
|
28
|
+
export const nodeFileSystemService: FileSystemService = {
|
|
29
|
+
readFileString: (path) =>
|
|
30
|
+
Effect.tryPromise({
|
|
31
|
+
try: () => readFile(path, 'utf-8'),
|
|
32
|
+
catch: (cause) => new PlanReadError({ path, cause }),
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
writeFileString: (path, data) =>
|
|
36
|
+
Effect.tryPromise({
|
|
37
|
+
try: () => writeFile(path, data, 'utf-8'),
|
|
38
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
writeFileAtomic: (path, data) => writeFileAtomic(path, data),
|
|
42
|
+
|
|
43
|
+
makeDir: (path) =>
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: async () => {
|
|
46
|
+
await mkdir(path, { recursive: true });
|
|
47
|
+
},
|
|
48
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
listDirectories: (path) =>
|
|
52
|
+
Effect.tryPromise({
|
|
53
|
+
try: async () => {
|
|
54
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
55
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
56
|
+
},
|
|
57
|
+
catch: (cause) => new PlanReadError({ path, cause }),
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
removeFile: (path) =>
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: () => unlink(path),
|
|
63
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Effect runtime for the plan-mode extension.
|
|
3
|
+
*
|
|
4
|
+
* Build the layer once inside the extension entry and run storage programs
|
|
5
|
+
* through the `runPlanIO` bridge so the imperative pi event handlers keep their
|
|
6
|
+
* `await fn(...)` shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect, Layer } from 'effect';
|
|
10
|
+
import { FileSystem, nodeFileSystemService } from './filesystem.js';
|
|
11
|
+
|
|
12
|
+
export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
|
|
13
|
+
return Layer.succeed(FileSystem, nodeFileSystemService);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build a bridge that runs storage programs against the live filesystem layer. */
|
|
17
|
+
export function makePlanRuntime() {
|
|
18
|
+
const layer = makeRuntimeLayer();
|
|
19
|
+
return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
|
|
20
|
+
return Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged errors for plan-mode disk I/O and JSONL validation.
|
|
3
|
+
*
|
|
4
|
+
* These replace ad-hoc `throw new Error(...)` so storage programs surface
|
|
5
|
+
* typed, inspectable failures. They are mapped back to user-facing strings at
|
|
6
|
+
* the tool boundary via `errorMessage`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Data } from 'effect';
|
|
10
|
+
|
|
11
|
+
export class PlanReadError extends Data.TaggedError('PlanReadError')<{
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly cause: unknown;
|
|
14
|
+
}> {
|
|
15
|
+
get message(): string {
|
|
16
|
+
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly cause: unknown;
|
|
23
|
+
}> {
|
|
24
|
+
get message(): string {
|
|
25
|
+
return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly line: number;
|
|
32
|
+
readonly cause?: unknown;
|
|
33
|
+
}> {
|
|
34
|
+
get message(): string {
|
|
35
|
+
return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly line: number;
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
}> {
|
|
44
|
+
get message(): string {
|
|
45
|
+
return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
|
|
50
|
+
readonly path: string;
|
|
51
|
+
}> {
|
|
52
|
+
get message(): string {
|
|
53
|
+
return `${this.path} is missing meta record`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
|
|
58
|
+
readonly planDir: string;
|
|
59
|
+
readonly taskId: string;
|
|
60
|
+
}> {
|
|
61
|
+
get message(): string {
|
|
62
|
+
return `Task not found: ${this.taskId}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
|
|
67
|
+
readonly planDir: string;
|
|
68
|
+
}> {
|
|
69
|
+
get message(): string {
|
|
70
|
+
return `No tasks.jsonl found in ${this.planDir}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PlanStorageError =
|
|
75
|
+
| PlanReadError
|
|
76
|
+
| PlanWriteError
|
|
77
|
+
| JsonlParseError
|
|
78
|
+
| JsonlValidationError
|
|
79
|
+
| MissingMetaRecord
|
|
80
|
+
| TaskNotFound
|
|
81
|
+
| TasksFileNotFound;
|
|
82
|
+
|
|
83
|
+
export function causeMessage(cause: unknown): string {
|
|
84
|
+
if (cause instanceof Error) return cause.message;
|
|
85
|
+
return String(cause);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function errorMessage(error: unknown): string {
|
|
89
|
+
if (error instanceof Error) return error.message;
|
|
90
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
91
|
+
const message = (error as { message?: unknown }).message;
|
|
92
|
+
if (typeof message === 'string') return message;
|
|
93
|
+
}
|
|
94
|
+
return String(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convert any error (including tagged errors) into a native Error for the tool boundary. */
|
|
98
|
+
export function toNativeError(error: unknown): Error {
|
|
99
|
+
if (error instanceof Error) return error;
|
|
100
|
+
const native = new Error(errorMessage(error));
|
|
101
|
+
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
102
|
+
native.name = String((error as { _tag: unknown })._tag);
|
|
103
|
+
}
|
|
104
|
+
return native;
|
|
105
|
+
}
|
|
@@ -28,21 +28,26 @@ import {
|
|
|
28
28
|
} from './constants.js';
|
|
29
29
|
import type { ThinkingLevel } from './types.js';
|
|
30
30
|
import { PlanModeState } from './state.js';
|
|
31
|
+
import { makePlanRuntime } from './effects/runtime.js';
|
|
31
32
|
import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
|
|
32
33
|
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
33
34
|
import { upsertPlanEntry } from './storage/plans-manifest.js';
|
|
34
35
|
import { updateUI } from './ui.js';
|
|
35
36
|
import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
|
|
36
37
|
import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
|
|
38
|
+
import { activeTasksResolved, deferredTasks } from './task-status.js';
|
|
37
39
|
import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
|
|
38
40
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
39
41
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
40
42
|
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
41
43
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
44
|
+
import { registerAddTaskTool } from './tools/add-task.js';
|
|
42
45
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
43
46
|
|
|
44
47
|
export default function planMode(pi: ExtensionAPI): void {
|
|
45
48
|
const state = new PlanModeState();
|
|
49
|
+
// Build the live Effect runtime once; all storage I/O runs through this bridge.
|
|
50
|
+
const runPlanIO = makePlanRuntime();
|
|
46
51
|
|
|
47
52
|
// ── Flag ──────────────────────────────────────────────────────────────────
|
|
48
53
|
pi.registerFlag('plan', {
|
|
@@ -52,7 +57,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
// ── Tools ─────────────────────────────────────────────────────────────────
|
|
55
|
-
registerSubmitPlanTool(pi, {
|
|
60
|
+
registerSubmitPlanTool(pi, runPlanIO, {
|
|
56
61
|
onPlanSubmitted: (dir, submittedPlan) => {
|
|
57
62
|
state.planDir = dir;
|
|
58
63
|
state.plan = submittedPlan;
|
|
@@ -60,7 +65,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
60
65
|
},
|
|
61
66
|
});
|
|
62
67
|
|
|
63
|
-
registerPreviewPrototypeTool(pi);
|
|
68
|
+
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
64
69
|
|
|
65
70
|
registerUpdateTaskTool(pi, {
|
|
66
71
|
getPlan: () => state.plan,
|
|
@@ -71,15 +76,38 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
71
76
|
task.status = status;
|
|
72
77
|
task.updated_at = new Date().toISOString();
|
|
73
78
|
if (notes) task.notes = notes;
|
|
74
|
-
await
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
await runPlanIO(
|
|
80
|
+
writeTasksJsonl(
|
|
81
|
+
state.planDir,
|
|
82
|
+
{
|
|
83
|
+
_type: 'meta',
|
|
84
|
+
title: state.plan.title,
|
|
85
|
+
plan_name: state.plan.planName,
|
|
86
|
+
created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
|
|
87
|
+
},
|
|
88
|
+
state.plan.tasks,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
state.persist(pi);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
registerAddTaskTool(pi, {
|
|
96
|
+
getPlan: () => state.plan,
|
|
97
|
+
onTaskAdded: async (task) => {
|
|
98
|
+
if (!state.plan || !state.planDir) return;
|
|
99
|
+
state.plan.tasks.push(task);
|
|
100
|
+
await runPlanIO(
|
|
101
|
+
writeTasksJsonl(
|
|
102
|
+
state.planDir,
|
|
103
|
+
{
|
|
104
|
+
_type: 'meta',
|
|
105
|
+
title: state.plan.title,
|
|
106
|
+
plan_name: state.plan.planName,
|
|
107
|
+
created_at: state.plan.tasks[0]?.created_at ?? task.created_at,
|
|
108
|
+
},
|
|
109
|
+
state.plan.tasks,
|
|
110
|
+
),
|
|
83
111
|
);
|
|
84
112
|
state.persist(pi);
|
|
85
113
|
},
|
|
@@ -92,7 +120,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
92
120
|
handler: async (args, ctx) => {
|
|
93
121
|
const trimmed = args?.trim();
|
|
94
122
|
if (trimmed === 'resume') {
|
|
95
|
-
await resumePlan(state, pi, ctx);
|
|
123
|
+
await resumePlan(state, pi, ctx, runPlanIO);
|
|
96
124
|
return;
|
|
97
125
|
}
|
|
98
126
|
if (state.planEnabled || state.executing) {
|
|
@@ -115,7 +143,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
115
143
|
const first =
|
|
116
144
|
state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
|
|
117
145
|
const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
|
|
118
|
-
await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
|
|
146
|
+
await executeInNewSession(ctx, runPlanIO, state.planDir, state.plan, kickoff);
|
|
119
147
|
},
|
|
120
148
|
});
|
|
121
149
|
|
|
@@ -126,9 +154,18 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
126
154
|
ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
|
|
127
155
|
return;
|
|
128
156
|
}
|
|
129
|
-
const statusIcon = {
|
|
157
|
+
const statusIcon = {
|
|
158
|
+
pending: '○',
|
|
159
|
+
done: '✓',
|
|
160
|
+
skipped: '⊘',
|
|
161
|
+
blocked: '✗',
|
|
162
|
+
deferred: '⏸',
|
|
163
|
+
} as const;
|
|
130
164
|
const list = state.plan.tasks
|
|
131
|
-
.map((s) =>
|
|
165
|
+
.map((s) => {
|
|
166
|
+
const marker = s.origin === 'discovered' ? ' (discovered)' : '';
|
|
167
|
+
return `${s.id}. ${statusIcon[s.status]} ${s.description}${marker}`;
|
|
168
|
+
})
|
|
132
169
|
.join('\n');
|
|
133
170
|
ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
|
|
134
171
|
},
|
|
@@ -210,10 +247,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
210
247
|
|
|
211
248
|
if (blocked.length > 0) {
|
|
212
249
|
const bs = blocked[0];
|
|
213
|
-
|
|
250
|
+
let info = bs.notes
|
|
214
251
|
? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
|
|
215
252
|
: `Task ${bs.id}: ${bs.description}`;
|
|
216
253
|
|
|
254
|
+
const pausedFollowups = deferredTasks(state.plan.tasks);
|
|
255
|
+
if (pausedFollowups.length > 0) {
|
|
256
|
+
info += `\n\nNote: ${pausedFollowups.length} follow-up(s) captured for later review (/plan resume).`;
|
|
257
|
+
}
|
|
258
|
+
|
|
217
259
|
const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
|
|
218
260
|
'Skip this task',
|
|
219
261
|
'Provide instructions',
|
|
@@ -224,15 +266,17 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
224
266
|
if (choice === 'Skip this task') {
|
|
225
267
|
bs.status = 'skipped';
|
|
226
268
|
bs.updated_at = new Date().toISOString();
|
|
227
|
-
await
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
269
|
+
await runPlanIO(
|
|
270
|
+
writeTasksJsonl(
|
|
271
|
+
state.planDir!,
|
|
272
|
+
{
|
|
273
|
+
_type: 'meta',
|
|
274
|
+
title: state.plan.title,
|
|
275
|
+
plan_name: state.plan.planName,
|
|
276
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
277
|
+
},
|
|
278
|
+
state.plan.tasks,
|
|
279
|
+
),
|
|
236
280
|
);
|
|
237
281
|
updateUI(state, ctx);
|
|
238
282
|
state.persist(pi);
|
|
@@ -247,15 +291,17 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
247
291
|
bs.status = 'pending';
|
|
248
292
|
bs.notes = undefined;
|
|
249
293
|
bs.updated_at = new Date().toISOString();
|
|
250
|
-
await
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
294
|
+
await runPlanIO(
|
|
295
|
+
writeTasksJsonl(
|
|
296
|
+
state.planDir!,
|
|
297
|
+
{
|
|
298
|
+
_type: 'meta',
|
|
299
|
+
title: state.plan.title,
|
|
300
|
+
plan_name: state.plan.planName,
|
|
301
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
302
|
+
},
|
|
303
|
+
state.plan.tasks,
|
|
304
|
+
),
|
|
259
305
|
);
|
|
260
306
|
updateUI(state, ctx);
|
|
261
307
|
state.persist(pi);
|
|
@@ -278,22 +324,76 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
278
324
|
}
|
|
279
325
|
}
|
|
280
326
|
|
|
327
|
+
// ── Discovered follow-ups checkpoint ──
|
|
328
|
+
// Active work is done but the agent captured deferred follow-ups: keep the
|
|
329
|
+
// plan in-progress and inform the user, who decides via /plan resume.
|
|
330
|
+
const deferred = deferredTasks(state.plan.tasks);
|
|
331
|
+
if (activeTasksResolved(state.plan.tasks) && deferred.length > 0) {
|
|
332
|
+
if (state.planDir) {
|
|
333
|
+
await runPlanIO(
|
|
334
|
+
writeTasksJsonl(
|
|
335
|
+
state.planDir,
|
|
336
|
+
{
|
|
337
|
+
_type: 'meta',
|
|
338
|
+
title: state.plan.title,
|
|
339
|
+
plan_name: state.plan.planName,
|
|
340
|
+
created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
|
|
341
|
+
},
|
|
342
|
+
state.plan.tasks,
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const followups = deferred
|
|
348
|
+
.map((s) => {
|
|
349
|
+
const label = `${s.id}. ⏸ ${s.description}`;
|
|
350
|
+
return s.notes ? `${label}\n ${s.notes}` : label;
|
|
351
|
+
})
|
|
352
|
+
.join('\n');
|
|
353
|
+
const followSummary = [
|
|
354
|
+
`**Plan tasks complete — ${deferred.length} follow-up(s) discovered (kept for later)**`,
|
|
355
|
+
'',
|
|
356
|
+
'Run `/plan resume` to review and decide whether to implement them.',
|
|
357
|
+
'',
|
|
358
|
+
'## Discovered follow-ups',
|
|
359
|
+
'',
|
|
360
|
+
followups,
|
|
361
|
+
].join('\n');
|
|
362
|
+
pi.sendMessage(
|
|
363
|
+
{ customType: 'plan-followups', content: followSummary, display: true },
|
|
364
|
+
{ triggerTurn: false },
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const { previousModel: dpm, previousThinking: dpt } = state;
|
|
368
|
+
state.exitPreservingPlan();
|
|
369
|
+
pi.setActiveTools(EXEC_TOOLS);
|
|
370
|
+
if (dpm) await switchModel(pi, ctx, dpm);
|
|
371
|
+
if (dpt) pi.setThinkingLevel(dpt);
|
|
372
|
+
updateUI(state, ctx);
|
|
373
|
+
state.persist(pi);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
281
377
|
// Check completion
|
|
282
378
|
const allResolved = state.plan.tasks.every(
|
|
283
379
|
(s) => s.status === 'done' || s.status === 'skipped',
|
|
284
380
|
);
|
|
285
381
|
if (allResolved) {
|
|
286
382
|
if (state.planDir) {
|
|
287
|
-
await
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
383
|
+
await runPlanIO(
|
|
384
|
+
upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title }),
|
|
385
|
+
);
|
|
386
|
+
await runPlanIO(
|
|
387
|
+
writeTasksJsonl(
|
|
388
|
+
state.planDir,
|
|
389
|
+
{
|
|
390
|
+
_type: 'meta',
|
|
391
|
+
title: state.plan.title,
|
|
392
|
+
plan_name: state.plan.planName,
|
|
393
|
+
created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
|
|
394
|
+
},
|
|
395
|
+
state.plan.tasks,
|
|
396
|
+
),
|
|
297
397
|
);
|
|
298
398
|
}
|
|
299
399
|
const done = state.plan.tasks.filter((s) => s.status === 'done').length;
|
|
@@ -351,16 +451,16 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
351
451
|
);
|
|
352
452
|
|
|
353
453
|
// Check for exec-pending handoff from planning session
|
|
354
|
-
const pending = await readAndClearExecPending();
|
|
454
|
+
const pending = await runPlanIO(readAndClearExecPending());
|
|
355
455
|
if (pending) {
|
|
356
456
|
state.planDir = pending.planDir;
|
|
357
457
|
{
|
|
358
|
-
const snapshot = await readTasksJsonl(pending.planDir);
|
|
458
|
+
const snapshot = await runPlanIO(readTasksJsonl(pending.planDir));
|
|
359
459
|
state.plan = snapshot
|
|
360
460
|
? {
|
|
361
461
|
title: snapshot.meta.title,
|
|
362
462
|
planName: snapshot.meta.plan_name,
|
|
363
|
-
handoff: (await loadHandoff(pending.planDir)) ?? '',
|
|
463
|
+
handoff: (await runPlanIO(loadHandoff(pending.planDir))) ?? '',
|
|
364
464
|
tasks: snapshot.tasks,
|
|
365
465
|
}
|
|
366
466
|
: undefined;
|
|
@@ -63,6 +63,7 @@ Rules:
|
|
|
63
63
|
- Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
|
|
64
64
|
- Do NOT explore the codebase beyond what the current task requires
|
|
65
65
|
- Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
|
|
66
|
+
- If you notice worthwhile work OUTSIDE the current plan, call add_task to capture it as a deferred follow-up, then keep going. Do NOT implement discovered work in this run — the user reviews follow-ups later via /plan resume.
|
|
66
67
|
|
|
67
68
|
## Current task
|
|
68
69
|
${currentTask.id}: ${currentTask.description}${currentDetails}
|
|
@@ -9,11 +9,13 @@ import type {
|
|
|
9
9
|
} from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import type { PlanModeState } from './state.js';
|
|
11
11
|
import type { PlanData } from './types.js';
|
|
12
|
+
import type { RunPlanIO } from './effects/runtime.js';
|
|
12
13
|
import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
|
|
13
14
|
import { readPlansManifest } from './storage/plans-manifest.js';
|
|
14
15
|
import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
|
|
15
16
|
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
16
17
|
import { enterPlanMode } from './phase-transitions.js';
|
|
18
|
+
import { reactivateForExecution } from './task-status.js';
|
|
17
19
|
|
|
18
20
|
export async function pickExecutionModel(
|
|
19
21
|
ctx: ExtensionContext,
|
|
@@ -26,6 +28,7 @@ export async function pickExecutionModel(
|
|
|
26
28
|
|
|
27
29
|
export async function executeInNewSession(
|
|
28
30
|
ctx: ExtensionCommandContext,
|
|
31
|
+
runPlanIO: RunPlanIO,
|
|
29
32
|
dir: string,
|
|
30
33
|
_planData: PlanData,
|
|
31
34
|
kickoff: string,
|
|
@@ -33,7 +36,7 @@ export async function executeInNewSession(
|
|
|
33
36
|
const selectedModel = await pickExecutionModel(ctx);
|
|
34
37
|
if (!selectedModel) return;
|
|
35
38
|
|
|
36
|
-
await writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING });
|
|
39
|
+
await runPlanIO(writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING }));
|
|
37
40
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
38
41
|
|
|
39
42
|
await ctx.newSession({
|
|
@@ -48,8 +51,9 @@ export async function resumePlan(
|
|
|
48
51
|
state: PlanModeState,
|
|
49
52
|
pi: ExtensionAPI,
|
|
50
53
|
ctx: ExtensionCommandContext,
|
|
54
|
+
runPlanIO: RunPlanIO,
|
|
51
55
|
): Promise<void> {
|
|
52
|
-
const manifest = await readPlansManifest();
|
|
56
|
+
const manifest = await runPlanIO(readPlansManifest());
|
|
53
57
|
const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
|
|
54
58
|
|
|
55
59
|
if (inProgress.length === 0) {
|
|
@@ -65,7 +69,7 @@ export async function resumePlan(
|
|
|
65
69
|
|
|
66
70
|
const planName = choice.split(' — ')[0];
|
|
67
71
|
const dir = `.plans/${planName}`;
|
|
68
|
-
const snapshot = await readTasksJsonl(dir);
|
|
72
|
+
const snapshot = await runPlanIO(readTasksJsonl(dir));
|
|
69
73
|
|
|
70
74
|
if (!snapshot) {
|
|
71
75
|
ctx.ui.notify(`Could not load ${dir}/tasks.jsonl`, 'error');
|
|
@@ -76,7 +80,7 @@ export async function resumePlan(
|
|
|
76
80
|
state.plan = {
|
|
77
81
|
title: snapshot.meta.title,
|
|
78
82
|
planName: snapshot.meta.plan_name,
|
|
79
|
-
handoff: (await loadHandoff(dir)) ?? '',
|
|
83
|
+
handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
|
|
80
84
|
tasks: snapshot.tasks,
|
|
81
85
|
};
|
|
82
86
|
|
|
@@ -85,8 +89,9 @@ export async function resumePlan(
|
|
|
85
89
|
).length;
|
|
86
90
|
const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
|
|
87
91
|
const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
|
|
92
|
+
const deferredCount = state.plan.tasks.filter((task) => task.status === 'deferred').length;
|
|
88
93
|
|
|
89
|
-
if (pendingCount === 0 && blockedCount === 0) {
|
|
94
|
+
if (pendingCount === 0 && blockedCount === 0 && deferredCount === 0) {
|
|
90
95
|
ctx.ui.notify(
|
|
91
96
|
`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`,
|
|
92
97
|
'info',
|
|
@@ -96,7 +101,10 @@ export async function resumePlan(
|
|
|
96
101
|
return;
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
const summary =
|
|
104
|
+
const summary =
|
|
105
|
+
`${doneCount}/${state.plan.tasks.length} done, ${pendingCount} pending` +
|
|
106
|
+
(blockedCount ? `, ${blockedCount} blocked` : '') +
|
|
107
|
+
(deferredCount ? `, ${deferredCount} follow-up` : '');
|
|
100
108
|
const action = await ctx.ui.select(`Resume "${state.plan.title}" (${summary}) — what next?`, [
|
|
101
109
|
'Continue execution',
|
|
102
110
|
'Re-plan from scratch',
|
|
@@ -119,19 +127,15 @@ export async function resumePlan(
|
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
task.updated_at = new Date().toISOString();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
await writeTasksJsonl(dir, snapshot.meta, state.plan.tasks);
|
|
130
|
+
// Reactivate blocked tasks and discovered follow-ups so "Continue execution"
|
|
131
|
+
// picks them up — this is the moment the user decides to go ahead.
|
|
132
|
+
if (reactivateForExecution(state.plan.tasks, new Date().toISOString())) {
|
|
133
|
+
await runPlanIO(writeTasksJsonl(dir, snapshot.meta, state.plan.tasks));
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
const remaining = state.plan.tasks.filter((task) => task.status === 'pending');
|
|
133
137
|
const taskList = remaining.map((task) => `${task.id}. ${task.description}`).join('\n');
|
|
134
138
|
const kickoff = `Resuming plan: "${state.plan.title}"\n\nCompleted: ${doneCount}/${state.plan.tasks.length} tasks\n\nRemaining tasks:\n${taskList}\n\nContinue from ${remaining[0]?.id}. Call update_task after completing each task.`;
|
|
135
139
|
|
|
136
|
-
await executeInNewSession(ctx, dir, state.plan, kickoff);
|
|
140
|
+
await executeInNewSession(ctx, runPlanIO, dir, state.plan, kickoff);
|
|
137
141
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Schema definitions for plan-mode persisted records.
|
|
3
|
+
*
|
|
4
|
+
* These replace the hand-rolled type guards. Schemas are the single source of
|
|
5
|
+
* truth for record shape; the mutable TS interfaces in `types.ts` are kept for
|
|
6
|
+
* the imperative orchestration code (which mutates tasks in place) and are
|
|
7
|
+
* structurally compatible with the decoded values.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Schema } from 'effect';
|
|
11
|
+
|
|
12
|
+
export const TaskStatusSchema = Schema.Literal('pending', 'done', 'skipped', 'blocked', 'deferred');
|
|
13
|
+
|
|
14
|
+
export const TaskOriginSchema = Schema.Literal('plan', 'discovered');
|
|
15
|
+
|
|
16
|
+
export const TaskRecordSchema = Schema.Struct({
|
|
17
|
+
_type: Schema.Literal('task'),
|
|
18
|
+
id: Schema.String,
|
|
19
|
+
description: Schema.String,
|
|
20
|
+
details: Schema.optional(Schema.String),
|
|
21
|
+
status: TaskStatusSchema,
|
|
22
|
+
origin: Schema.optional(TaskOriginSchema),
|
|
23
|
+
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
24
|
+
notes: Schema.optional(Schema.String),
|
|
25
|
+
created_at: Schema.String,
|
|
26
|
+
updated_at: Schema.String,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const TaskMetaSchema = Schema.Struct({
|
|
30
|
+
_type: Schema.Literal('meta'),
|
|
31
|
+
title: Schema.String,
|
|
32
|
+
plan_name: Schema.String,
|
|
33
|
+
created_at: Schema.String,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A single tasks.jsonl line is either the meta record or a task record. */
|
|
37
|
+
export const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
|
|
38
|
+
|
|
39
|
+
export const PlanManifestEntrySchema = Schema.Struct({
|
|
40
|
+
_type: Schema.Literal('plan'),
|
|
41
|
+
name: Schema.String,
|
|
42
|
+
status: Schema.Literal('in-progress', 'done'),
|
|
43
|
+
title: Schema.String,
|
|
44
|
+
created_at: Schema.String,
|
|
45
|
+
completed_at: Schema.NullOr(Schema.String),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const ExecPendingConfigSchema = Schema.Struct({
|
|
49
|
+
model: Schema.Struct({ provider: Schema.String, id: Schema.String }),
|
|
50
|
+
thinking: Schema.String,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const decodeTaskRecord = Schema.decodeUnknownEither(TaskRecordSchema);
|
|
54
|
+
export const decodeTaskMeta = Schema.decodeUnknownEither(TaskMetaSchema);
|
|
55
|
+
export const decodeTasksLine = Schema.decodeUnknownEither(TasksLineSchema);
|
|
56
|
+
export const decodePlanManifestEntry = Schema.decodeUnknownEither(PlanManifestEntrySchema);
|
|
57
|
+
export const decodeExecPendingConfig = Schema.decodeUnknownEither(ExecPendingConfigSchema);
|