@dreki-gg/pi-plan-mode 0.27.3 → 0.29.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,46 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ffde28d: Plan into another project's `.plans/` and stop trusting in-memory plan status.
8
+
9
+ - `submit_plan`, `revise_plan`, and `add_task` now accept an optional `target`
10
+ pointing at another repo's root. When set, the plan is filed into that
11
+ project's `.plans/` registry — handy when you're dogfooding project A, hit a
12
+ gap in package B (which you author), and want the plan to live and later
13
+ execute in B. Author-only: an external plan is never pinned as the current
14
+ session's active plan, and a missing/invalid target is rejected up front.
15
+ - The plan-mode status bar no longer renders progress counts (`📋 2/5`) or mode
16
+ badges from in-memory state — that data drifted from disk. The agent reads
17
+ real status from the ledger via `plan_status` instead.
18
+ - `taskman` exposes `makeNodeFileSystemService(root)` and a `root` parameter on
19
+ `makePlanRuntime` / `makeRuntimeLayer`, so the whole `.plans/` registry can be
20
+ rooted at any working directory. Default behaviour (current working directory)
21
+ is unchanged.
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [ffde28d]
26
+ - @dreki-gg/taskman@0.4.0
27
+
28
+ ## 0.28.0
29
+
30
+ ### Minor Changes
31
+
32
+ - Add a precondition gate that stops "delete X, it's unused" plan drift. Any task
33
+ that deletes, removes, renames, or narrows a codebase symbol/export/file/feature
34
+ must now carry a re-runnable proof of its premise — a `Proof: <command>` line
35
+ (grep/ast-grep over every exported symbol, scoped by feature not directory) or
36
+ an explicit `Precondition: none — <reason>` opt-out. The rule is hoisted to the
37
+ top of the planner prompt and enforced deterministically: `submit_plan` refuses
38
+ to persist a plan whose destructive tasks lack proof, naming the offending task
39
+ ids so you can fix and resubmit. No LLM call, no reviewer side effect. At
40
+ execution time the proof is re-run, and a contradicted premise (live consumers,
41
+ a delete that breaks an import) blocks instead of being silently improvised
42
+ around.
43
+
3
44
  ## 0.27.3
4
45
 
5
46
  ### Patch Changes
