@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 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}
@@ -57,6 +57,7 @@ async function attach(
57
57
  planName: snapshot.meta.plan_name,
58
58
  handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
59
59
  tasks: snapshot.tasks,
60
+ base_commit: snapshot.meta.base_commit,
60
61
  };
61
62
  state.plan = plan;
62
63
  state.planDir = dir;
@@ -82,6 +82,7 @@ export async function resumePlan(
82
82
  planName: snapshot.meta.plan_name,
83
83
  handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
84
84
  tasks: snapshot.tasks,
85
+ base_commit: snapshot.meta.base_commit,
85
86
  };
86
87
 
87
88
  const doneCount = state.plan.tasks.filter(
@@ -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 = { title: params.title, planName, handoff: params.handoff, tasks };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
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"