@dreki-gg/pi-plan-mode 0.26.2 → 0.27.3

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,38 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.27.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Require an explicit plan id on plan mutations so writes always target the intended plan.
8
+
9
+ ## 0.27.2
10
+
11
+ ### Patch Changes
12
+
13
+ - `add_task` now rejects an empty or whitespace `plan` argument instead of
14
+ silently filing the follow-up onto whatever plan happens to be in-progress.
15
+ This prevents discovered tasks from being misfiled onto an unrelated plan
16
+ after a context switch — the agent is told to re-issue with an explicit plan.
17
+
18
+ ## 0.27.1
19
+
20
+ ### Patch Changes
21
+
22
+ - Require an explicit `plan` argument on `add_task`, `update_task`, and `update_tasks` so writes are always scoped to the intended plan and can never be misfiled into whatever plan happened to be the active pin. Previously these tools would trust a stale in-memory active-plan pin (or silently fall back to some other in-progress plan) after the pinned plan finished, causing follow-ups to be filed against the wrong plan.
23
+
24
+ In addition, `resolveActivePlan` now drops a stale pin and reports the in-progress candidates instead of guessing when a pinned plan has completed while other plans are still in-progress. The pin is still honored when no other plan is in-progress.
25
+
26
+ ## 0.27.0
27
+
28
+ ### Minor Changes
29
+
30
+ - Plan mode no longer overrides the model or thinking level. Entering plan mode,
31
+ starting execution, and session restore all keep whatever model and thinking
32
+ level the user had active. The forced executor model picker is also removed.
33
+ Additionally, plan mode now auto-exits after the agent submits a plan or
34
+ initiative, returning the user to normal mode immediately.
35
+
3
36
  ## 0.26.2
4
37
 
5
38
  ### Patch Changes
