@dreki-gg/pi-plan-mode 0.28.0 → 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,30 @@
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
+
3
28
  ## 0.28.0
4
29
 
5
30
  ### Minor 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
+ });
@@ -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';
@@ -20,6 +20,7 @@ interface SubmitParams {
20
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
 
@@ -136,3 +137,42 @@ describe('submit_plan tool — initiative + plan deps', () => {
136
137
  expect(result.content?.[0]?.text).not.toMatch(/initiative/i);
137
138
  });
138
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,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,10 @@ 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';
17
19
  import { detectPreconditionGaps, formatPreconditionRejection } from '../precondition-guard.js';
18
20
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
19
21
 
@@ -76,6 +78,12 @@ export function registerSubmitPlanTool(
76
78
  }),
77
79
  ),
78
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
+ ),
79
87
  }),
80
88
 
81
89
  async execute(_toolCallId, params) {
@@ -91,12 +99,19 @@ export function registerSubmitPlanTool(
91
99
  };
92
100
  }
93
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
+
94
109
  const planName = toKebabCase(params.name);
95
110
  const planDir = `.plans/${planName}`;
96
111
  const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
97
112
  const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
98
113
  const now = new Date().toISOString();
99
- const baseCommit = await readHeadCommit();
114
+ const baseCommit = await readHeadCommit(targetDir ?? process.cwd());
100
115
  const meta: TaskMeta = {
101
116
  _type: 'meta',
102
117
  title: params.title,
@@ -122,7 +137,7 @@ export function registerSubmitPlanTool(
122
137
  base_commit: baseCommit,
123
138
  };
124
139
 
125
- const unknownInitiative = await runPlanIO(
140
+ const unknownInitiative = await io(
126
141
  Effect.gen(function* () {
127
142
  yield* writeTasksJsonl(planDir, meta, tasks);
128
143
  yield* saveHandoff(planDir, params.handoff);
@@ -140,21 +155,26 @@ export function registerSubmitPlanTool(
140
155
  }),
141
156
  );
142
157
 
143
- 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);
144
165
 
145
166
  const linkSuffix = initiative
146
167
  ? ` Linked to initiative "${initiative}"${
147
168
  unknownInitiative ? ' (no initiatives.jsonl entry yet — create it with submit_initiative)' : ''
148
169
  }.`
149
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}`;
150
175
  return {
151
- content: [
152
- {
153
- type: 'text' as const,
154
- text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`,
155
- },
156
- ],
157
- 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 },
158
178
  };
159
179
  },
160
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.28.0",
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": {