@dreki-gg/pi-plan-mode 0.27.0 → 0.28.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,44 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add a precondition gate that stops "delete X, it's unused" plan drift. Any task
8
+ that deletes, removes, renames, or narrows a codebase symbol/export/file/feature
9
+ must now carry a re-runnable proof of its premise — a `Proof: <command>` line
10
+ (grep/ast-grep over every exported symbol, scoped by feature not directory) or
11
+ an explicit `Precondition: none — <reason>` opt-out. The rule is hoisted to the
12
+ top of the planner prompt and enforced deterministically: `submit_plan` refuses
13
+ to persist a plan whose destructive tasks lack proof, naming the offending task
14
+ ids so you can fix and resubmit. No LLM call, no reviewer side effect. At
15
+ execution time the proof is re-run, and a contradicted premise (live consumers,
16
+ a delete that breaks an import) blocks instead of being silently improvised
17
+ around.
18
+
19
+ ## 0.27.3
20
+
21
+ ### Patch Changes
22
+
23
+ - Require an explicit plan id on plan mutations so writes always target the intended plan.
24
+
25
+ ## 0.27.2
26
+
27
+ ### Patch Changes
28
+
29
+ - `add_task` now rejects an empty or whitespace `plan` argument instead of
30
+ silently filing the follow-up onto whatever plan happens to be in-progress.
31
+ This prevents discovered tasks from being misfiled onto an unrelated plan
32
+ after a context switch — the agent is told to re-issue with an explicit plan.
33
+
34
+ ## 0.27.1
35
+
36
+ ### Patch Changes
37
+
38
+ - 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.
39
+
40
+ 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.
41
+
3
42
  ## 0.27.0
4
43
 
