@dreki-gg/pi-plan-mode 0.27.0 → 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,28 @@
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
+
3
26
  ## 0.27.0
4
27
 
5
28
  ### Minor 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
  });
@@ -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: [] };
@@ -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.27.0",
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",