@@ -0,0 +1,133 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime, writeTasksJsonl, upsertPlanEntry } from '@dreki-gg/taskman';
7
+ import type { TaskMeta, TaskRecord } from '@dreki-gg/taskman';
8
+ import { registerRevisePlanTool } from '../tools/revise-plan.js';
9
+ import { registerAddTaskTool } from '../tools/add-task.js';
10
+
11
+ const now = '2026-05-27T12:00:00.000Z';
12
+
13
+ interface CapturedTool {
14
+ execute: (
15
+ id: string,
16
+ params: Record<string, unknown>,
17
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
18
+ }
19
+
20
+ function captureTool(register: (pi: unknown) => void): CapturedTool {
21
+ let tool: CapturedTool | undefined;
22
+ register({ registerTool: (config: CapturedTool) => (tool = config) });
23
+ return tool!;
24
+ }
25
+
26
+ /** Seed an in-progress plan with one done task into a target repo's .plans/. */
27
+ async function seedPlan(targetDir: string): Promise<void> {
28
+ const io = makePlanRuntime(targetDir);
29
+ const meta: TaskMeta = { _type: 'meta', title: 'Gap', plan_name: 'gap', created_at: now };
30
+ const task: TaskRecord = {
31
+ _type: 'task',
32
+ id: 't-001',
33
+ description: 'done work',
34
+ details: '',
35
+ status: 'done',
36
+ origin: 'plan',
37
+ created_at: now,
38
+ updated_at: now,
39
+ };
40
+ await io(writeTasksJsonl('.plans/gap', meta, [task]));
41
+ await io(upsertPlanEntry('gap', { status: 'in-progress', title: 'Gap' }));
42
+ }
43
+
44
+ const originalCwd = process.cwd();
45
+ let cwdDir: string;
46
+ let targetDir: string;
47
+
48
+ beforeEach(async () => {
49
+ cwdDir = await mkdtemp(join(tmpdir(), 'plan-mode-ext-cwd-'));
50
+ targetDir = await mkdtemp(join(tmpdir(), 'plan-mode-ext-target-'));
51
+ chdir(cwdDir);
52
+ await seedPlan(targetDir);
53
+ });
54
+ afterEach(async () => {
55
+ chdir(originalCwd);
56
+ await rm(cwdDir, { recursive: true, force: true });
57
+ await rm(targetDir, { recursive: true, force: true });
58
+ });
59
+
60
+ describe('revise_plan — external target', () => {
61
+ test('rewrites the plan in the target repo and does not pin session state', async () => {
62
+ let pinned = false;
63
+ const tool = captureTool((pi) =>
64
+ registerRevisePlanTool(pi as never, makePlanRuntime(), {
65
+ resolvePlan: async () => ({ plan: undefined, candidates: [] }),
66
+ onPlanRevised: () => {
67
+ pinned = true;
68
+ },
69
+ }),
70
+ );
71
+
72
+ const res = await tool.execute('c', {
73
+ plan: 'gap',
74
+ title: 'Gap (revised)',
75
+ target: targetDir,
76
+ });
77
+
78
+ const manifest = await readFile(join(targetDir, '.plans', 'plans.jsonl'), 'utf-8');
79
+ expect(manifest).toContain('Gap (revised)');
80
+ expect(res.content?.[0]?.text).toMatch(/revised/);
81
+ expect((res.details as { target?: string }).target).toBe(targetDir);
82
+ expect(pinned).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe('add_task — external target', () => {
87
+ test('appends a deferred task to the target plan, bypassing session callbacks', async () => {
88
+ let sessionCalled = false;
89
+ const tool = captureTool((pi) =>
90
+ registerAddTaskTool(pi as never, {
91
+ resolvePlan: async () => {
92
+ sessionCalled = true;
93
+ return { plan: undefined, candidates: [] };
94
+ },
95
+ onTaskAdded: () => {
96
+ sessionCalled = true;
97
+ },
98
+ }),
99
+ );
100
+
101
+ const res = await tool.execute('c', {
102
+ description: 'Fix the gap edge case',
103
+ reason: 'found while dogfooding',
104
+ plan: 'gap',
105
+ target: targetDir,
106
+ });
107
+
108
+ const tasks = await readFile(join(targetDir, '.plans', 'gap', 'tasks.jsonl'), 'utf-8');
109
+ expect(tasks).toContain('Fix the gap edge case');
110
+ expect(tasks).toContain('deferred');
111
+ expect(res.content?.[0]?.text).toMatch(/Captured follow-up t-002/);
112
+ expect(sessionCalled).toBe(false);
113
+ });
114
+
115
+ test('soft-skips when the plan does not exist in the target', async () => {
116
+ const tool = captureTool((pi) =>
117
+ registerAddTaskTool(pi as never, {
118
+ resolvePlan: async () => ({ plan: undefined, candidates: [] }),
119
+ onTaskAdded: () => {},
120
+ }),
121
+ );
122
+
123
+ const res = await tool.execute('c', {
124
+ description: 'x',
125
+ reason: 'y',
126
+ plan: 'nonexistent',
127
+ target: targetDir,
128
+ });
129
+
130
+ expect(res.content?.[0]?.text).toMatch(/plan not found/i);
131
+ expect((res.details as { skipped?: boolean }).skipped).toBe(true);
132
+ });
133
+ });
@@ -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:');
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
2
  import { chdir } from 'node:process';
3
- import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { mkdtemp, rm, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { makePlanRuntime } from '@dreki-gg/taskman';
@@ -17,9 +17,10 @@ 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
+ target?: string;
23
24
  }
24
25
  interface CapturedTool {
25
26
  execute: (
@@ -28,14 +29,14 @@ interface CapturedTool {
28
29
  ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
29
30
  }
30
31
 
31
- function setup(): CapturedTool {
32
+ function setup(onPlanSubmitted: () => void = () => {}): CapturedTool {
32
33
  let tool: CapturedTool | undefined;
33
34
  const pi = {
34
35
  registerTool: (config: CapturedTool) => {
35
36
  tool = config;
36
37
  },
37
38
  } as unknown as Parameters<typeof registerSubmitPlanTool>[0];
38
- registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted: () => {} });
39
+ registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted });
39
40
  return tool!;
40
41
  }
41
42
 
@@ -58,6 +59,47 @@ afterEach(async () => {
58
59
  await rm(dir, { recursive: true, force: true });
59
60
  });
60
61
 
62
+ describe('submit_plan tool — precondition gate', () => {
63
+ test('rejects a destructive task with no proof and persists nothing', async () => {
64
+ const tool = setup();
65
+ const res = await tool.execute(
66
+ 'c',
67
+ baseParams({ tasks: [{ id: 't-001', description: 'Delete the `AuthProvider` export' }] }),
68
+ );
69
+ expect(res.content?.[0]?.text).toContain('precondition gate failed');
70
+ expect((res.details as { rejected?: boolean }).rejected).toBe(true);
71
+ // Nothing written to the registry.
72
+ const entries = await runPlanIO(readPlansManifest());
73
+ expect(entries).toHaveLength(0);
74
+ });
75
+
76
+ test('accepts a destructive task that carries a proof command', async () => {
77
+ const tool = setup();
78
+ const res = await tool.execute(
79
+ 'c',
80
+ baseParams({
81
+ tasks: [
82
+ {
83
+ id: 't-001',
84
+ description: 'Delete the `AuthProvider` export',
85
+ details: 'Proof: rg "AuthProvider" -l → no consumers',
86
+ },
87
+ ],
88
+ }),
89
+ );
90
+ expect((res.details as { rejected?: boolean }).rejected).toBeUndefined();
91
+ const entries = await runPlanIO(readPlansManifest());
92
+ expect(entries).toHaveLength(1);
93
+ });
94
+
95
+ test('accepts a non-destructive task untouched', async () => {
96
+ const tool = setup();
97
+ await tool.execute('c', baseParams());
98
+ const entries = await runPlanIO(readPlansManifest());
99
+ expect(entries).toHaveLength(1);
100
+ });
101
+ });
102
+
61
103
  describe('submit_plan tool — initiative + plan deps', () => {
62
104
  test('persists initiative + depends_on onto the plan manifest entry', async () => {
63
105
  await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
@@ -95,3 +137,42 @@ describe('submit_plan tool — initiative + plan deps', () => {
95
137
  expect(result.content?.[0]?.text).not.toMatch(/initiative/i);
96
138
  });
97
139
  });
140
+
141
+ describe('submit_plan tool — external target', () => {
142
+ test('files the plan into the target repo, not cwd, and does not pin session state', async () => {
143
+ const targetDir = await mkdtemp(join(tmpdir(), 'plan-mode-target-'));
144
+ try {
145
+ let pinned = false;
146
+ const tool = setup(() => {
147
+ pinned = true;
148
+ });
149
+
150
+ const result = await tool.execute('c', baseParams({ target: targetDir }));
151
+
152
+ // Plan landed in the target repo's registry.
153
+ const targetManifest = await readFile(join(targetDir, '.plans', 'plans.jsonl'), 'utf-8');
154
+ expect(targetManifest).toContain('auth-jwt');
155
+ const tasks = await readFile(join(targetDir, '.plans', 'auth-jwt', 'tasks.jsonl'), 'utf-8');
156
+ expect(tasks).toContain('t-001');
157
+
158
+ // Nothing leaked into the current working directory.
159
+ await expect(
160
+ readFile(join(dir, '.plans', 'plans.jsonl'), 'utf-8'),
161
+ ).rejects.toThrow();
162
+
163
+ // Author-only: the active-plan callback is NOT invoked for external targets.
164
+ expect(pinned).toBe(false);
165
+ expect(result.content?.[0]?.text).toMatch(/filed into/);
166
+ expect((result.details as { target?: string }).target).toBe(targetDir);
167
+ } finally {
168
+ await rm(targetDir, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test('rejects a target directory that does not exist', async () => {
173
+ const tool = setup();
174
+ await expect(
175
+ tool.execute('c', baseParams({ target: join(dir, 'does-not-exist') })),
176
+ ).rejects.toThrow(/does not exist/);
177
+ });
178
+ });
@@ -0,0 +1,73 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join, relative } from 'node:path';
5
+ import { mkdir, writeFile as writeFileFs } from 'node:fs/promises';
6
+ import {
7
+ resolvePlanTarget,
8
+ assertTargetReceived,
9
+ assertTargetTaskAppended,
10
+ } from '../target.js';
11
+
12
+ let dir: string;
13
+
14
+ beforeEach(async () => {
15
+ dir = await mkdtemp(join(tmpdir(), 'plan-target-'));
16
+ });
17
+ afterEach(async () => {
18
+ await rm(dir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe('resolvePlanTarget', () => {
22
+ test('returns undefined for absent / empty / whitespace input', async () => {
23
+ expect(await resolvePlanTarget(undefined)).toBeUndefined();
24
+ expect(await resolvePlanTarget('')).toBeUndefined();
25
+ expect(await resolvePlanTarget(' ')).toBeUndefined();
26
+ });
27
+
28
+ test('resolves an existing absolute directory', async () => {
29
+ expect(await resolvePlanTarget(dir)).toBe(dir);
30
+ });
31
+
32
+ test('resolves a relative path against cwd', async () => {
33
+ const rel = relative(process.cwd(), dir);
34
+ expect(await resolvePlanTarget(rel)).toBe(dir);
35
+ });
36
+
37
+ test('expands a leading ~ to the home directory', async () => {
38
+ expect(await resolvePlanTarget('~')).toBe(homedir());
39
+ });
40
+
41
+ test('throws when the target does not exist', async () => {
42
+ await expect(resolvePlanTarget(join(dir, 'nope'))).rejects.toThrow(/does not exist/);
43
+ });
44
+
45
+ test('throws when the target is a file, not a directory', async () => {
46
+ const file = join(dir, 'file.txt');
47
+ await writeFile(file, 'x');
48
+ await expect(resolvePlanTarget(file)).rejects.toThrow(/not a directory/);
49
+ });
50
+ });
51
+
52
+ describe('stale-target guards', () => {
53
+ test('assertTargetReceived throws when the target tasks file is absent', async () => {
54
+ await expect(assertTargetReceived(dir, '.plans/gap')).rejects.toThrow(
55
+ /does not support external targets/,
56
+ );
57
+ });
58
+
59
+ test('assertTargetReceived passes when the target tasks file exists', async () => {
60
+ await mkdir(join(dir, '.plans', 'gap'), { recursive: true });
61
+ await writeFileFs(join(dir, '.plans', 'gap', 'tasks.jsonl'), '{"_type":"meta"}\n');
62
+ await expect(assertTargetReceived(dir, '.plans/gap')).resolves.toBeUndefined();
63
+ });
64
+
65
+ test('assertTargetTaskAppended throws when the task id is not in the target file', async () => {
66
+ await mkdir(join(dir, '.plans', 'gap'), { recursive: true });
67
+ await writeFileFs(join(dir, '.plans', 'gap', 'tasks.jsonl'), '{"id":"t-001"}\n');
68
+ await expect(assertTargetTaskAppended(dir, '.plans/gap', 't-002')).rejects.toThrow(
69
+ /external targets/,
70
+ );
71
+ await expect(assertTargetTaskAppended(dir, '.plans/gap', 't-001')).resolves.toBeUndefined();
72
+ });
73
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { updateUI } from '../ui.js';
3
+ import { PlanModeState } from '../state.js';
4
+ import type { PlanData, TaskRecord } from '../types.js';
5
+
6
+ function makePlan(): PlanData {
7
+ const done: TaskRecord = {
8
+ _type: 'task',
9
+ id: 't-001',
10
+ description: 'Done work',
11
+ details: '',
12
+ status: 'done',
13
+ created_at: '2026-01-01T00:00:00Z',
14
+ updated_at: '2026-01-01T00:00:00Z',
15
+ };
16
+ const pending: TaskRecord = { ...done, id: 't-002', status: 'pending' };
17
+ return { title: 'T', planName: 'test', handoff: '# H', tasks: [done, pending] };
18
+ }
19
+
20
+ function makeCtx() {
21
+ const statusCalls: Array<[string, unknown]> = [];
22
+ const widgetCalls: Array<[string, unknown]> = [];
23
+ const ctx = {
24
+ ui: {
25
+ theme: { fg: (_role: string, text: string) => text },
26
+ setStatus: (id: string, value: unknown) => statusCalls.push([id, value]),
27
+ setWidget: (id: string, value: unknown) => widgetCalls.push([id, value]),
28
+ },
29
+ } as unknown as Parameters<typeof updateUI>[1];
30
+ return { ctx, statusCalls, widgetCalls };
31
+ }
32
+
33
+ describe('updateUI', () => {
34
+ test('never renders plan progress from memory — status is always cleared', () => {
35
+ const cases: Array<Partial<PlanModeState>> = [
36
+ { executing: true, plan: makePlan() },
37
+ { executing: false, planEnabled: false, plan: makePlan() },
38
+ { planEnabled: true },
39
+ {},
40
+ ];
41
+
42
+ for (const overrides of cases) {
43
+ const state = Object.assign(new PlanModeState(), overrides);
44
+ const { ctx, statusCalls, widgetCalls } = makeCtx();
45
+
46
+ updateUI(state, ctx);
47
+
48
+ expect(statusCalls).toEqual([['plan-mode', undefined]]);
49
+ expect(widgetCalls).toEqual([['plan-todos', undefined]]);
50
+ }
51
+ });
52
+ });
@@ -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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Resolve an optional external `target` working directory for the write tools
3
+ * (submit_plan / revise_plan / add_task).
4
+ *
5
+ * The use-case: while working in repo A you discover a gap in package B (which
6
+ * you author) and want to file the plan straight into B's `.plans/` registry so
7
+ * it becomes a first-class local plan there. The `target` points at B's repo
8
+ * root (NOT its `.plans/` dir).
9
+ *
10
+ * Returns `undefined` when no target is given (caller uses the default,
11
+ * cwd-bound runtime). When a target is given it is expanded (`~`), resolved to
12
+ * an absolute path, and validated to be an existing directory — a missing or
13
+ * non-directory target throws so we never silently create `.plans/` inside a
14
+ * typo'd path.
15
+ */
16
+
17
+ import { homedir } from 'node:os';
18
+ import { isAbsolute, resolve } from 'node:path';
19
+ import { readFile, stat } from 'node:fs/promises';
20
+
21
+ /** Expand a leading `~` / `~/` to the user's home directory. */
22
+ function expandHome(input: string): string {
23
+ if (input === '~') return homedir();
24
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
25
+ return resolve(homedir(), input.slice(2));
26
+ }
27
+ return input;
28
+ }
29
+
30
+ /**
31
+ * Resolve and validate an external target directory.
32
+ *
33
+ * @returns the absolute target dir, or `undefined` when `target` is empty/absent.
34
+ * @throws when the resolved path does not exist or is not a directory.
35
+ */
36
+ export async function resolvePlanTarget(target?: string): Promise<string | undefined> {
37
+ const trimmed = target?.trim();
38
+ if (!trimmed) return undefined;
39
+
40
+ const expanded = expandHome(trimmed);
41
+ const absolute = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
42
+
43
+ let stats;
44
+ try {
45
+ stats = await stat(absolute);
46
+ } catch {
47
+ throw new Error(
48
+ `target directory does not exist: ${absolute}. Pass the repo root of the project whose .plans/ should receive this plan.`,
49
+ );
50
+ }
51
+ if (!stats.isDirectory()) {
52
+ throw new Error(`target is not a directory: ${absolute}. Pass the repo root, not a file.`);
53
+ }
54
+ return absolute;
55
+ }
56
+
57
+ /**
58
+ * Verify that an external-target write actually landed under `targetDir`.
59
+ *
60
+ * Routing to an external root relies on the loaded `@dreki-gg/taskman` honoring
61
+ * the `root` argument to `makePlanRuntime`. An OLD/stale taskman build silently
62
+ * ignores it and writes to the current working directory instead — reporting
63
+ * success while misfiling the plan. This converts that silent fallback into a
64
+ * loud, actionable failure.
65
+ *
66
+ * @throws when `<targetDir>/<planDir>/tasks.jsonl` was not created.
67
+ */
68
+ export async function assertTargetReceived(targetDir: string, planDir: string): Promise<void> {
69
+ const marker = resolve(targetDir, planDir, 'tasks.jsonl');
70
+ let body: string;
71
+ try {
72
+ body = await readFile(marker, 'utf-8');
73
+ } catch {
74
+ throw staleTargetError(targetDir, marker);
75
+ }
76
+ if (body.length === 0) throw staleTargetError(targetDir, marker);
77
+ }
78
+
79
+ /**
80
+ * Like {@link assertTargetReceived} but for an append: confirm the freshly
81
+ * written `taskId` is actually present in the target plan's tasks file. Reads
82
+ * the file directly (not via a runtime) so a stale taskman cannot mask the
83
+ * fallback by reading from cwd.
84
+ */
85
+ export async function assertTargetTaskAppended(
86
+ targetDir: string,
87
+ planDir: string,
88
+ taskId: string,
89
+ ): Promise<void> {
90
+ const marker = resolve(targetDir, planDir, 'tasks.jsonl');
91
+ let body = '';
92
+ try {
93
+ body = await readFile(marker, 'utf-8');
94
+ } catch {
95
+ throw staleTargetError(targetDir, marker);
96
+ }
97
+ if (!body.includes(`"${taskId}"`)) throw staleTargetError(targetDir, marker);
98
+ }
99
+
100
+ function staleTargetError(targetDir: string, marker: string): Error {
101
+ return new Error(
102
+ `External-target write did not land in ${targetDir} (${marker} missing or unchanged). ` +
103
+ 'The loaded @dreki-gg/taskman build does not support external targets (root param) — ' +
104
+ 'restart pi so it reloads the rebuilt taskman, then retry. ' +
105
+ "Note: the write may have gone to the current project's .plans/ instead — check and clean up there.",
106
+ );
107
+ }
@@ -9,9 +9,17 @@
9
9
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
10
  import { Text } from '@earendil-works/pi-tui';
11
11
  import { Type } from 'typebox';
12
+ import { Effect as E } from 'effect';
12
13
  import type { TaskRecord } from '../types.js';
13
14
  import type { ResolvedPlan } from '../resolve-plan.js';
14
- import { nextTaskId } from '@dreki-gg/taskman';
15
+ import {
16
+ nextTaskId,
17
+ makePlanRuntime,
18
+ resolvePlanByName,
19
+ appendDeferredTask,
20
+ loadPlanData,
21
+ } from '@dreki-gg/taskman';
22
+ import { resolvePlanTarget, assertTargetTaskAppended } from '../target.js';
15
23
 
16
24
  export interface AddTaskCallbacks {
17
25
  /** Resolve the active plan, attaching from disk when none is in memory. */
@@ -45,6 +53,12 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
45
53
  description:
46
54
  'Plan name (or .plans/<name>) to target. Required — always scope the capture explicitly so it never lands in the wrong plan.',
47
55
  }),
56
+ target: Type.Optional(
57
+ Type.String({
58
+ description:
59
+ "Optional path to ANOTHER project's repo root whose .plans/ holds the plan. Use when capturing a follow-up against a plan you filed into a package you author. The plan must already exist there.",
60
+ }),
61
+ ),
48
62
  }),
49
63
 
50
64
  async execute(_toolCallId, params) {
@@ -58,6 +72,54 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
58
72
  'add_task requires an explicit { plan } — pass the plan name so the follow-up is never misfiled onto an unrelated in-progress plan.',
59
73
  );
60
74
  }
75
+ // External target: resolve + append against that repo's .plans/ via a
76
+ // target-scoped runtime, bypassing the cwd-bound session callbacks.
77
+ const targetDir = await resolvePlanTarget(params.target);
78
+ if (targetDir) {
79
+ const io = makePlanRuntime(targetDir);
80
+ const resolved = await io(resolvePlanByName({ name: params.plan }));
81
+ if (!resolved.planName || !resolved.planDir) {
82
+ const hint = resolved.candidates.length
83
+ ? ` In-progress in ${targetDir}: ${resolved.candidates.join(', ')}.`
84
+ : ` No such plan in ${targetDir}/.plans/.`;
85
+ return {
86
+ content: [
87
+ { type: 'text' as const, text: `Skipped follow-up capture — plan not found.${hint}` },
88
+ ],
89
+ details: { skipped: true, candidates: resolved.candidates, target: targetDir },
90
+ };
91
+ }
92
+ const { task, deferred } = await io(
93
+ E.gen(function* () {
94
+ const added = yield* appendDeferredTask(resolved.planDir!, {
95
+ description: params.description,
96
+ reason: params.reason,
97
+ details: params.details,
98
+ depends_on: params.depends_on,
99
+ });
100
+ const reloaded = yield* loadPlanData(resolved.planDir!);
101
+ const count = reloaded?.tasks.filter((t) => t.status === 'deferred').length ?? 1;
102
+ return { task: added, deferred: count };
103
+ }),
104
+ );
105
+ // Fail loudly if an old/stale taskman silently appended to cwd instead.
106
+ await assertTargetTaskAppended(targetDir, resolved.planDir!, task.id);
107
+ return {
108
+ content: [
109
+ {
110
+ type: 'text' as const,
111
+ text: `Captured follow-up ${task.id}: ${task.description} into ${targetDir}/${resolved.planDir} (deferred for review). ${deferred} follow-up(s) pending there. Continue with the planned tasks — do not implement this now.`,
112
+ },
113
+ ],
114
+ details: {
115
+ task_id: task.id,
116
+ description: task.description,
117
+ reason: params.reason,
118
+ target: targetDir,
119
+ },
120
+ };
121
+ }
122
+
61
123
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
62
124
  // No active plan is a tracking miss, not an error — return a soft,
63
125
  // non-terminating result so real work continues.
@@ -26,7 +26,10 @@ import {
26
26
  import { reconcileInitiativeForPlan, reconcileInitiativeStatus } from '@dreki-gg/taskman';
27
27
  import { isPlanFinalizable } from '@dreki-gg/taskman';
28
28
  import { toKebabCase } from '@dreki-gg/taskman';
29
+ import { makePlanRuntime, resolvePlanByName, loadPlanData } from '@dreki-gg/taskman';
29
30
  import type { RunPlanIO } from '@dreki-gg/taskman';
31
+ import { Effect as E } from 'effect';
32
+ import { resolvePlanTarget, assertTargetReceived } from '../target.js';
30
33
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
31
34
 
32
35
  export interface RevisePlanCallbacks {
@@ -53,6 +56,12 @@ export function registerRevisePlanTool(
53
56
  ],
54
57
  parameters: Type.Object({
55
58
  plan: Type.String({ description: 'Plan name (or .plans/<name>) to revise' }),
59
+ target: Type.Optional(
60
+ Type.String({
61
+ description:
62
+ "Optional path to ANOTHER project's repo root whose .plans/ holds the plan being revised. Use when the plan was filed into a package you author. Author-only: the revised plan is NOT pinned as this session's active plan.",
63
+ }),
64
+ ),
56
65
  title: Type.Optional(Type.String({ description: 'New human-readable plan title' })),
57
66
  handoff: Type.Optional(Type.String({ description: 'New markdown content for HANDOFF.md' })),
58
67
  tasks: Type.Optional(
@@ -92,7 +101,23 @@ export function registerRevisePlanTool(
92
101
  'revise_plan requires an explicit { plan } — pass the plan name so the rewrite is never applied to an unrelated in-progress plan.',
93
102
  );
94
103
  }
95
- const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
104
+ // Resolve an optional external target. When set, resolve + rewrite the
105
+ // plan against that repo's .plans/ via a target-scoped runtime, and do
106
+ // not touch this session's active-plan state (author-only).
107
+ const targetDir = await resolvePlanTarget(params.target);
108
+ const io: RunPlanIO = targetDir ? makePlanRuntime(targetDir) : runPlanIO;
109
+
110
+ const { plan, candidates } = targetDir
111
+ ? await io(
112
+ E.gen(function* () {
113
+ const resolved = yield* resolvePlanByName({ name: params.plan });
114
+ if (!resolved.planName || !resolved.planDir)
115
+ return { plan: undefined, candidates: resolved.candidates };
116
+ const loaded = yield* loadPlanData(resolved.planDir);
117
+ return { plan: loaded, candidates: [] as string[] };
118
+ }),
119
+ )
120
+ : await callbacks.resolvePlan({ name: params.plan });
96
121
  if (!plan) {
97
122
  const notFound: Record<string, unknown> = {
98
123
  error: 'not_found',
@@ -152,7 +177,7 @@ export function registerRevisePlanTool(
152
177
  const newInitiative = params.initiative ? toKebabCase(params.initiative) : undefined;
153
178
  const newDependsOn = params.depends_on_plans?.map(toKebabCase);
154
179
 
155
- await runPlanIO(
180
+ await io(
156
181
  Effect.gen(function* () {
157
182
  yield* writeTasksJsonl(planDir, meta, tasks);
158
183
  yield* saveHandoff(planDir, newHandoff);
@@ -176,21 +201,27 @@ export function registerRevisePlanTool(
176
201
  }),
177
202
  );
178
203
 
179
- callbacks.onPlanRevised(planDir, revised);
204
+ // Fail loudly if an old/stale taskman silently routed the write to cwd.
205
+ if (targetDir) await assertTargetReceived(targetDir, planDir);
206
+
207
+ // Author-only: only re-pin the active plan when revising in the current
208
+ // project (an external plan stays a local plan in its own repo).
209
+ if (!targetDir) callbacks.onPlanRevised(planDir, revised);
180
210
 
181
211
  const changed = [
182
212
  params.title ? 'title' : undefined,
183
213
  params.handoff ? 'handoff' : undefined,
184
214
  params.tasks ? 'tasks' : undefined,
185
215
  ].filter(Boolean);
216
+ const location = targetDir ? `${targetDir}/${planDir}` : planDir;
186
217
  return {
187
218
  content: [
188
219
  {
189
220
  type: 'text' as const,
190
- text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${planDir}.`,
221
+ text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${location}.`,
191
222
  },
192
223
  ],
193
- details: { planDir, plan: revised, changed },
224
+ details: { planDir, plan: revised, changed, target: targetDir },
194
225
  };
195
226
  },
196
227
 
@@ -12,8 +12,11 @@ import { upsertPlanEntry } from '@dreki-gg/taskman';
12
12
  import { readInitiativesManifest } from '@dreki-gg/taskman';
13
13
  import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
14
14
  import type { RunPlanIO } from '@dreki-gg/taskman';
15
+ import { makePlanRuntime } from '@dreki-gg/taskman';
15
16
  import { toKebabCase } from '@dreki-gg/taskman';
16
17
  import { readHeadCommit } from '../git.js';
18
+ import { resolvePlanTarget, assertTargetReceived } from '../target.js';
19
+ import { detectPreconditionGaps, formatPreconditionRejection } from '../precondition-guard.js';
17
20
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
18
21
 
19
22
  export interface SubmitPlanCallbacks {
@@ -75,15 +78,40 @@ export function registerSubmitPlanTool(
75
78
  }),
76
79
  ),
77
80
  ),
81
+ target: Type.Optional(
82
+ Type.String({
83
+ description:
84
+ "Optional path to ANOTHER project's repo root (not its .plans/ dir). When set, the plan is filed into that project's .plans/ registry instead of the current one — useful when you find a gap in a package you author and want the plan to live (and later execute) there. Author-only: the plan is NOT pinned as the active plan in this session.",
85
+ }),
86
+ ),
78
87
  }),
79
88
 
80
89
  async execute(_toolCallId, params) {
90
+ // Precondition gate (deterministic, no LLM): reject destructive tasks that
91
+ // carry no proof command or auditable opt-out. Nothing is persisted on
92
+ // rejection — the agent fixes the tasks and re-calls submit_plan.
93
+ const gaps = detectPreconditionGaps(params.tasks);
94
+ if (gaps.length > 0) {
95
+ const details: Record<string, unknown> = { rejected: true, preconditionGaps: gaps };
96
+ return {
97
+ content: [{ type: 'text' as const, text: formatPreconditionRejection(gaps) }],
98
+ details,
99
+ };
100
+ }
101
+
102
+ // Resolve an optional external target. When set, the plan is filed into
103
+ // that repo's .plans/ registry via a target-scoped runtime, its base
104
+ // commit comes from that repo's HEAD, and we do NOT pin it as this
105
+ // session's active plan (author-only).
106
+ const targetDir = await resolvePlanTarget(params.target);
107
+ const io: RunPlanIO = targetDir ? makePlanRuntime(targetDir) : runPlanIO;
108
+
81
109
  const planName = toKebabCase(params.name);
82
110
  const planDir = `.plans/${planName}`;
83
111
  const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
84
112
  const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
85
113
  const now = new Date().toISOString();
86
- const baseCommit = await readHeadCommit();
114
+ const baseCommit = await readHeadCommit(targetDir ?? process.cwd());
87
115
  const meta: TaskMeta = {
88
116
  _type: 'meta',
89
117
  title: params.title,
@@ -109,7 +137,7 @@ export function registerSubmitPlanTool(
109
137
  base_commit: baseCommit,
110
138
  };
111
139
 
112
- const unknownInitiative = await runPlanIO(
140
+ const unknownInitiative = await io(
113
141
  Effect.gen(function* () {
114
142
  yield* writeTasksJsonl(planDir, meta, tasks);
115
143
  yield* saveHandoff(planDir, params.handoff);
@@ -127,21 +155,26 @@ export function registerSubmitPlanTool(
127
155
  }),
128
156
  );
129
157
 
130
- callbacks.onPlanSubmitted(planDir, plan);
158
+ // Fail loudly if an old/stale taskman silently routed the write to cwd.
159
+ if (targetDir) await assertTargetReceived(targetDir, planDir);
160
+
161
+ // Author-only: only pin as the active plan when filed in the current
162
+ // project. A plan filed into an external repo is a first-class local plan
163
+ // *there* — pinning a non-local path here would corrupt session state.
164
+ if (!targetDir) callbacks.onPlanSubmitted(planDir, plan);
131
165
 
132
166
  const linkSuffix = initiative
133
167
  ? ` Linked to initiative "${initiative}"${
134
168
  unknownInitiative ? ' (no initiatives.jsonl entry yet — create it with submit_initiative)' : ''
135
169
  }.`
136
170
  : '';
171
+ const location = targetDir ? `${targetDir}/${planDir}` : planDir;
172
+ const text = targetDir
173
+ ? `Plan "${params.title}" filed into ${location} with ${tasks.length} tasks. It is a local plan in that project — execute it from that directory.${linkSuffix}`
174
+ : `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`;
137
175
  return {
138
- content: [
139
- {
140
- type: 'text' as const,
141
- text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`,
142
- },
143
- ],
144
- details: { planDir, plan, initiative, depends_on_plans: dependsOnPlans },
176
+ content: [{ type: 'text' as const, text }],
177
+ details: { planDir, plan, initiative, depends_on_plans: dependsOnPlans, target: targetDir },
145
178
  };
146
179
  },
147
180
 
@@ -1,27 +1,18 @@
1
1
  /**
2
2
  * Plan mode UI — status bar and task widget rendering.
3
+ *
4
+ * The plan-mode status bar entry is intentionally always cleared: any progress
5
+ * indicator would be derived from in-memory `state.plan`, which drifts from the
6
+ * source of truth on disk. The agent reads real status from `tasks.jsonl` via
7
+ * the `plan_status` tool instead of trusting a memory-rendered badge.
3
8
  */
4
9
 
5
10
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
6
11
  import type { PlanModeState } from './state.js';
7
12
 
8
- export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
9
- const { theme } = ctx.ui;
10
-
11
- if (state.executing && state.plan) {
12
- const done = state.plan.tasks.filter((task) => task.status === 'done').length;
13
- const total = state.plan.tasks.length;
14
- ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
15
- } else if (state.plan && !state.planEnabled) {
16
- const done = state.plan.tasks.filter((task) => task.status === 'done').length;
17
- const total = state.plan.tasks.length;
18
- ctx.ui.setStatus('plan-mode', theme.fg('muted', `📋 ${done}/${total}`));
19
- } else if (state.planEnabled) {
20
- ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
21
- } else {
22
- ctx.ui.setStatus('plan-mode', undefined);
23
- }
24
-
25
- // Task list lives in plan.jsonl — no widget needed.
13
+ export function updateUI(_state: PlanModeState, ctx: ExtensionContext): void {
14
+ // Never render plan state from memory — clear both the status entry and the
15
+ // (unused) task widget.
16
+ ctx.ui.setStatus('plan-mode', undefined);
26
17
  ctx.ui.setWidget('plan-todos', undefined);
27
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.27.3",
3
+ "version": "0.29.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"
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@dreki-gg/pi-command-sandbox": "^0.3.0",
44
- "@dreki-gg/taskman": "^0.3.0",
44
+ "@dreki-gg/taskman": "^0.4.0",
45
45
  "effect": "^3.21.2"
46
46
  },
47
47
  "devDependencies": {
@@ -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