5
44
  ### 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
  });
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ detectPreconditionGaps,
4
+ isDestructiveTaskText,
5
+ hasPreconditionProof,
6
+ formatPreconditionRejection,
7
+ } from '../precondition-guard.js';
8
+
9
+ describe('isDestructiveTaskText', () => {
10
+ test('fires on destructive verb + symbol target', () => {
11
+ expect(isDestructiveTaskText('Remove the `AuthProvider` export')).toBe(true);
12
+ expect(isDestructiveTaskText('Delete the previewCandidatesOp operation')).toBe(true);
13
+ });
14
+
15
+ test('fires on destructive verb + file path', () => {
16
+ expect(isDestructiveTaskText('Delete src/auth/middleware.ts')).toBe(true);
17
+ });
18
+
19
+ test('does NOT fire on destructive verb with no codebase target', () => {
20
+ expect(isDestructiveTaskText('Remove the TODO comment')).toBe(false);
21
+ expect(isDestructiveTaskText('Delete the temporary value we created')).toBe(false);
22
+ });
23
+
24
+ test('does NOT fire on non-destructive task', () => {
25
+ expect(isDestructiveTaskText('Add a new export to the schema module')).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('hasPreconditionProof', () => {
30
+ test('accepts a Precondition: marker (incl. opt-out)', () => {
31
+ expect(hasPreconditionProof('Precondition: none — never imported anywhere')).toBe(true);
32
+ });
33
+ test('accepts a Proof: marker', () => {
34
+ expect(hasPreconditionProof('Proof: ran the grep, zero hits')).toBe(true);
35
+ });
36
+ test('accepts an actual search command', () => {
37
+ expect(hasPreconditionProof('rg "AuthProvider" -l shows no consumers')).toBe(true);
38
+ expect(hasPreconditionProof('ast-grep verifies no callers')).toBe(true);
39
+ });
40
+ test('rejects prose with no proof signal', () => {
41
+ expect(hasPreconditionProof('this is unused so delete it')).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('detectPreconditionGaps', () => {
46
+ test('flags a destructive task lacking proof', () => {
47
+ const gaps = detectPreconditionGaps([
48
+ { id: 't-005', description: 'Delete core viewer schema', details: 'it has zero consumers' },
49
+ ]);
50
+ expect(gaps.map((g) => g.id)).toEqual(['t-005']);
51
+ });
52
+
53
+ test('passes a destructive task WITH a proof command', () => {
54
+ const gaps = detectPreconditionGaps([
55
+ {
56
+ id: 't-005',
57
+ description: 'Delete the `ViewerConfig` export',
58
+ details: 'Proof: rg "ViewerConfig|Viewer3DConfig|ViewerSceneConfig" -l → no hits',
59
+ },
60
+ ]);
61
+ expect(gaps).toHaveLength(0);
62
+ });
63
+
64
+ test('passes a destructive task WITH an explicit opt-out', () => {
65
+ const gaps = detectPreconditionGaps([
66
+ {
67
+ id: 't-003',
68
+ description: 'Remove the legacy preview command',
69
+ details: 'Precondition: none — added this session, never wired to anything',
70
+ },
71
+ ]);
72
+ expect(gaps).toHaveLength(0);
73
+ });
74
+
75
+ test('ignores non-destructive tasks entirely', () => {
76
+ const gaps = detectPreconditionGaps([
77
+ { id: 't-001', description: 'Add auth middleware', details: 'create src/auth/mw.ts' },
78
+ { id: 't-002', description: 'Remove the TODO comment in README' },
79
+ ]);
80
+ expect(gaps).toHaveLength(0);
81
+ });
82
+
83
+ test('reports multiple gaps', () => {
84
+ const gaps = detectPreconditionGaps([
85
+ { id: 't-001', description: 'Delete src/old.ts' },
86
+ { id: 't-002', description: 'Rename the `Foo` interface to Bar' },
87
+ ]);
88
+ expect(gaps.map((g) => g.id)).toEqual(['t-001', 't-002']);
89
+ });
90
+ });
91
+
92
+ describe('formatPreconditionRejection', () => {
93
+ test('names offending ids and the escape hatch', () => {
94
+ const msg = formatPreconditionRejection([{ id: 't-005', matched: 'Delete' }]);
95
+ expect(msg).toContain('t-005');
96
+ expect(msg).toContain('Precondition: none');
97
+ expect(msg).toContain('submit_plan again');
98
+ });
99
+ });
@@ -28,6 +28,13 @@ describe('buildPlanModePrompt', () => {
28
28
  expect(prompt).toContain('STOP conditions');
29
29
  });
30
30
 
31
+ test('hoists the precondition gate and flags it as enforced', () => {
32
+ expect(prompt).toContain('PRECONDITION GATE');
33
+ expect(prompt).toMatch(/submit_plan will REJECT/i);
34
+ expect(prompt).toContain('Precondition: none');
35
+ expect(prompt).toMatch(/FEATURE, not directory/i);
36
+ });
37
+
31
38
  test('mentions handoff instead of context and risks', () => {
32
39
  expect(prompt).toContain('handoff');
33
40
  expect(prompt).not.toContain('- risks:');
@@ -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
  });
@@ -17,7 +17,7 @@ interface SubmitParams {
17
17
  name: string;
18
18
  title: string;
19
19
  handoff: string;
20
- tasks: Array<{ id: string; description: string }>;
20
+ tasks: Array<{ id: string; description: string; details?: string }>;
21
21
  initiative?: string;
22
22
  depends_on_plans?: string[];
23
23
  }
@@ -58,6 +58,47 @@ afterEach(async () => {
58
58
  await rm(dir, { recursive: true, force: true });
59
59
  });
60
60
 
61
+ describe('submit_plan tool — precondition gate', () => {
62
+ test('rejects a destructive task with no proof and persists nothing', async () => {
63
+ const tool = setup();
64
+ const res = await tool.execute(
65
+ 'c',
66
+ baseParams({ tasks: [{ id: 't-001', description: 'Delete the `AuthProvider` export' }] }),
67
+ );
68
+ expect(res.content?.[0]?.text).toContain('precondition gate failed');
69
+ expect((res.details as { rejected?: boolean }).rejected).toBe(true);
70
+ // Nothing written to the registry.
71
+ const entries = await runPlanIO(readPlansManifest());
72
+ expect(entries).toHaveLength(0);
73
+ });
74
+
75
+ test('accepts a destructive task that carries a proof command', async () => {
76
+ const tool = setup();
77
+ const res = await tool.execute(
78
+ 'c',
79
+ baseParams({
80
+ tasks: [
81
+ {
82
+ id: 't-001',
83
+ description: 'Delete the `AuthProvider` export',
84
+ details: 'Proof: rg "AuthProvider" -l → no consumers',
85
+ },
86
+ ],
87
+ }),
88
+ );
89
+ expect((res.details as { rejected?: boolean }).rejected).toBeUndefined();
90
+ const entries = await runPlanIO(readPlansManifest());
91
+ expect(entries).toHaveLength(1);
92
+ });
93
+
94
+ test('accepts a non-destructive task untouched', async () => {
95
+ const tool = setup();
96
+ await tool.execute('c', baseParams());
97
+ const entries = await runPlanIO(readPlansManifest());
98
+ expect(entries).toHaveLength(1);
99
+ });
100
+ });
101
+
61
102
  describe('submit_plan tool — initiative + plan deps', () => {
62
103
  test('persists initiative + depends_on onto the plan manifest entry', async () => {
63
104
  await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
@@ -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
  });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Deterministic precondition guard for submit_plan.
3
+ *
4
+ * Catches "destructive premise without proof" drift at plan-submission time:
5
+ * a task that deletes/removes/renames a codebase symbol/path but carries no
6
+ * re-runnable consumer-proof command (or an explicit, auditable opt-out).
7
+ *
8
+ * Pure string heuristics — NO LLM, no reviewer agent, no I/O. The trigger is
9
+ * intentionally narrow (destructive verb AND a codebase-scoped target) to
10
+ * avoid false-positive fatigue, which is the failure mode that makes guards
11
+ * get ignored.
12
+ */
13
+
14
+ /** A destructive action verb applied to code. */
15
+ const DESTRUCTIVE =
16
+ /\b(delete|deletes|deleting|remove|removes|removing|rename|renames|renaming|drop|drops|dropping|strip|strips|stripping|purge|purges|purging|deprecate|deprecates|deprecating|unregister|unregisters)\b|\b(rip|tear)\s+out\b/i;
17
+
18
+ /**
19
+ * A codebase-scoped target: a backticked identifier, a file path, a known code
20
+ * file extension, or a code-construct noun. Generic words like "file" alone are
21
+ * deliberately excluded — file-ish targets must show up as a path/extension/
22
+ * backtick — to keep "delete the temp file" from tripping the guard.
23
+ */
24
+ const CODEBASE_TARGET =
25
+ /`[^`]+`|\b[\w.-]+\/[\w./-]+|\.(ts|tsx|js|jsx|mjs|cjs|json|md|css|scss|html|py|go|rs|java|rb|sql|ya?ml|toml)\b|\b(export|exports|import|imports|function|class|interface|type|schema|module|component|route|endpoint|operation|command|setting|settings|config|field|enum|const|method|prop|props|symbol|package|namespace|hook|reducer|selector|migration)s?\b/i;
26
+
27
+ /**
28
+ * Proof or auditable opt-out signal. A `Precondition:`/`Proof:` marker (the
29
+ * opt-out form is `Precondition: none — <reason>`), or an actual search command
30
+ * that establishes the consumer set.
31
+ */
32
+ const PROOF = /\b(precondition|proof)\b\s*:|\b(grep|rg|ripgrep|ast-grep|ast_grep)\b/i;
33
+
34
+ export interface PreconditionGap {
35
+ id: string;
36
+ /** The destructive verb phrase that triggered the check. */
37
+ matched: string;
38
+ }
39
+
40
+ /** Returns true when text describes a destructive change to a codebase target. */
41
+ export function isDestructiveTaskText(text: string): boolean {
42
+ return DESTRUCTIVE.test(text) && CODEBASE_TARGET.test(text);
43
+ }
44
+
45
+ /** Returns true when text carries a proof command or an auditable opt-out. */
46
+ export function hasPreconditionProof(text: string): boolean {
47
+ return PROOF.test(text);
48
+ }
49
+
50
+ /**
51
+ * Find tasks that describe a destructive codebase change but carry no proof
52
+ * command or opt-out. Scans description + details together.
53
+ */
54
+ export function detectPreconditionGaps(
55
+ tasks: ReadonlyArray<{ id: string; description: string; details?: string }>,
56
+ ): PreconditionGap[] {
57
+ const gaps: PreconditionGap[] = [];
58
+ for (const task of tasks) {
59
+ const text = `${task.description}\n${task.details ?? ''}`;
60
+ if (!isDestructiveTaskText(text)) continue;
61
+ if (hasPreconditionProof(text)) continue;
62
+ const matched = DESTRUCTIVE.exec(text)?.[0] ?? 'destructive change';
63
+ gaps.push({ id: task.id, matched });
64
+ }
65
+ return gaps;
66
+ }
67
+
68
+ /** Human-readable rejection message for the agent to act on. */
69
+ export function formatPreconditionRejection(gaps: PreconditionGap[]): string {
70
+ const list = gaps.map((g) => ` • ${g.id} ("${g.matched}")`).join('\n');
71
+ return (
72
+ `Plan NOT saved — precondition gate failed for ${gaps.length} destructive ` +
73
+ `task(s):\n${list}\n\n` +
74
+ `Each task above deletes/removes/renames a codebase target but carries no ` +
75
+ `precondition proof. In the task's details, add ONE of:\n` +
76
+ ` 1. A proof command that establishes the consumer set, scoped by feature ` +
77
+ `not directory — e.g. \`Proof: rg "ExportedSymbol" -l\` (run it for EVERY ` +
78
+ `exported symbol being removed, not just the type/feature name), OR\n` +
79
+ ` 2. An explicit, auditable opt-out: \`Precondition: none — <reason it has ` +
80
+ `no consumers>\`.\n\n` +
81
+ `Then call submit_plan again. This is deterministic input validation, not a review.`
82
+ );
83
+ }
@@ -21,6 +21,8 @@ Your job is to reach shared understanding before formalizing a plan:
21
21
  3. Maintain a living .plans/<plan-name>/context.md as you converge — the planning-context skill covers what to capture and how.
22
22
  4. Only call submit_plan after the user and agent have converged on the approach.
23
23
 
24
+ PRECONDITION GATE — blast-radius awareness (anti-rot). This is enforced: submit_plan will REJECT the plan if you skip it. Any task that deletes, removes, renames, or narrows a symbol, export, file, or feature must carry a re-runnable proof of its premise in the task's details. Record the exact command (grep / ast-grep over EVERY exported symbol name being removed — not just the type or feature name) that establishes the consumer set, written as a \`Proof: <command>\` line, plus the expected result. Scope by FEATURE, not directory: follow each symbol upward to its callers, operations, commands, and settings — a "delete the X dir" task that never traces X's consumers is incomplete and will rot. If you genuinely believe there are no consumers, state it as an auditable opt-out: \`Precondition: none — <reason>\`. The executor RE-RUNS this proof; if reality contradicts it, the executor blocks instead of improvising.
25
+
24
26
  When you are ready to finalize the plan, call submit_plan with:
25
27
  - name: a short kebab-case name (e.g. "add-auth-middleware")
26
28
  - title: a human-readable plan title
@@ -31,6 +33,8 @@ Plan weight:
31
33
  - **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
34
  - **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
35
 
36
+ The verification gate proves success *after* a task; the **precondition gate** (above) proves a destructive task's *premise before* acting on it. Both are required where they apply — the precondition gate is enforced by submit_plan.
37
+
34
38
  submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
35
39
 
36
40
  Sizing the work — flat plan vs initiative:
@@ -73,6 +77,7 @@ Rules:
73
77
  - Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
74
78
  - Do NOT explore the codebase beyond what the current task requires
75
79
  - Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
80
+ - If a destructive task names a precondition (e.g. "X is unused", "no consumers of Y"), **re-run its proof command FIRST**. If reality contradicts the task — the symbol still has live consumers, the delete would break an import — **STOP and call update_task status "blocked"** with what you found. Do NOT rename, stub, comment out, or otherwise improvise to make the delete "work". Silently improvising around a contradicted premise is the exact failure this rule exists to stop.
76
81
  - If you notice worthwhile work OUTSIDE the current plan, call add_task to capture it as a deferred follow-up, then keep going. Do NOT implement discovered work in this run — the user reviews follow-ups later via /plan resume.
77
82
 
78
83
  ## Current task
@@ -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> = {
@@ -14,6 +14,7 @@ import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
14
14
  import type { RunPlanIO } from '@dreki-gg/taskman';
15
15
  import { toKebabCase } from '@dreki-gg/taskman';
16
16
  import { readHeadCommit } from '../git.js';
17
+ import { detectPreconditionGaps, formatPreconditionRejection } from '../precondition-guard.js';
17
18
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
18
19
 
19
20
  export interface SubmitPlanCallbacks {
@@ -78,6 +79,18 @@ export function registerSubmitPlanTool(
78
79
  }),
79
80
 
80
81
  async execute(_toolCallId, params) {
82
+ // Precondition gate (deterministic, no LLM): reject destructive tasks that
83
+ // carry no proof command or auditable opt-out. Nothing is persisted on
84
+ // rejection — the agent fixes the tasks and re-calls submit_plan.
85
+ const gaps = detectPreconditionGaps(params.tasks);
86
+ if (gaps.length > 0) {
87
+ const details: Record<string, unknown> = { rejected: true, preconditionGaps: gaps };
88
+ return {
89
+ content: [{ type: 'text' as const, text: formatPreconditionRejection(gaps) }],
90
+ details,
91
+ };
92
+ }
93
+
81
94
  const planName = toKebabCase(params.name);
82
95
  const planDir = `.plans/${planName}`;
83
96
  const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
@@ -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.28.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"
@@ -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",
@@ -28,6 +28,7 @@ As soon as you understand the intent, write `.plans/<plan-name>/context.md` with
28
28
  - **Constraints** — technical, product, or process limits that shape the work
29
29
  - **Open questions** — anything unresolved; do not submit a plan with silent unknowns
30
30
  - **Discarded options** — approaches considered and rejected, with why. This is the highest-value section and the one most often skipped.
31
+ - **Blast radius** (when the plan deletes/removes/renames/narrows anything) — for every symbol, export, file, or feature being removed, record the exact proof command (grep / ast-grep over *every* exported symbol name, not just the type or feature name) and its result. Scope by **feature, not directory**: trace each symbol upward to its callers, operations, commands, and settings. This evidence is what turns "delete, it's unused" from an assertion into a precondition the executor can re-run — see the precondition gate in the plan-mode prompt. A removal task without it will rot.
31
32
 
32
33
  ### 3. Style
33
34