@dreki-gg/pi-plan-mode 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -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 -10
- 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
|
@@ -1,17 +1,35 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
1
2
|
import { createWriteStream } from 'node:fs';
|
|
2
3
|
import { open, rename, rm } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { PlanWriteError } from '../errors.js';
|
|
5
7
|
|
|
6
8
|
export interface AtomicWriteOptions {
|
|
7
9
|
/** Test seam: file mode for the temporary file. */
|
|
8
10
|
mode?: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Atomically write `data` to `path`: write to a temp file, fsync, rename into
|
|
15
|
+
* place, then best-effort fsync the directory. Failures surface as
|
|
16
|
+
* `PlanWriteError`.
|
|
17
|
+
*/
|
|
18
|
+
export function writeFileAtomic(
|
|
12
19
|
path: string,
|
|
13
20
|
data: string | Buffer,
|
|
14
21
|
options: AtomicWriteOptions = {},
|
|
22
|
+
): Effect.Effect<void, PlanWriteError> {
|
|
23
|
+
return Effect.tryPromise({
|
|
24
|
+
try: () => writeFileAtomicPromise(path, data, options),
|
|
25
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function writeFileAtomicPromise(
|
|
30
|
+
path: string,
|
|
31
|
+
data: string | Buffer,
|
|
32
|
+
options: AtomicWriteOptions,
|
|
15
33
|
): Promise<void> {
|
|
16
34
|
const dir = dirname(path);
|
|
17
35
|
const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
|
|
@@ -2,48 +2,76 @@
|
|
|
2
2
|
* Plan disk I/O — exec-pending markers and handoff documents.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Effect, Either, Option } from 'effect';
|
|
6
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
7
|
+
import type { PlanWriteError } from '../errors.js';
|
|
8
|
+
import { decodeExecPendingConfig } from '../schema.js';
|
|
6
9
|
import type { ExecPendingConfig } from '../types.js';
|
|
7
10
|
import { EXEC_PENDING_FILE } from '../constants.js';
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const PLANS_DIR = '.plans';
|
|
13
|
+
|
|
14
|
+
export function writeExecPending(
|
|
15
|
+
dir: string,
|
|
16
|
+
config: ExecPendingConfig,
|
|
17
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
18
|
+
return Effect.gen(function* () {
|
|
19
|
+
const fs = yield* FileSystem;
|
|
20
|
+
yield* fs.makeDir(dir);
|
|
21
|
+
yield* fs.writeFileString(
|
|
22
|
+
`${dir}/${EXEC_PENDING_FILE}`,
|
|
23
|
+
JSON.stringify(config, null, 2) + '\n',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
export
|
|
15
|
-
{ planDir: string; config: ExecPendingConfig } | undefined
|
|
28
|
+
export function readAndClearExecPending(): Effect.Effect<
|
|
29
|
+
{ planDir: string; config: ExecPendingConfig } | undefined,
|
|
30
|
+
never,
|
|
31
|
+
FileSystem
|
|
16
32
|
> {
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
return Effect.gen(function* () {
|
|
34
|
+
const fs = yield* FileSystem;
|
|
35
|
+
const maybeDirs = yield* Effect.option(fs.listDirectories(PLANS_DIR));
|
|
36
|
+
if (Option.isNone(maybeDirs)) return undefined;
|
|
37
|
+
|
|
38
|
+
for (const name of maybeDirs.value) {
|
|
39
|
+
const dir = `${PLANS_DIR}/${name}`;
|
|
22
40
|
const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
|
|
41
|
+
const maybeText = yield* Effect.option(fs.readFileString(markerPath));
|
|
42
|
+
if (Option.isNone(maybeText)) continue;
|
|
43
|
+
|
|
44
|
+
let parsed: unknown;
|
|
23
45
|
try {
|
|
24
|
-
|
|
25
|
-
const config = JSON.parse(text) as ExecPendingConfig;
|
|
26
|
-
await unlink(markerPath);
|
|
27
|
-
return { planDir: dir, config };
|
|
46
|
+
parsed = JSON.parse(maybeText.value);
|
|
28
47
|
} catch {
|
|
29
|
-
|
|
48
|
+
continue;
|
|
30
49
|
}
|
|
50
|
+
const decoded = decodeExecPendingConfig(parsed);
|
|
51
|
+
if (Either.isLeft(decoded)) continue;
|
|
52
|
+
|
|
53
|
+
yield* Effect.ignore(fs.removeFile(markerPath));
|
|
54
|
+
return { planDir: dir, config: decoded.right };
|
|
31
55
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
return undefined;
|
|
56
|
+
return undefined;
|
|
57
|
+
});
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
export function saveHandoff(
|
|
61
|
+
planDir: string,
|
|
62
|
+
content: string,
|
|
63
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const fs = yield* FileSystem;
|
|
66
|
+
yield* fs.makeDir(planDir);
|
|
67
|
+
yield* fs.writeFileString(`${planDir}/HANDOFF.md`, content);
|
|
68
|
+
});
|
|
41
69
|
}
|
|
42
70
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
48
|
-
}
|
|
71
|
+
export function loadHandoff(planDir: string): Effect.Effect<string | undefined, never, FileSystem> {
|
|
72
|
+
return Effect.gen(function* () {
|
|
73
|
+
const fs = yield* FileSystem;
|
|
74
|
+
const maybeText = yield* Effect.option(fs.readFileString(`${planDir}/HANDOFF.md`));
|
|
75
|
+
return Option.getOrUndefined(maybeText);
|
|
76
|
+
});
|
|
49
77
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Effect, Either, Option } from 'effect';
|
|
2
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
3
|
+
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
4
|
+
import { decodePlanManifestEntry } from '../schema.js';
|
|
3
5
|
|
|
6
|
+
const MANIFEST_DIR = '.plans';
|
|
4
7
|
const MANIFEST_PATH = '.plans/plans.jsonl';
|
|
5
8
|
|
|
6
9
|
export interface PlanManifestEntry {
|
|
@@ -12,66 +15,70 @@ export interface PlanManifestEntry {
|
|
|
12
15
|
completed_at: string | null;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
type ReadError = JsonlParseError | JsonlValidationError;
|
|
19
|
+
|
|
20
|
+
export function readPlansManifest(): Effect.Effect<PlanManifestEntry[], ReadError, FileSystem> {
|
|
21
|
+
return Effect.gen(function* () {
|
|
22
|
+
const fs = yield* FileSystem;
|
|
23
|
+
// A missing or unreadable manifest is treated as "no plans".
|
|
24
|
+
const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
|
|
25
|
+
if (Option.isNone(maybeText)) return [];
|
|
26
|
+
|
|
27
|
+
const entries: PlanManifestEntry[] = [];
|
|
28
|
+
for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
|
|
29
|
+
if (!raw.trim()) continue;
|
|
30
|
+
const line = index + 1;
|
|
31
|
+
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const decoded = decodePlanManifestEntry(parsed);
|
|
40
|
+
if (Either.isLeft(decoded)) {
|
|
41
|
+
return yield* Effect.fail(
|
|
42
|
+
new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
entries.push(decoded.right);
|
|
30
46
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
entries.push(parsed);
|
|
34
|
-
}
|
|
35
|
-
return entries;
|
|
47
|
+
return entries;
|
|
48
|
+
});
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
export function writePlansManifest(
|
|
52
|
+
entries: PlanManifestEntry[],
|
|
53
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
54
|
+
return Effect.gen(function* () {
|
|
55
|
+
const fs = yield* FileSystem;
|
|
56
|
+
yield* fs.makeDir(MANIFEST_DIR);
|
|
57
|
+
const content =
|
|
58
|
+
entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
|
|
59
|
+
yield* fs.writeFileAtomic(MANIFEST_PATH, content);
|
|
60
|
+
});
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
export
|
|
63
|
+
export function upsertPlanEntry(
|
|
46
64
|
name: string,
|
|
47
65
|
updates: { status: 'in-progress' | 'done'; title?: string },
|
|
48
|
-
):
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
|
|
67
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
68
|
-
const record = value as Record<string, unknown>;
|
|
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
|
-
);
|
|
66
|
+
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const entries = yield* readPlansManifest();
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
71
|
+
const existing = index === -1 ? undefined : entries[index];
|
|
72
|
+
const entry: PlanManifestEntry = {
|
|
73
|
+
_type: 'plan',
|
|
74
|
+
name,
|
|
75
|
+
status: updates.status,
|
|
76
|
+
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
77
|
+
created_at: existing?.created_at ?? now,
|
|
78
|
+
completed_at: updates.status === 'done' ? now : null,
|
|
79
|
+
};
|
|
80
|
+
if (index === -1) entries.push(entry);
|
|
81
|
+
else entries[index] = entry;
|
|
82
|
+
yield* writePlansManifest(entries);
|
|
83
|
+
});
|
|
77
84
|
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, Either, Option } from 'effect';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
4
|
+
import {
|
|
5
|
+
JsonlParseError,
|
|
6
|
+
JsonlValidationError,
|
|
7
|
+
MissingMetaRecord,
|
|
8
|
+
PlanWriteError,
|
|
9
|
+
TaskNotFound,
|
|
10
|
+
TasksFileNotFound,
|
|
11
|
+
} from '../errors.js';
|
|
12
|
+
import { decodeTasksLine } from '../schema.js';
|
|
13
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
5
14
|
|
|
6
15
|
const TASKS_FILE = 'tasks.jsonl';
|
|
7
16
|
|
|
@@ -10,58 +19,87 @@ export interface TasksSnapshot {
|
|
|
10
19
|
tasks: TaskRecord[];
|
|
11
20
|
}
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
let text: string;
|
|
15
|
-
try {
|
|
16
|
-
text = await readFile(join(planDir, TASKS_FILE), 'utf8');
|
|
17
|
-
} catch {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
|
|
22
|
+
type ReadError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
export function readTasksJsonl(
|
|
25
|
+
planDir: string,
|
|
26
|
+
): Effect.Effect<TasksSnapshot | undefined, ReadError, FileSystem> {
|
|
27
|
+
const path = join(planDir, TASKS_FILE);
|
|
28
|
+
return Effect.gen(function* () {
|
|
29
|
+
const fs = yield* FileSystem;
|
|
30
|
+
// A read failure (missing or unreadable file) is treated as "no snapshot".
|
|
31
|
+
const maybeText = yield* Effect.option(fs.readFileString(path));
|
|
32
|
+
if (Option.isNone(maybeText)) return undefined;
|
|
33
|
+
|
|
34
|
+
const text = maybeText.value;
|
|
35
|
+
if (!text.trim()) return yield* Effect.fail(new MissingMetaRecord({ path }));
|
|
36
|
+
|
|
37
|
+
let meta: TaskMeta | undefined;
|
|
38
|
+
const tasks: TaskRecord[] = [];
|
|
39
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
40
|
+
if (!raw.trim()) continue;
|
|
41
|
+
const line = index + 1;
|
|
42
|
+
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
return yield* Effect.fail(new JsonlParseError({ path, line, cause }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const decoded = decodeTasksLine(parsed);
|
|
51
|
+
if (Either.isLeft(decoded)) {
|
|
52
|
+
return yield* Effect.fail(
|
|
53
|
+
new JsonlValidationError({ path, line, reason: decoded.left.message }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const record = decoded.right;
|
|
58
|
+
if (record._type === 'meta') meta = record;
|
|
59
|
+
else tasks.push(record);
|
|
31
60
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
if (!meta) throw new Error('tasks.jsonl is missing meta record');
|
|
37
|
-
return { meta, tasks };
|
|
61
|
+
|
|
62
|
+
if (!meta) return yield* Effect.fail(new MissingMetaRecord({ path }));
|
|
63
|
+
return { meta, tasks };
|
|
64
|
+
});
|
|
38
65
|
}
|
|
39
66
|
|
|
40
|
-
export
|
|
67
|
+
export function writeTasksJsonl(
|
|
41
68
|
planDir: string,
|
|
42
69
|
meta: TaskMeta,
|
|
43
70
|
tasks: TaskRecord[],
|
|
44
|
-
):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
72
|
+
return Effect.gen(function* () {
|
|
73
|
+
const fs = yield* FileSystem;
|
|
74
|
+
yield* fs.makeDir(planDir);
|
|
75
|
+
const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
|
|
76
|
+
yield* fs.writeFileAtomic(join(planDir, TASKS_FILE), content);
|
|
77
|
+
});
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
export
|
|
80
|
+
export function updateTask(
|
|
51
81
|
planDir: string,
|
|
52
82
|
taskId: string,
|
|
53
83
|
updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>,
|
|
54
|
-
):
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
): Effect.Effect<
|
|
85
|
+
TaskRecord,
|
|
86
|
+
ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
|
|
87
|
+
FileSystem
|
|
88
|
+
> {
|
|
89
|
+
return Effect.gen(function* () {
|
|
90
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
91
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
92
|
+
|
|
93
|
+
const index = snapshot.tasks.findIndex((task) => task.id === taskId);
|
|
94
|
+
if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
|
|
95
|
+
|
|
96
|
+
const updated: TaskRecord = {
|
|
97
|
+
...snapshot.tasks[index],
|
|
98
|
+
...updates,
|
|
99
|
+
updated_at: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
snapshot.tasks[index] = updated;
|
|
102
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
103
|
+
return updated;
|
|
104
|
+
});
|
|
67
105
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for reasoning about task status at execution checkpoints.
|
|
3
|
+
*
|
|
4
|
+
* `deferred` tasks are discovered follow-ups kept out of the active queue, so
|
|
5
|
+
* they are excluded from "resolved" checks but block plan finalization.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TaskRecord } from './types.js';
|
|
9
|
+
|
|
10
|
+
export function deferredTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
11
|
+
return tasks.filter((task) => task.status === 'deferred');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when no active work remains — every task is done, skipped, or deferred
|
|
16
|
+
* (nothing pending or blocked).
|
|
17
|
+
*/
|
|
18
|
+
export function activeTasksResolved(tasks: readonly TaskRecord[]): boolean {
|
|
19
|
+
return tasks.every(
|
|
20
|
+
(task) => task.status === 'done' || task.status === 'skipped' || task.status === 'deferred',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True when the plan can be marked complete: active work is resolved AND there
|
|
26
|
+
* are no deferred follow-ups awaiting the user's decision.
|
|
27
|
+
*/
|
|
28
|
+
export function isPlanFinalizable(tasks: readonly TaskRecord[]): boolean {
|
|
29
|
+
return activeTasksResolved(tasks) && !tasks.some((task) => task.status === 'deferred');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reactivate tasks for a resumed run: blocked tasks and deferred follow-ups
|
|
34
|
+
* become pending (mutated in place). Returns true if anything changed.
|
|
35
|
+
*/
|
|
36
|
+
export function reactivateForExecution(tasks: TaskRecord[], timestamp: string): boolean {
|
|
37
|
+
let changed = false;
|
|
38
|
+
for (const task of tasks) {
|
|
39
|
+
if (task.status === 'blocked' || task.status === 'deferred') {
|
|
40
|
+
task.status = 'pending';
|
|
41
|
+
task.updated_at = timestamp;
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return changed;
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* add_task tool — available during execution.
|
|
3
|
+
*
|
|
4
|
+
* Lets the agent capture a follow-up task it discovered while implementing.
|
|
5
|
+
* The task is recorded as a *deferred* `discovered` task so it stays out of the
|
|
6
|
+
* active execution queue — the user reviews and decides via `/plan resume`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
11
|
+
import { Type } from 'typebox';
|
|
12
|
+
import type { PlanData, TaskRecord } from '../types.js';
|
|
13
|
+
import { nextTaskId } from '../utils.js';
|
|
14
|
+
|
|
15
|
+
export interface AddTaskCallbacks {
|
|
16
|
+
getPlan: () => PlanData | undefined;
|
|
17
|
+
onTaskAdded: (task: TaskRecord) => void | Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallbacks): void {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: 'add_task',
|
|
23
|
+
label: 'Add Task',
|
|
24
|
+
description:
|
|
25
|
+
'Capture a follow-up task you discovered while implementing. It is recorded as a deferred task for the user to review later — it is NOT executed in this run.',
|
|
26
|
+
promptSnippet: 'Capture a discovered follow-up as a deferred task for later review',
|
|
27
|
+
promptGuidelines: [
|
|
28
|
+
'Use add_task when you notice worthwhile work outside the current plan while implementing.',
|
|
29
|
+
'Discovered tasks are deferred for the user to review — do NOT implement them now; continue with the planned tasks.',
|
|
30
|
+
'Give a clear reason so the user can decide whether the follow-up is worth doing.',
|
|
31
|
+
],
|
|
32
|
+
parameters: Type.Object({
|
|
33
|
+
description: Type.String({ description: 'Short task label (≤60 chars)' }),
|
|
34
|
+
reason: Type.String({
|
|
35
|
+
description: 'Why this follow-up matters — what you noticed and why it is worth doing',
|
|
36
|
+
}),
|
|
37
|
+
details: Type.Optional(
|
|
38
|
+
Type.String({ description: 'Optional fuller implementation notes for the follow-up' }),
|
|
39
|
+
),
|
|
40
|
+
depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
async execute(_toolCallId, params) {
|
|
44
|
+
const plan = callbacks.getPlan();
|
|
45
|
+
if (!plan) throw new Error('No active plan. Cannot add a follow-up task.');
|
|
46
|
+
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const task: TaskRecord = {
|
|
49
|
+
_type: 'task',
|
|
50
|
+
id: nextTaskId(plan.tasks.map((candidate) => candidate.id)),
|
|
51
|
+
description: params.description.slice(0, 60),
|
|
52
|
+
details: params.details ?? '',
|
|
53
|
+
status: 'deferred',
|
|
54
|
+
origin: 'discovered',
|
|
55
|
+
depends_on: params.depends_on,
|
|
56
|
+
notes: params.reason,
|
|
57
|
+
created_at: now,
|
|
58
|
+
updated_at: now,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
await callbacks.onTaskAdded(task);
|
|
62
|
+
|
|
63
|
+
const deferred = plan.tasks.filter((candidate) => candidate.status === 'deferred').length;
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: 'text' as const,
|
|
68
|
+
text: `Captured follow-up ${task.id}: ${task.description} (deferred for review). ${deferred} follow-up(s) pending. Continue with the planned tasks — do not implement this now.`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
details: { task_id: task.id, description: task.description, reason: params.reason },
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
renderCall(args, theme) {
|
|
76
|
+
const description = (args as { description?: string }).description ?? '';
|
|
77
|
+
let content = theme.fg('toolTitle', theme.bold('add_task '));
|
|
78
|
+
content += theme.fg('accent', '+ ');
|
|
79
|
+
content += description;
|
|
80
|
+
return new Text(content, 0, 0);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
renderResult(result, _options, theme) {
|
|
84
|
+
const details = result.details as { task_id?: string; description?: string } | undefined;
|
|
85
|
+
if (!details) return new Text(theme.fg('dim', 'Follow-up captured'), 0, 0);
|
|
86
|
+
return new Text(
|
|
87
|
+
theme.fg('accent', '+ ') +
|
|
88
|
+
`${theme.fg('muted', details.task_id ?? '')} ${details.description ?? ''} ` +
|
|
89
|
+
theme.fg('dim', '(deferred)'),
|
|
90
|
+
0,
|
|
91
|
+
0,
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import { Text } from '@earendil-works/pi-tui';
|
|
11
11
|
import { Type } from 'typebox';
|
|
12
|
-
import {
|
|
12
|
+
import { Effect } from 'effect';
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
16
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
15
17
|
import { renderPrototypeHtml } from '../html/render.js';
|
|
16
18
|
import { toKebabCase } from '../utils.js';
|
|
17
19
|
|
|
@@ -34,7 +36,7 @@ function openInBrowser(filePath: string): void {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function registerPreviewPrototypeTool(pi: ExtensionAPI): void {
|
|
39
|
+
export function registerPreviewPrototypeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
38
40
|
pi.registerTool({
|
|
39
41
|
name: 'preview_prototype',
|
|
40
42
|
label: 'Preview Prototype',
|
|
@@ -59,8 +61,13 @@ export function registerPreviewPrototypeTool(pi: ExtensionAPI): void {
|
|
|
59
61
|
const filePath = join(PREVIEW_DIR, `${slug}.html`);
|
|
60
62
|
const html = renderPrototypeHtml(params.title, params.intent, params.pug);
|
|
61
63
|
|
|
62
|
-
await
|
|
63
|
-
|
|
64
|
+
await runPlanIO(
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const fs = yield* FileSystem;
|
|
67
|
+
yield* fs.makeDir(PREVIEW_DIR);
|
|
68
|
+
yield* fs.writeFileString(filePath, html);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
64
71
|
openInBrowser(filePath);
|
|
65
72
|
ctx?.ui?.notify(`Prototype written to ${filePath} — opening for review.`, 'info');
|
|
66
73
|
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import { Text } from '@earendil-works/pi-tui';
|
|
7
7
|
import { Type } from 'typebox';
|
|
8
|
-
import {
|
|
8
|
+
import { Effect } from 'effect';
|
|
9
9
|
import { saveHandoff } from '../storage/plan-storage.js';
|
|
10
10
|
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
11
11
|
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
12
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
12
13
|
import { toKebabCase } from '../utils.js';
|
|
13
14
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
14
15
|
|
|
@@ -16,14 +17,16 @@ export interface SubmitPlanCallbacks {
|
|
|
16
17
|
onPlanSubmitted: (planDir: string, plan: PlanData) => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function registerSubmitPlanTool(
|
|
20
|
+
export function registerSubmitPlanTool(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
runPlanIO: RunPlanIO,
|
|
23
|
+
callbacks: SubmitPlanCallbacks,
|
|
24
|
+
): void {
|
|
20
25
|
pi.registerTool({
|
|
21
26
|
name: 'submit_plan',
|
|
22
27
|
label: 'Submit Plan',
|
|
23
|
-
description:
|
|
24
|
-
|
|
25
|
-
promptSnippet:
|
|
26
|
-
'Finalize the plan with title, handoff, tasks, and dependencies',
|
|
28
|
+
description: 'Finalize a conversational plan with task IDs, JSONL storage, and HANDOFF.md.',
|
|
29
|
+
promptSnippet: 'Finalize the plan with title, handoff, tasks, and dependencies',
|
|
27
30
|
promptGuidelines: [
|
|
28
31
|
'Only call submit_plan after shared understanding has been reached with the user.',
|
|
29
32
|
'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
|
|
@@ -78,10 +81,13 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
78
81
|
}));
|
|
79
82
|
const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
|
|
80
83
|
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
await runPlanIO(
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
87
|
+
yield* saveHandoff(planDir, params.handoff);
|
|
88
|
+
yield* upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
85
91
|
|
|
86
92
|
callbacks.onPlanSubmitted(planDir, plan);
|
|
87
93
|
|