@@ -62,6 +62,7 @@ describe('add_task tool', () => {
62
62
  await tool.execute('call-1', {
63
63
  description: 'Extract shared helper',
64
64
  reason: 'noticed duplication while editing',
65
+ plan: 'plan',
65
66
  });
66
67
 
67
68
  expect(added).toHaveLength(1);
@@ -73,11 +74,23 @@ describe('add_task tool', () => {
73
74
  expect(task.description).toBe('Extract shared helper');
74
75
  });
75
76
 
76
- test('soft-skips (does not throw) when there is no active plan', async () => {
77
+ test('soft-skips (does not throw) when the named plan cannot be resolved', async () => {
77
78
  const { tool, added } = setup(undefined);
78
- const result = await tool.execute('call-1', { description: 'x', reason: 'y' });
79
+ const result = await tool.execute('call-1', { description: 'x', reason: 'y', plan: 'plan' });
79
80
  expect((result.details as { skipped?: boolean }).skipped).toBe(true);
80
81
  expect(result.content?.[0]?.text).toMatch(/no active plan/i);
81
82
  expect(added).toHaveLength(0);
82
83
  });
84
+
85
+ test('throws when { plan } is missing or whitespace', async () => {
86
+ const plan = basePlan([planTask('t-001')]);
87
+ const { tool, added } = setup(plan);
88
+ await expect(tool.execute('call-1', { description: 'x', reason: 'y' })).rejects.toThrow(
89
+ /requires an explicit \{ plan \}/,
90
+ );
91
+ await expect(
92
+ tool.execute('call-2', { description: 'x', reason: 'y', plan: ' ' }),
93
+ ).rejects.toThrow(/requires an explicit \{ plan \}/);
94
+ expect(added).toHaveLength(0);
95
+ });
83
96
  });
@@ -133,6 +133,36 @@ describe('resolveActivePlan', () => {
133
133
  expect(state.planDir).toBe('.plans/beta');
134
134
  });
135
135
 
136
+ test('refuses a stale in-memory pin when other plans are in-progress (misfile guard)', async () => {
137
+ // The pinned plan has completed; two unrelated plans are still in-progress.
138
+ await seedPlan('alpha', 'done', ['t-001']);
139
+ await seedPlan('beta', 'in-progress', ['t-001']);
140
+ await seedPlan('gamma', 'in-progress', ['t-001']);
141
+ const state = new PlanModeState();
142
+ state.plan = { title: 'alpha', planName: 'alpha', handoff: '', tasks: [task('t-001')] };
143
+ state.planDir = '.plans/alpha';
144
+ const { pi } = fakePi();
145
+
146
+ const result = await resolveActivePlan(state, pi, runPlanIO);
147
+
148
+ // Must NOT silently file into the stale pin or guess one of the others.
149
+ expect(result.plan).toBeUndefined();
150
+ expect(result.candidates.sort()).toEqual(['beta', 'gamma']);
151
+ });
152
+
153
+ test('keeps a stale in-memory pin when no other plan is in-progress', async () => {
154
+ // Pin completed, nothing else live → safe to keep using it (no ambiguity).
155
+ await seedPlan('alpha', 'done', ['t-001']);
156
+ const state = new PlanModeState();
157
+ state.plan = { title: 'alpha', planName: 'alpha', handoff: '', tasks: [task('t-001')] };
158
+ state.planDir = '.plans/alpha';
159
+ const { pi } = fakePi();
160
+
161
+ const result = await resolveActivePlan(state, pi, runPlanIO);
162
+
163
+ expect(result.plan?.planName).toBe('alpha');
164
+ });
165
+
136
166
  test('ignores a done plan when auto-attaching', async () => {
137
167
  await seedPlan('alpha', 'done', ['t-001']);
138
168
  const state = new PlanModeState();
@@ -206,4 +206,16 @@ describe('revise_plan tool', () => {
206
206
  const [entry] = await runPlanIO(readPlansManifest());
207
207
  expect(entry.initiative).toBe('big');
208
208
  });
209
+
210
+ test('throws when { plan } is empty or whitespace', async () => {
211
+ const plan: PlanData = { title: 'P', planName: 'p', handoff: 'h', tasks: [task('t-001')] };
212
+ const { tool, revised } = setup(plan);
213
+ await expect(tool.execute('c', { plan: '', title: 'x' })).rejects.toThrow(
214
+ /requires an explicit \{ plan \}/,
215
+ );
216
+ await expect(tool.execute('c', { plan: ' ', title: 'x' })).rejects.toThrow(
217
+ /requires an explicit \{ plan \}/,
218
+ );
219
+ expect(revised).toHaveLength(0);
220
+ });
209
221
  });
@@ -56,20 +56,25 @@ const planTask = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
56
56
  describe('update_task tool', () => {
57
57
  test('marks a pending task and reports progress', async () => {
58
58
  const { tool, updates } = setup(basePlan([planTask('t-001'), planTask('t-002')]));
59
- const result = await tool.execute('c', { task_id: 't-001', status: 'done', notes: 'did it' });
59
+ const result = await tool.execute('c', {
60
+ task_id: 't-001',
61
+ status: 'done',
62
+ notes: 'did it',
63
+ plan: 'plan',
64
+ });
60
65
  expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: 'did it' }]);
61
66
  expect(result.content?.[0]?.text).toMatch(/Progress: 1\/2/);
62
67
  });
63
68
 
64
69
  test('blocked terminates the turn', async () => {
65
70
  const { tool } = setup(basePlan([planTask('t-001')]));
66
- const result = await tool.execute('c', { task_id: 't-001', status: 'blocked' });
71
+ const result = await tool.execute('c', { task_id: 't-001', status: 'blocked', plan: 'plan' });
67
72
  expect(result.terminate).toBe(true);
68
73
  });
69
74
 
70
75
  test('soft-skips (no throw) when there is no active plan', async () => {
71
76
  const { tool, updates } = setup(undefined, ['alpha', 'beta']);
72
- const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
77
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done', plan: 'plan' });
73
78
  expect((result.details as { skipped?: boolean }).skipped).toBe(true);
74
79
  expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
75
80
  expect(updates).toHaveLength(0);
@@ -77,7 +82,7 @@ describe('update_task tool', () => {
77
82
 
78
83
  test('soft result (no throw) for an unknown task id', async () => {
79
84
  const { tool, updates } = setup(basePlan([planTask('t-001')]));
80
- const result = await tool.execute('c', { task_id: 't-999', status: 'done' });
85
+ const result = await tool.execute('c', { task_id: 't-999', status: 'done', plan: 'plan' });
81
86
  expect((result.details as { error?: string }).error).toBe('not_found');
82
87
  expect(result.content?.[0]?.text).toMatch(/t-001/);
83
88
  expect(updates).toHaveLength(0);
@@ -85,22 +90,33 @@ describe('update_task tool', () => {
85
90
 
86
91
  test('idempotent: re-marking the same status is a no-op success', async () => {
87
92
  const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
88
- const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
93
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done', plan: 'plan' });
89
94
  expect(result.content?.[0]?.text).toMatch(/no-op/);
90
95
  expect(updates).toHaveLength(0);
91
96
  });
92
97
 
93
98
  test('corrects an already-resolved task (done → skipped) and reports it', async () => {
94
99
  const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
95
- const result = await tool.execute('c', { task_id: 't-001', status: 'skipped' });
100
+ const result = await tool.execute('c', { task_id: 't-001', status: 'skipped', plan: 'plan' });
96
101
  expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
97
102
  expect(result.content?.[0]?.text).toMatch(/corrected \(done → skipped\)/);
98
103
  });
99
104
 
100
105
  test('unblocks a task (blocked → done) without terminating', async () => {
101
106
  const { tool, updates } = setup(basePlan([planTask('t-001', 'blocked')]));
102
- const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
107
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done', plan: 'plan' });
103
108
  expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
104
109
  expect(result.terminate).toBeUndefined();
105
110
  });
111
+
112
+ test('throws when { plan } is missing or whitespace', async () => {
113
+ const { tool, updates } = setup(basePlan([planTask('t-001')]));
114
+ await expect(tool.execute('c', { task_id: 't-001', status: 'done' })).rejects.toThrow(
115
+ /requires an explicit \{ plan \}/,
116
+ );
117
+ await expect(
118
+ tool.execute('c', { task_id: 't-001', status: 'done', plan: ' ' }),
119
+ ).rejects.toThrow(/requires an explicit \{ plan \}/);
120
+ expect(updates).toHaveLength(0);
121
+ });
106
122
  });
@@ -66,6 +66,7 @@ describe('update_tasks tool', () => {
66
66
  { task_id: 't-001', status: 'done', notes: 'a' },
67
67
  { task_id: 't-002', status: 'skipped', notes: 'b' },
68
68
  ],
69
+ plan: 'plan',
69
70
  });
70
71
  expect(updates).toEqual([
71
72
  { taskId: 't-001', status: 'done', notes: 'a' },
@@ -79,7 +80,10 @@ describe('update_tasks tool', () => {
79
80
 
80
81
  test('soft-skips (no throw) when there is no active plan', async () => {
81
82
  const { tool, updates } = setup(undefined, ['alpha', 'beta']);
82
- const result = await tool.execute('c', { updates: [{ task_id: 't-001', status: 'done' }] });
83
+ const result = await tool.execute('c', {
84
+ updates: [{ task_id: 't-001', status: 'done' }],
85
+ plan: 'plan',
86
+ });
83
87
  expect((result.details as { skipped?: boolean }).skipped).toBe(true);
84
88
  expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
85
89
  expect(updates).toHaveLength(0);
@@ -92,6 +96,7 @@ describe('update_tasks tool', () => {
92
96
  { task_id: 't-001', status: 'done' },
93
97
  { task_id: 't-999', status: 'done' },
94
98
  ],
99
+ plan: 'plan',
95
100
  });
96
101
  expect(getWriteCount()).toBe(0);
97
102
  });
@@ -103,6 +108,7 @@ describe('update_tasks tool', () => {
103
108
  { task_id: 't-001', status: 'done' },
104
109
  { task_id: 't-999', status: 'done' },
105
110
  ],
111
+ plan: 'plan',
106
112
  });
107
113
  expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
108
114
  const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
@@ -116,6 +122,7 @@ describe('update_tasks tool', () => {
116
122
  { task_id: 't-001', status: 'done' },
117
123
  { task_id: 't-002', status: 'done' },
118
124
  ],
125
+ plan: 'plan',
119
126
  });
120
127
  expect(updates).toEqual([{ taskId: 't-002', status: 'done', notes: undefined }]);
121
128
  const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
@@ -124,7 +131,21 @@ describe('update_tasks tool', () => {
124
131
 
125
132
  test('corrects an already-resolved task (done → skipped)', async () => {
126
133
  const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
127
- await tool.execute('c', { updates: [{ task_id: 't-001', status: 'skipped' }] });
134
+ await tool.execute('c', {
135
+ updates: [{ task_id: 't-001', status: 'skipped' }],
136
+ plan: 'plan',
137
+ });
128
138
  expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
129
139
  });
140
+
141
+ test('throws when { plan } is missing or whitespace', async () => {
142
+ const { tool, updates } = setup(basePlan([planTask('t-001')]));
143
+ await expect(
144
+ tool.execute('c', { updates: [{ task_id: 't-001', status: 'done' }] }),
145
+ ).rejects.toThrow(/requires an explicit \{ plan \}/);
146
+ await expect(
147
+ tool.execute('c', { updates: [{ task_id: 't-001', status: 'done' }], plan: ' ' }),
148
+ ).rejects.toThrow(/requires an explicit \{ plan \}/);
149
+ expect(updates).toHaveLength(0);
150
+ });
130
151
  });
@@ -41,18 +41,5 @@ export const EXEC_TOOLS = [
41
41
  'reconcile_plans',
42
42
  ];
43
43
 
44
- // ── Model + thinking presets ─────────────────────────────────────────────────
45
- export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
46
- export const PLAN_THINKING = 'medium' as const;
47
-
48
- export const EXEC_MODEL = { provider: 'openai', id: 'gpt-5.5' } as const;
49
- export const EXEC_THINKING = 'low' as const;
50
-
51
44
  // ── Exec-pending marker file name ────────────────────────────────────────────
52
45
  export const EXEC_PENDING_FILE = '.exec-pending.json';
53
-
54
- // ── Execution model picker options ───────────────────────────────────────────
55
- export const EXEC_MODEL_OPTIONS: { label: string; model: { provider: string; id: string } }[] = [
56
- { label: 'gpt-5.5', model: { provider: 'openai', id: 'gpt-5.5' } },
57
- { label: 'claude-opus-4-6', model: { provider: 'anthropic', id: 'claude-opus-4-6' } },
58
- ];
@@ -21,12 +21,8 @@ import { Key } from '@earendil-works/pi-tui';
21
21
  import {
22
22
  PLAN_TOOLS,
23
23
  EXEC_TOOLS,
24
- PLAN_MODEL,
25
- PLAN_THINKING,
26
- EXEC_MODEL,
27
- EXEC_THINKING,
28
24
  } from './constants.js';
29
- import type { ThinkingLevel, TaskStatus } from './types.js';
25
+ import type { TaskStatus } from './types.js';
30
26
  import { PlanModeState } from './state.js';
31
27
  import { makePlanRuntime } from '@dreki-gg/taskman';
32
28
  import { loadHandoff } from '@dreki-gg/taskman';
@@ -524,11 +520,10 @@ export default function planMode(pi: ExtensionAPI): void {
524
520
  { triggerTurn: false },
525
521
  );
526
522
 
527
- const { previousModel: dpm, previousThinking: dpt } = state;
523
+ const { previousModel: dpm } = state;
528
524
  state.exitPreservingPlan();
529
525
  pi.setActiveTools(EXEC_TOOLS);
530
526
  if (dpm) await switchModel(pi, ctx, dpm);
531
- if (dpt) pi.setThinkingLevel(dpt);
532
527
  updateUI(state, ctx);
533
528
  state.persist(pi);
534
529
  return;
@@ -587,11 +582,10 @@ export default function planMode(pi: ExtensionAPI): void {
587
582
  { triggerTurn: false },
588
583
  );
589
584
 
590
- const { previousModel: pm, previousThinking: pt } = state;
585
+ const { previousModel: pm } = state;
591
586
  state.reset();
592
587
  pi.setActiveTools(EXEC_TOOLS);
593
588
  if (pm) await switchModel(pi, ctx, pm);
594
- if (pt) pi.setThinkingLevel(pt);
595
589
  updateUI(state, ctx);
596
590
  state.persist(pi);
597
591
  return;
@@ -599,8 +593,11 @@ export default function planMode(pi: ExtensionAPI): void {
599
593
  return;
600
594
  }
601
595
 
602
- // Plan submitted user can /plan-exec or type naturally.
603
- // No menu needed: plan.jsonl + HANDOFF.md are the source of truth.
596
+ // Auto-exit plan mode after plan/initiative submission so the user
597
+ // returns to normal mode with their original model.
598
+ if (state.planEnabled) {
599
+ await exitPlanMode(state, pi, ctx);
600
+ }
604
601
  });
605
602
 
606
603
  // ── Event: session restore ────────────────────────────────────────────────
@@ -636,7 +633,7 @@ export default function planMode(pi: ExtensionAPI): void {
636
633
  state.planEnabled = false;
637
634
  pi.setActiveTools(EXEC_TOOLS);
638
635
  await switchModel(pi, ctx, pending.config.model);
639
- pi.setThinkingLevel(pending.config.thinking as ThinkingLevel);
636
+
640
637
  updateUI(state, ctx);
641
638
  state.persist(pi);
642
639
  return;
@@ -651,15 +648,11 @@ export default function planMode(pi: ExtensionAPI): void {
651
648
  await resolveActivePlan(state, pi, runPlanIO);
652
649
  }
653
650
 
654
- // Apply tool restrictions, model, and thinking level
651
+ // Apply tool restrictions (no model/thinking override keep user's settings)
655
652
  if (state.planEnabled) {
656
653
  pi.setActiveTools(PLAN_TOOLS);
657
- await switchModel(pi, ctx, PLAN_MODEL);
658
- pi.setThinkingLevel(PLAN_THINKING);
659
654
  } else if (state.executing) {
660
655
  pi.setActiveTools(EXEC_TOOLS);
661
- await switchModel(pi, ctx, EXEC_MODEL);
662
- pi.setThinkingLevel(EXEC_THINKING);
663
656
  }
664
657
 
665
658
  updateUI(state, ctx);
@@ -4,14 +4,9 @@
4
4
 
5
5
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
6
  import type { PlanModeState } from './state.js';
7
- import type { ThinkingLevel } from './types.js';
8
7
  import {
9
8
  PLAN_TOOLS,
10
9
  EXEC_TOOLS,
11
- PLAN_MODEL,
12
- PLAN_THINKING,
13
- EXEC_MODEL,
14
- EXEC_THINKING,
15
10
  } from './constants.js';
16
11
  import { updateUI } from './ui.js';
17
12
 
@@ -42,12 +37,9 @@ export async function enterPlanMode(
42
37
  state.executing = false;
43
38
  state.planDir = undefined;
44
39
  state.plan = undefined;
45
- state.previousThinking = pi.getThinkingLevel() as ThinkingLevel;
46
40
  state.previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
47
41
  pi.setActiveTools(PLAN_TOOLS);
48
- await switchModel(pi, ctx, PLAN_MODEL);
49
- pi.setThinkingLevel(PLAN_THINKING);
50
- ctx.ui.notify(`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`, 'info');
42
+ ctx.ui.notify('Plan mode ON', 'info');
51
43
  updateUI(state, ctx);
52
44
  state.persist(pi);
53
45
  }
@@ -57,16 +49,13 @@ export async function exitPlanMode(
57
49
  pi: ExtensionAPI,
58
50
  ctx: ExtensionContext,
59
51
  ): Promise<void> {
60
- const { previousModel, previousThinking } = state;
52
+ const { previousModel } = state;
61
53
  state.exitPreservingPlan();
62
54
  pi.setActiveTools(EXEC_TOOLS);
63
55
  if (previousModel) {
64
56
  await switchModel(pi, ctx, previousModel);
65
57
  }
66
- if (previousThinking) {
67
- pi.setThinkingLevel(previousThinking);
68
- }
69
- ctx.ui.notify('Plan mode OFF — original model restored', 'info');
58
+ ctx.ui.notify('Plan mode OFF', 'info');
70
59
  updateUI(state, ctx);
71
60
  state.persist(pi);
72
61
  }
@@ -80,12 +69,7 @@ export async function startExecution(
80
69
  state.executing = true;
81
70
  state.executionStartIdx = ctx.sessionManager.getEntries().length;
82
71
  pi.setActiveTools(EXEC_TOOLS);
83
- await switchModel(pi, ctx, EXEC_MODEL);
84
- pi.setThinkingLevel(EXEC_THINKING);
85
- ctx.ui.notify(
86
- `Executing plan — ${EXEC_MODEL.provider}/${EXEC_MODEL.id}:${EXEC_THINKING}`,
87
- 'info',
88
- );
72
+ ctx.ui.notify('Executing plan', 'info');
89
73
  updateUI(state, ctx);
90
74
  state.persist(pi);
91
75
  }
@@ -106,10 +106,27 @@ export async function resolveActivePlan(
106
106
  }
107
107
 
108
108
  // ── No hint: in-memory plan, else the single in-progress plan on disk ─────
109
- if (state.plan) return { plan: state.plan, candidates: [] };
110
-
109
+ //
110
+ // IMPORTANT (misfile guard): a pinned `state.plan` must NOT be trusted blindly
111
+ // when it no longer corresponds to an in-progress plan. After a plan completes
112
+ // (e.g. `set_active_plan` then 6/6 done), a stale pin would silently capture
113
+ // writes — or the fallback would guess a different in-progress plan. Mirror
114
+ // `update_task`'s strictness: when the pin is stale AND other plans are still
115
+ // in-progress, REFUSE and force an explicit `{ plan }` rather than guess.
111
116
  const manifest = await runPlanIO(readPlansManifest());
112
117
  const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
118
+
119
+ if (state.plan) {
120
+ const pinName = state.plan.planName;
121
+ const pinInProgress = inProgress.some((entry) => entry.name === pinName);
122
+ const competing = inProgress.filter((entry) => entry.name !== pinName);
123
+ // Trust the pin only when it is still in-progress, or when there is no other
124
+ // in-progress plan to confuse it with (e.g. an in-memory-only plan).
125
+ if (pinInProgress || competing.length === 0) return { plan: state.plan, candidates: [] };
126
+ // Stale pin with live competitors → ambiguous: require an explicit choice.
127
+ return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
128
+ }
129
+
113
130
  if (inProgress.length === 1) {
114
131
  const plan = await attach(state, pi, runPlanIO, inProgress[0]!.name);
115
132
  if (plan) return { plan, candidates: [] };
@@ -4,13 +4,12 @@
4
4
 
5
5
  import type {
6
6
  ExtensionAPI,
7
- ExtensionContext,
8
7
  ExtensionCommandContext,
9
8
  } from '@earendil-works/pi-coding-agent';
10
9
  import type { PlanModeState } from './state.js';
11
10
  import type { PlanData } from './types.js';
12
11
  import type { RunPlanIO } from '@dreki-gg/taskman';
13
- import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
12
+
14
13
  import { readPlansManifest } from '@dreki-gg/taskman';
15
14
  import { loadHandoff } from '@dreki-gg/taskman';
16
15
  import { writeExecPending } from './exec-pending.js';
@@ -18,15 +17,6 @@ import { readTasksJsonl, writeTasksJsonl } from '@dreki-gg/taskman';
18
17
  import { enterPlanMode } from './phase-transitions.js';
19
18
  import { reactivateForExecution } from '@dreki-gg/taskman';
20
19
 
21
- export async function pickExecutionModel(
22
- ctx: ExtensionContext,
23
- ): Promise<{ provider: string; id: string } | undefined> {
24
- const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
25
- const choice = await ctx.ui.select('Execute with:', labels);
26
- if (!choice) return undefined;
27
- return EXEC_MODEL_OPTIONS.find((o) => o.label === choice)?.model;
28
- }
29
-
30
20
  export async function executeInNewSession(
31
21
  ctx: ExtensionCommandContext,
32
22
  runPlanIO: RunPlanIO,
@@ -34,10 +24,12 @@ export async function executeInNewSession(
34
24
  _planData: PlanData,
35
25
  kickoff: string,
36
26
  ): Promise<void> {
37
- const selectedModel = await pickExecutionModel(ctx);
38
- if (!selectedModel) return;
27
+ // Use the current model for execution — no forced model override.
28
+ const currentModel = ctx.model
29
+ ? { provider: ctx.model.provider, id: ctx.model.id }
30
+ : { provider: 'anthropic', id: 'claude-sonnet-4-20250514' };
39
31
 
40
- await runPlanIO(writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING }));
32
+ await runPlanIO(writeExecPending(dir, { model: currentModel, thinking: 'low' }));
41
33
  const parentSession = ctx.sessionManager.getSessionFile();
42
34
 
43
35
  await ctx.newSession({
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
- import type { PlanData, PersistedState, ThinkingLevel } from './types.js';
6
+ import type { PlanData, PersistedState } from './types.js';
7
7
 
8
8
  export class PlanModeState {
9
9
  planEnabled = false;
@@ -11,7 +11,6 @@ export class PlanModeState {
11
11
  planDir: string | undefined;
12
12
  plan: PlanData | undefined;
13
13
  executionStartIdx: number | undefined;
14
- previousThinking: ThinkingLevel | undefined;
15
14
  previousModel: { provider: string; id: string } | undefined;
16
15
 
17
16
  persist(pi: ExtensionAPI): void {
@@ -30,6 +30,7 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
30
30
  'Use add_task when you notice worthwhile work outside the current plan while implementing.',
31
31
  'Discovered tasks are deferred for the user to review — do NOT implement them now; continue with the planned tasks.',
32
32
  'Give a clear reason so the user can decide whether the follow-up is worth doing.',
33
+ 'Always pass an explicit { plan } (it is required) so the write is scoped to the intended plan — never rely on an implicit active-plan pin, which may have completed after a context switch.',
33
34
  ],
34
35
  parameters: Type.Object({
35
36
  description: Type.String({ description: 'Short task label (≤60 chars)' }),
@@ -40,15 +41,23 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
40
41
  Type.String({ description: 'Optional fuller implementation notes for the follow-up' }),
41
42
  ),
42
43
  depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
43
- plan: Type.Optional(
44
- Type.String({
45
- description:
46
- 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress.',
47
- }),
48
- ),
44
+ plan: Type.String({
45
+ description:
46
+ 'Plan name (or .plans/<name>) to target. Required — always scope the capture explicitly so it never lands in the wrong plan.',
47
+ }),
49
48
  }),
50
49
 
51
50
  async execute(_toolCallId, params) {
51
+ // Hard-require an explicit plan: the schema marks it required, but an
52
+ // empty/whitespace value must never fall through to candidate resolution
53
+ // (that silently files the follow-up onto whatever plan happens to be
54
+ // in-progress — e.g. after the intended plan completed on a context
55
+ // switch). Throw so the agent re-issues with an explicit { plan }.
56
+ if (!params.plan || !params.plan.trim()) {
57
+ throw new Error(
58
+ 'add_task requires an explicit { plan } — pass the plan name so the follow-up is never misfiled onto an unrelated in-progress plan.',
59
+ );
60
+ }
52
61
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
53
62
  // No active plan is a tracking miss, not an error — return a soft,
54
63
  // non-terminating result so real work continues.
@@ -83,6 +83,15 @@ export function registerRevisePlanTool(
83
83
  }),
84
84
 
85
85
  async execute(_toolCallId, params) {
86
+ // Hard-require an explicit plan: an empty/whitespace value must never fall
87
+ // through to candidate resolution (that silently rewrites whatever plan
88
+ // happens to be in-progress — e.g. after the intended plan completed on a
89
+ // context switch). Throw so the agent re-issues with an explicit { plan }.
90
+ if (!params.plan || !params.plan.trim()) {
91
+ throw new Error(
92
+ 'revise_plan requires an explicit { plan } — pass the plan name so the rewrite is never applied to an unrelated in-progress plan.',
93
+ );
94
+ }
86
95
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
87
96
  if (!plan) {
88
97
  const notFound: Record<string, unknown> = {
@@ -35,6 +35,7 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
35
35
  'Call update_task after completing each plan task before moving to the next.',
36
36
  'Always include notes summarizing what was done, why skipped, or why blocked.',
37
37
  'Use update_task with status "blocked" and explain the reason in notes if a task cannot be completed.',
38
+ 'Always pass an explicit { plan } (it is required) so the write is scoped to the intended plan — never rely on an implicit active-plan pin, which may have completed after a context switch.',
38
39
  ],
39
40
  parameters: Type.Object({
40
41
  task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
@@ -42,15 +43,22 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
42
43
  notes: Type.Optional(
43
44
  Type.String({ description: 'What was done, why skipped, or why blocked' }),
44
45
  ),
45
- plan: Type.Optional(
46
- Type.String({
47
- description:
48
- 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress; otherwise the active / sole in-progress plan is used.',
49
- }),
50
- ),
46
+ plan: Type.String({
47
+ description:
48
+ 'Plan name (or .plans/<name>) to target. Required — always scope the write explicitly so it never lands in the wrong plan.',
49
+ }),
51
50
  }),
52
51
 
53
52
  async execute(_toolCallId, params) {
53
+ // Hard-require an explicit plan: an empty/whitespace value must never fall
54
+ // through to candidate resolution (that silently writes to whatever plan
55
+ // happens to be in-progress — e.g. after the intended plan completed on a
56
+ // context switch). Throw so the agent re-issues with an explicit { plan }.
57
+ if (!params.plan || !params.plan.trim()) {
58
+ throw new Error(
59
+ 'update_task requires an explicit { plan } — pass the plan name so the write is never applied to an unrelated in-progress plan.',
60
+ );
61
+ }
54
62
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
55
63
  // No active plan is a tracking miss, not an error: return a soft result
56
64
  // (non-terminating) so the agent keeps doing the real work.
@@ -69,15 +69,22 @@ export function registerUpdateTasksTool(pi: ExtensionAPI, callbacks: UpdateTasks
69
69
  }),
70
70
  { description: 'Tasks to mark, each with its own status and notes', minItems: 1 },
71
71
  ),
72
- plan: Type.Optional(
73
- Type.String({
74
- description:
75
- 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress; otherwise the active / sole in-progress plan is used.',
76
- }),
77
- ),
72
+ plan: Type.String({
73
+ description:
74
+ 'Plan name (or .plans/<name>) to target. Required — always scope the write explicitly so it never lands in the wrong plan.',
75
+ }),
78
76
  }),
79
77
 
80
78
  async execute(_toolCallId, params) {
79
+ // Hard-require an explicit plan: an empty/whitespace value must never fall
80
+ // through to candidate resolution (that silently writes to whatever plan
81
+ // happens to be in-progress — e.g. after the intended plan completed on a
82
+ // context switch). Throw so the agent re-issues with an explicit { plan }.
83
+ if (!params.plan || !params.plan.trim()) {
84
+ throw new Error(
85
+ 'update_tasks requires an explicit { plan } — pass the plan name so the writes are never applied to an unrelated in-progress plan.',
86
+ );
87
+ }
81
88
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
82
89
  // No active plan is a tracking miss, not an error: return a soft result
83
90
  // (non-terminating) so the agent keeps doing the real work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.26.2",
3
+ "version": "0.27.3",
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"
@@ -9,12 +9,12 @@
9
9
  "license": "MIT",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/dreki-gg/pi-extensions",
12
+ "url": "git+https://github.com/dreki-gg/pi-extensions.git",
13
13
  "directory": "packages/plan-mode"
14
14
  },
15
15
  "type": "module",
16
16
  "bin": {
17
- "pi-plan-mode": "./bin/clean-plans.js"
17
+ "pi-plan-mode": "bin/clean-plans.js"
18
18
  },
19
19
  "files": [
20
20
  "extensions",