@dreki-gg/pi-plan-mode 0.25.0 → 0.26.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 +13 -0
- package/extensions/plan-mode/__tests__/git.test.ts +17 -0
- package/extensions/plan-mode/__tests__/prompts.test.ts +23 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +15 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +7 -0
- package/extensions/plan-mode/git.ts +21 -0
- package/extensions/plan-mode/index.ts +1 -0
- package/extensions/plan-mode/prompts.ts +6 -1
- package/extensions/plan-mode/resolve-plan.ts +1 -0
- package/extensions/plan-mode/resume.ts +1 -0
- package/extensions/plan-mode/schema.ts +2 -0
- package/extensions/plan-mode/tools/revise-plan.ts +2 -0
- package/extensions/plan-mode/tools/submit-plan.ts +11 -1
- package/extensions/plan-mode/types.ts +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.26.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Make plans more executable by zero-context executors. Plans now stamp the git
|
|
8
|
+
commit they were written against (`base_commit`), and the execution prompt runs
|
|
9
|
+
a drift check — if HEAD has moved, the executor is told to diff, re-read the
|
|
10
|
+
affected files, and proceed with caution. Planning guidance also now asks
|
|
11
|
+
delegation tasks to end their details with a verification gate (a concrete
|
|
12
|
+
command + expected output) and STOP conditions, so success is machine-checkable.
|
|
13
|
+
Fully backward-compatible: older plans without `base_commit` simply skip the
|
|
14
|
+
drift check.
|
|
15
|
+
|
|
3
16
|
## 0.25.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { readHeadCommit } from '../git.js';
|
|
4
|
+
|
|
5
|
+
describe('readHeadCommit', () => {
|
|
6
|
+
test('returns the HEAD sha inside a git repo', async () => {
|
|
7
|
+
// This monorepo is a git repo, so cwd resolves a real commit.
|
|
8
|
+
const sha = await readHeadCommit(process.cwd());
|
|
9
|
+
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('returns undefined outside a git repo (never throws)', async () => {
|
|
13
|
+
// tmpdir is not a git repo; the helper must swallow the error.
|
|
14
|
+
const sha = await readHeadCommit(tmpdir());
|
|
15
|
+
expect(sha).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -23,6 +23,11 @@ describe('buildPlanModePrompt', () => {
|
|
|
23
23
|
expect(prompt).toContain('technical-options');
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test('instructs delegation tasks to include a verification gate', () => {
|
|
27
|
+
expect(prompt).toContain('verification gate');
|
|
28
|
+
expect(prompt).toContain('STOP conditions');
|
|
29
|
+
});
|
|
30
|
+
|
|
26
31
|
test('mentions handoff instead of context and risks', () => {
|
|
27
32
|
expect(prompt).toContain('handoff');
|
|
28
33
|
expect(prompt).not.toContain('- risks:');
|
|
@@ -72,6 +77,24 @@ describe('buildExecutionPrompt', () => {
|
|
|
72
77
|
expect(prompt).toContain('Details: Full instructions here');
|
|
73
78
|
});
|
|
74
79
|
|
|
80
|
+
test('includes a drift check when base_commit is present', () => {
|
|
81
|
+
const plan: PlanData = {
|
|
82
|
+
title: 'Test',
|
|
83
|
+
planName: 'test',
|
|
84
|
+
handoff: '# H',
|
|
85
|
+
tasks: [makeTask()],
|
|
86
|
+
base_commit: 'deadbeef',
|
|
87
|
+
};
|
|
88
|
+
const prompt = buildExecutionPrompt(plan)!;
|
|
89
|
+
expect(prompt).toContain('Drift check');
|
|
90
|
+
expect(prompt).toContain('deadbeef');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('omits the drift check when base_commit is absent', () => {
|
|
94
|
+
const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask()] };
|
|
95
|
+
expect(buildExecutionPrompt(plan)!).not.toContain('Drift check');
|
|
96
|
+
});
|
|
97
|
+
|
|
75
98
|
test('returns undefined when no pending tasks', () => {
|
|
76
99
|
const plan: PlanData = {
|
|
77
100
|
title: 'Test',
|
|
@@ -113,6 +113,21 @@ describe('task meta schema', () => {
|
|
|
113
113
|
).toBe(true);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
test('accepts a meta record with base_commit', () => {
|
|
117
|
+
expect(
|
|
118
|
+
isOk(
|
|
119
|
+
{ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now, base_commit: 'abc123' },
|
|
120
|
+
decodeTaskMeta,
|
|
121
|
+
),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('accepts a meta record without base_commit (back-compat)', () => {
|
|
126
|
+
expect(
|
|
127
|
+
isOk({ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now }, decodeTaskMeta),
|
|
128
|
+
).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
116
131
|
test('rejects malformed meta records', () => {
|
|
117
132
|
expect(isOk({ _type: 'meta', title: 'Refactor' }, decodeTaskMeta)).toBe(false);
|
|
118
133
|
expect(
|
|
@@ -47,6 +47,13 @@ describe('tasks.jsonl storage', () => {
|
|
|
47
47
|
await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test('round trips base_commit on the meta record', async () => {
|
|
51
|
+
const metaWithCommit: TaskMeta = { ...meta, base_commit: 'deadbeefcafe' };
|
|
52
|
+
await run(writeTasksJsonl(dir, metaWithCommit, [task]));
|
|
53
|
+
const result = await run(readTasksJsonl(dir));
|
|
54
|
+
expect(result?.meta.base_commit).toBe('deadbeefcafe');
|
|
55
|
+
});
|
|
56
|
+
|
|
50
57
|
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
51
58
|
const lightweight: TaskRecord = {
|
|
52
59
|
_type: 'task',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git helpers. Failure-tolerant by design: plan-mode must never block
|
|
3
|
+
* or throw because git metadata is unavailable (no repo, detached HEAD, no
|
|
4
|
+
* commits yet). Runs on Node via execFile — no Bun APIs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the current HEAD commit SHA, or undefined when it cannot be read.
|
|
11
|
+
* Never rejects — callers treat undefined as "no drift baseline".
|
|
12
|
+
*/
|
|
13
|
+
export function readHeadCommit(cwd: string = process.cwd()): Promise<string | undefined> {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
execFile('git', ['rev-parse', 'HEAD'], { cwd }, (error, stdout) => {
|
|
16
|
+
if (error) return resolve(undefined);
|
|
17
|
+
const sha = stdout.trim();
|
|
18
|
+
resolve(sha.length > 0 ? sha : undefined);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -626,6 +626,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
626
626
|
planName: snapshot.meta.plan_name,
|
|
627
627
|
handoff: (await runPlanIO(loadHandoff(pending.planDir))) ?? '',
|
|
628
628
|
tasks: snapshot.tasks,
|
|
629
|
+
base_commit: snapshot.meta.base_commit,
|
|
629
630
|
}
|
|
630
631
|
: undefined;
|
|
631
632
|
}
|
|
@@ -28,7 +28,7 @@ When you are ready to finalize the plan, call submit_plan with:
|
|
|
28
28
|
- tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), optional details, and optional depends_on task IDs
|
|
29
29
|
|
|
30
30
|
Plan weight:
|
|
31
|
-
- **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them.
|
|
31
|
+
- **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them. End each task's details with a **verification gate** — a concrete command and its expected output (e.g. \`bun test\` → all pass) so the executor can prove success without judgement, plus any **STOP conditions** ("if X, stop and report" instead of improvising when reality doesn't match the plan).
|
|
32
32
|
- **Self-execution plans** (you plan and execute in the same session): use lightweight checklist-style tasks — just id + description, skip details. The handoff doc carries the real context.
|
|
33
33
|
|
|
34
34
|
submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
|
|
@@ -59,6 +59,10 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
|
|
|
59
59
|
const currentTask = remaining[0];
|
|
60
60
|
const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
|
|
61
61
|
|
|
62
|
+
const driftCheck = plan.base_commit
|
|
63
|
+
? `\n## Drift check (do this FIRST)\nThis plan was written against git commit ${plan.base_commit}. Before editing, run \`git rev-parse HEAD\`. If it differs, the codebase has moved since the plan was written: run \`git diff ${plan.base_commit} --stat\`, re-read any files the current task touches, and proceed with caution — adjust to what the code actually looks like now. This is a warning, not a stop.\n`
|
|
64
|
+
: '';
|
|
65
|
+
|
|
62
66
|
return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
|
|
63
67
|
|
|
64
68
|
You are executing a structured plan. Your ONLY job is to implement the plan tasks below, one at a time.
|
|
@@ -73,6 +77,7 @@ Rules:
|
|
|
73
77
|
|
|
74
78
|
## Current task
|
|
75
79
|
${currentTask.id}: ${currentTask.description}${currentDetails}
|
|
80
|
+
${driftCheck}
|
|
76
81
|
|
|
77
82
|
## Handoff
|
|
78
83
|
${plan.handoff}
|
|
@@ -31,6 +31,8 @@ export const TaskMetaSchema = Schema.Struct({
|
|
|
31
31
|
title: Schema.String,
|
|
32
32
|
plan_name: Schema.String,
|
|
33
33
|
created_at: Schema.String,
|
|
34
|
+
/** Optional git commit the plan was written against (back-compat: absent on older plans). */
|
|
35
|
+
base_commit: Schema.optional(Schema.String),
|
|
34
36
|
});
|
|
35
37
|
|
|
36
38
|
/** A single tasks.jsonl line is either the meta record or a task record. */
|
|
@@ -129,12 +129,14 @@ export function registerRevisePlanTool(
|
|
|
129
129
|
title: newTitle,
|
|
130
130
|
plan_name: plan.planName,
|
|
131
131
|
created_at: plan.tasks[0]?.created_at ?? now,
|
|
132
|
+
base_commit: plan.base_commit,
|
|
132
133
|
};
|
|
133
134
|
const revised: PlanData = {
|
|
134
135
|
title: newTitle,
|
|
135
136
|
planName: plan.planName,
|
|
136
137
|
handoff: newHandoff,
|
|
137
138
|
tasks,
|
|
139
|
+
base_commit: plan.base_commit,
|
|
138
140
|
};
|
|
139
141
|
const planDir = `.plans/${plan.planName}`;
|
|
140
142
|
|
|
@@ -13,6 +13,7 @@ import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
|
|
|
13
13
|
import { reconcileInitiativeForPlan } from '../initiative.js';
|
|
14
14
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
15
15
|
import { toKebabCase } from '../utils.js';
|
|
16
|
+
import { readHeadCommit } from '../git.js';
|
|
16
17
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
17
18
|
|
|
18
19
|
export interface SubmitPlanCallbacks {
|
|
@@ -33,6 +34,7 @@ export function registerSubmitPlanTool(
|
|
|
33
34
|
'Only call submit_plan after shared understanding has been reached with the user.',
|
|
34
35
|
'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
|
|
35
36
|
"When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
|
|
37
|
+
"For delegation tasks (those with details), end the details with a verification gate: a concrete command and its expected output, plus any STOP conditions, so a zero-context executor can prove success without judgement.",
|
|
36
38
|
'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.',
|
|
37
39
|
'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
|
|
38
40
|
'For visual/UI work, preview a prototype with preview_prototype during planning — before submit_plan, not as part of it.',
|
|
@@ -81,11 +83,13 @@ export function registerSubmitPlanTool(
|
|
|
81
83
|
const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
|
82
84
|
const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
|
|
83
85
|
const now = new Date().toISOString();
|
|
86
|
+
const baseCommit = await readHeadCommit();
|
|
84
87
|
const meta: TaskMeta = {
|
|
85
88
|
_type: 'meta',
|
|
86
89
|
title: params.title,
|
|
87
90
|
plan_name: planName,
|
|
88
91
|
created_at: now,
|
|
92
|
+
base_commit: baseCommit,
|
|
89
93
|
};
|
|
90
94
|
const tasks: TaskRecord[] = params.tasks.map((task) => ({
|
|
91
95
|
_type: 'task',
|
|
@@ -97,7 +101,13 @@ export function registerSubmitPlanTool(
|
|
|
97
101
|
created_at: now,
|
|
98
102
|
updated_at: now,
|
|
99
103
|
}));
|
|
100
|
-
const plan: PlanData = {
|
|
104
|
+
const plan: PlanData = {
|
|
105
|
+
title: params.title,
|
|
106
|
+
planName,
|
|
107
|
+
handoff: params.handoff,
|
|
108
|
+
tasks,
|
|
109
|
+
base_commit: baseCommit,
|
|
110
|
+
};
|
|
101
111
|
|
|
102
112
|
const unknownInitiative = await runPlanIO(
|
|
103
113
|
Effect.gen(function* () {
|
|
@@ -35,6 +35,12 @@ export interface TaskMeta {
|
|
|
35
35
|
title: string;
|
|
36
36
|
plan_name: string;
|
|
37
37
|
created_at: string;
|
|
38
|
+
/**
|
|
39
|
+
* Git commit (HEAD) the plan was written against, captured at submit time.
|
|
40
|
+
* Optional for back-compat: older tasks.jsonl files predate this field, and
|
|
41
|
+
* it stays undefined when git metadata is unavailable (no repo, no commits).
|
|
42
|
+
*/
|
|
43
|
+
base_commit?: string;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export interface PlanData {
|
|
@@ -42,6 +48,8 @@ export interface PlanData {
|
|
|
42
48
|
planName: string;
|
|
43
49
|
handoff: string;
|
|
44
50
|
tasks: TaskRecord[];
|
|
51
|
+
/** Git commit the plan was written against; powers the executor drift check. */
|
|
52
|
+
base_commit?: string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
package/package.json
CHANGED