@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
|
@@ -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
|
|
|
@@ -93,7 +99,6 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
93
99
|
},
|
|
94
100
|
],
|
|
95
101
|
details: { planDir, plan },
|
|
96
|
-
terminate: true,
|
|
97
102
|
};
|
|
98
103
|
},
|
|
99
104
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Shared types for plan mode.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export type TaskStatus = 'pending' | 'done' | 'skipped' | 'blocked';
|
|
5
|
+
export type TaskStatus = 'pending' | 'done' | 'skipped' | 'blocked' | 'deferred';
|
|
6
|
+
|
|
7
|
+
/** Where a task came from: the original submitted plan, or discovered during execution. */
|
|
8
|
+
export type TaskOrigin = 'plan' | 'discovered';
|
|
6
9
|
|
|
7
10
|
export interface TaskRecord {
|
|
8
11
|
_type: 'task';
|
|
@@ -10,6 +13,8 @@ export interface TaskRecord {
|
|
|
10
13
|
description: string;
|
|
11
14
|
details?: string;
|
|
12
15
|
status: TaskStatus;
|
|
16
|
+
/** Defaults to 'plan' when absent (back-compat with older tasks.jsonl files). */
|
|
17
|
+
origin?: TaskOrigin;
|
|
13
18
|
depends_on?: string[];
|
|
14
19
|
notes?: string;
|
|
15
20
|
created_at: string;
|
|
@@ -45,40 +50,5 @@ export interface PersistedState {
|
|
|
45
50
|
executionStartIdx: number | undefined;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
-
return typeof value === 'object' && value !== null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isStringArray(value: unknown): value is string[] {
|
|
55
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
59
|
-
if (!isRecord(value)) return false;
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
value._type === 'task' &&
|
|
63
|
-
typeof value.id === 'string' &&
|
|
64
|
-
typeof value.description === 'string' &&
|
|
65
|
-
(value.details === undefined || typeof value.details === 'string') &&
|
|
66
|
-
typeof value.status === 'string' &&
|
|
67
|
-
TASK_STATUSES.has(value.status as TaskStatus) &&
|
|
68
|
-
(value.depends_on === undefined || isStringArray(value.depends_on)) &&
|
|
69
|
-
(value.notes === undefined || typeof value.notes === 'string') &&
|
|
70
|
-
typeof value.created_at === 'string' &&
|
|
71
|
-
typeof value.updated_at === 'string'
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function isTaskMeta(value: unknown): value is TaskMeta {
|
|
76
|
-
if (!isRecord(value)) return false;
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
value._type === 'meta' &&
|
|
80
|
-
typeof value.title === 'string' &&
|
|
81
|
-
typeof value.plan_name === 'string' &&
|
|
82
|
-
typeof value.created_at === 'string'
|
|
83
|
-
);
|
|
84
|
-
}
|
|
53
|
+
// Record validation lives in `schema.ts` (Effect Schema). The interfaces above
|
|
54
|
+
// remain the mutable shapes used by the imperative orchestration code.
|
|
@@ -42,3 +42,23 @@ export function toKebabCase(name: string): string {
|
|
|
42
42
|
.replace(/^-+|-+$/g, '')
|
|
43
43
|
.slice(0, 60);
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate the next sequential task id (`t-NNN`) given existing ids.
|
|
48
|
+
*
|
|
49
|
+
* Uses the max numeric suffix of `t-<digits>` ids + 1, zero-padded to 3.
|
|
50
|
+
* Falls back to `t-<count+1>` when no ids match the pattern.
|
|
51
|
+
*/
|
|
52
|
+
export function nextTaskId(existingIds: readonly string[]): string {
|
|
53
|
+
let max = 0;
|
|
54
|
+
let matched = false;
|
|
55
|
+
for (const id of existingIds) {
|
|
56
|
+
const m = /^t-(\d+)$/.exec(id);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
matched = true;
|
|
59
|
+
const n = Number.parseInt(m[1], 10);
|
|
60
|
+
if (n > max) max = n;
|
|
61
|
+
}
|
|
62
|
+
const next = matched ? max + 1 : existingIds.length + 1;
|
|
63
|
+
return `t-${String(next).padStart(3, '0')}`;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
|
+
"effect": "^3.21.2",
|
|
44
45
|
"pug": "^3.0.3"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { isTaskMeta, isTaskRecord } from '../types.js';
|
|
3
|
-
|
|
4
|
-
const now = '2026-05-27T12:00:00.000Z';
|
|
5
|
-
|
|
6
|
-
describe('task record type guards', () => {
|
|
7
|
-
test('accepts valid task records', () => {
|
|
8
|
-
expect(
|
|
9
|
-
isTaskRecord({
|
|
10
|
-
_type: 'task',
|
|
11
|
-
id: 't-001',
|
|
12
|
-
description: 'Do work',
|
|
13
|
-
details: 'Full instructions',
|
|
14
|
-
status: 'pending',
|
|
15
|
-
depends_on: ['t-000'],
|
|
16
|
-
notes: 'note',
|
|
17
|
-
created_at: now,
|
|
18
|
-
updated_at: now,
|
|
19
|
-
}),
|
|
20
|
-
).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('accepts task records without details (lightweight checklist)', () => {
|
|
24
|
-
expect(
|
|
25
|
-
isTaskRecord({
|
|
26
|
-
_type: 'task',
|
|
27
|
-
id: 't-001',
|
|
28
|
-
description: 'Do work',
|
|
29
|
-
status: 'pending',
|
|
30
|
-
created_at: now,
|
|
31
|
-
updated_at: now,
|
|
32
|
-
}),
|
|
33
|
-
).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('rejects malformed task records', () => {
|
|
37
|
-
expect(isTaskRecord({ _type: 'task', id: 't-001', status: 'pending' })).toBe(false);
|
|
38
|
-
expect(
|
|
39
|
-
isTaskRecord({
|
|
40
|
-
_type: 'task',
|
|
41
|
-
id: 't-001',
|
|
42
|
-
description: 'Do work',
|
|
43
|
-
details: 'Full instructions',
|
|
44
|
-
status: 'unknown',
|
|
45
|
-
created_at: now,
|
|
46
|
-
updated_at: now,
|
|
47
|
-
}),
|
|
48
|
-
).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('task meta type guard', () => {
|
|
53
|
-
test('accepts valid meta records', () => {
|
|
54
|
-
expect(
|
|
55
|
-
isTaskMeta({
|
|
56
|
-
_type: 'meta',
|
|
57
|
-
title: 'Refactor',
|
|
58
|
-
plan_name: 'refactor',
|
|
59
|
-
created_at: now,
|
|
60
|
-
}),
|
|
61
|
-
).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('rejects malformed meta records', () => {
|
|
65
|
-
expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
|
|
66
|
-
expect(
|
|
67
|
-
isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now }),
|
|
68
|
-
).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
});
|