@dreki-gg/pi-plan-mode 0.17.1 → 0.19.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.
@@ -0,0 +1,114 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { writeTasksJsonl } from '../storage/task-storage.js';
8
+ import {
9
+ readPlansManifest,
10
+ upsertPlanEntry,
11
+ writePlansManifest,
12
+ } from '../storage/plans-manifest.js';
13
+ import { applyReconcile, collectPlanDrift } from '../reconcile.js';
14
+ import type { TaskMeta, TaskRecord, TaskStatus } from '../types.js';
15
+
16
+ const runPlanIO = makePlanRuntime();
17
+ const now = '2026-05-27T12:00:00.000Z';
18
+
19
+ const meta = (name: string): TaskMeta => ({
20
+ _type: 'meta',
21
+ title: `Title ${name}`,
22
+ plan_name: name,
23
+ created_at: now,
24
+ });
25
+ const task = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
26
+ _type: 'task',
27
+ id,
28
+ description: `task ${id}`,
29
+ status,
30
+ origin: 'plan',
31
+ created_at: now,
32
+ updated_at: now,
33
+ });
34
+
35
+ const originalCwd = process.cwd();
36
+ let dir: string;
37
+ beforeEach(async () => {
38
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-reconcile-'));
39
+ chdir(dir);
40
+ });
41
+ afterEach(async () => {
42
+ chdir(originalCwd);
43
+ await rm(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ describe('collectPlanDrift', () => {
47
+ test('flags a fully-done plan still registered in-progress as status drift', async () => {
48
+ await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
49
+ await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
50
+
51
+ const rows = await runPlanIO(collectPlanDrift());
52
+ const alpha = rows.find((r) => r.name === 'alpha')!;
53
+ expect(alpha.drift).toBe('status');
54
+ expect(alpha.derivedStatus).toBe('done');
55
+ expect(alpha.resolved).toBe(1);
56
+ expect(alpha.total).toBe(1);
57
+ });
58
+
59
+ test('reports an in-sync plan with no drift', async () => {
60
+ await runPlanIO(writeTasksJsonl('.plans/beta', meta('beta'), [task('t-001', 'pending')]));
61
+ await runPlanIO(upsertPlanEntry('beta', { status: 'in-progress', title: 'Title beta' }));
62
+
63
+ const rows = await runPlanIO(collectPlanDrift());
64
+ expect(rows.find((r) => r.name === 'beta')!.drift).toBeUndefined();
65
+ });
66
+
67
+ test('flags registry-only plans (no tasks.jsonl)', async () => {
68
+ await runPlanIO(upsertPlanEntry('ghost', { status: 'in-progress', title: 'Ghost' }));
69
+ const rows = await runPlanIO(collectPlanDrift());
70
+ const ghost = rows.find((r) => r.name === 'ghost')!;
71
+ expect(ghost.drift).toBe('registry-only');
72
+ expect(ghost.hasTasks).toBe(false);
73
+ });
74
+
75
+ test('flags orphan task dirs (tasks.jsonl, no registry entry)', async () => {
76
+ await runPlanIO(writeTasksJsonl('.plans/orphan', meta('orphan'), [task('t-001', 'done')]));
77
+ const rows = await runPlanIO(collectPlanDrift());
78
+ const orphan = rows.find((r) => r.name === 'orphan')!;
79
+ expect(orphan.drift).toBe('orphan');
80
+ });
81
+
82
+ test('never flags a manually-closed terminal status as drift', async () => {
83
+ await runPlanIO(writeTasksJsonl('.plans/sup', meta('sup'), [task('t-001', 'pending')]));
84
+ await runPlanIO(
85
+ writePlansManifest([
86
+ {
87
+ _type: 'plan',
88
+ name: 'sup',
89
+ status: 'superseded',
90
+ title: 'Title sup',
91
+ created_at: now,
92
+ completed_at: now,
93
+ reason: 'absorbed',
94
+ },
95
+ ]),
96
+ );
97
+ const rows = await runPlanIO(collectPlanDrift());
98
+ expect(rows.find((r) => r.name === 'sup')!.drift).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe('applyReconcile', () => {
103
+ test('repairs only status drift and projects derived status into the registry', async () => {
104
+ await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
105
+ await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
106
+
107
+ const rows = await runPlanIO(collectPlanDrift());
108
+ const repaired = await runPlanIO(applyReconcile(rows));
109
+ expect(repaired.map((r) => r.name)).toEqual(['alpha']);
110
+
111
+ const [entry] = await runPlanIO(readPlansManifest());
112
+ expect(entry.status).toBe('done');
113
+ });
114
+ });
@@ -0,0 +1,154 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { writeTasksJsonl } from '../storage/task-storage.js';
8
+ import { upsertPlanEntry } from '../storage/plans-manifest.js';
9
+ import { saveHandoff } from '../storage/plan-storage.js';
10
+ import { PlanModeState } from '../state.js';
11
+ import { resolveActivePlan } from '../resolve-plan.js';
12
+ import type { TaskMeta, TaskRecord } from '../types.js';
13
+
14
+ const runPlanIO = makePlanRuntime();
15
+ const now = '2026-05-27T12:00:00.000Z';
16
+
17
+ const meta = (name: string): TaskMeta => ({
18
+ _type: 'meta',
19
+ title: `Title ${name}`,
20
+ plan_name: name,
21
+ created_at: now,
22
+ });
23
+ const task = (id: string): TaskRecord => ({
24
+ _type: 'task',
25
+ id,
26
+ description: `task ${id}`,
27
+ status: 'pending',
28
+ origin: 'plan',
29
+ created_at: now,
30
+ updated_at: now,
31
+ });
32
+
33
+ /** Minimal pi stub — `state.persist` only needs `appendEntry`. */
34
+ function fakePi() {
35
+ const entries: unknown[] = [];
36
+ return {
37
+ pi: { appendEntry: (_t: string, d: unknown) => entries.push(d) },
38
+ entries,
39
+ } as unknown as {
40
+ pi: Parameters<typeof resolveActivePlan>[1];
41
+ entries: unknown[];
42
+ };
43
+ }
44
+
45
+ async function seedPlan(name: string, status: 'in-progress' | 'done', ids: string[]) {
46
+ await runPlanIO(writeTasksJsonl(`.plans/${name}`, meta(name), ids.map(task)));
47
+ await runPlanIO(saveHandoff(`.plans/${name}`, `# Handoff ${name}`));
48
+ await runPlanIO(upsertPlanEntry(name, { status, title: `Title ${name}` }));
49
+ }
50
+
51
+ const originalCwd = process.cwd();
52
+ let dir: string;
53
+ beforeEach(async () => {
54
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-resolve-'));
55
+ chdir(dir);
56
+ });
57
+ afterEach(async () => {
58
+ chdir(originalCwd);
59
+ await rm(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ describe('resolveActivePlan', () => {
63
+ test('returns the in-memory plan without touching disk', async () => {
64
+ const state = new PlanModeState();
65
+ state.plan = { title: 'mem', planName: 'mem', handoff: '', tasks: [task('t-001')] };
66
+ const { pi } = fakePi();
67
+ const result = await resolveActivePlan(state, pi, runPlanIO);
68
+ expect(result.plan?.planName).toBe('mem');
69
+ });
70
+
71
+ test('auto-attaches the single in-progress plan from disk', async () => {
72
+ await seedPlan('alpha', 'in-progress', ['t-001', 't-002']);
73
+ const state = new PlanModeState();
74
+ const { pi } = fakePi();
75
+
76
+ const result = await resolveActivePlan(state, pi, runPlanIO);
77
+
78
+ expect(result.plan?.planName).toBe('alpha');
79
+ expect(result.plan?.tasks).toHaveLength(2);
80
+ expect(result.plan?.handoff).toBe('# Handoff alpha');
81
+ // Attached into state (data only — execution mode untouched).
82
+ expect(state.planDir).toBe('.plans/alpha');
83
+ expect(state.executing).toBe(false);
84
+ });
85
+
86
+ test('is ambiguous (no attach) when multiple plans are in-progress', async () => {
87
+ await seedPlan('alpha', 'in-progress', ['t-001']);
88
+ await seedPlan('beta', 'in-progress', ['t-001']);
89
+ const state = new PlanModeState();
90
+ const { pi } = fakePi();
91
+
92
+ const result = await resolveActivePlan(state, pi, runPlanIO);
93
+
94
+ expect(result.plan).toBeUndefined();
95
+ expect(result.candidates.sort()).toEqual(['alpha', 'beta']);
96
+ expect(state.plan).toBeUndefined();
97
+ });
98
+
99
+ test('an explicit name hint wins over an attached in-memory plan (FEEDBACK #7)', async () => {
100
+ // Two plans on disk; a *different* plan is pinned in memory.
101
+ await seedPlan('alpha', 'in-progress', ['t-001']);
102
+ await seedPlan('beta', 'in-progress', ['t-001']);
103
+ const state = new PlanModeState();
104
+ state.plan = { title: 'alpha', planName: 'alpha', handoff: '', tasks: [task('t-001')] };
105
+ state.planDir = '.plans/alpha';
106
+ const { pi } = fakePi();
107
+
108
+ // The explicit hint must re-attach beta, not silently keep alpha.
109
+ const result = await resolveActivePlan(state, pi, runPlanIO, { name: 'beta' });
110
+
111
+ expect(result.plan?.planName).toBe('beta');
112
+ expect(state.plan?.planName).toBe('beta');
113
+ expect(state.planDir).toBe('.plans/beta');
114
+ });
115
+
116
+ test('a hint matching the in-memory plan returns it without touching disk', async () => {
117
+ const state = new PlanModeState();
118
+ state.plan = { title: 'mem', planName: 'mem', handoff: '', tasks: [task('t-001')] };
119
+ const { pi } = fakePi();
120
+ const result = await resolveActivePlan(state, pi, runPlanIO, { name: 'mem' });
121
+ expect(result.plan?.planName).toBe('mem');
122
+ });
123
+
124
+ test('a name hint disambiguates among multiple in-progress plans', async () => {
125
+ await seedPlan('alpha', 'in-progress', ['t-001']);
126
+ await seedPlan('beta', 'in-progress', ['t-001']);
127
+ const state = new PlanModeState();
128
+ const { pi } = fakePi();
129
+
130
+ const result = await resolveActivePlan(state, pi, runPlanIO, { name: '.plans/beta' });
131
+
132
+ expect(result.plan?.planName).toBe('beta');
133
+ expect(state.planDir).toBe('.plans/beta');
134
+ });
135
+
136
+ test('ignores a done plan when auto-attaching', async () => {
137
+ await seedPlan('alpha', 'done', ['t-001']);
138
+ const state = new PlanModeState();
139
+ const { pi } = fakePi();
140
+
141
+ const result = await resolveActivePlan(state, pi, runPlanIO);
142
+
143
+ expect(result.plan).toBeUndefined();
144
+ expect(result.candidates).toEqual([]);
145
+ });
146
+
147
+ test('returns no plan + no candidates when .plans is empty', async () => {
148
+ const state = new PlanModeState();
149
+ const { pi } = fakePi();
150
+ const result = await resolveActivePlan(state, pi, runPlanIO);
151
+ expect(result.plan).toBeUndefined();
152
+ expect(result.candidates).toEqual([]);
153
+ });
154
+ });
@@ -177,6 +177,25 @@ describe('plan manifest entry schema', () => {
177
177
  ).toBe(true);
178
178
  });
179
179
 
180
+ test('accepts terminal statuses (superseded / abandoned) with a reason', () => {
181
+ for (const status of ['done', 'superseded', 'abandoned'] as const) {
182
+ expect(
183
+ isOk(
184
+ {
185
+ _type: 'plan',
186
+ name: 'plan',
187
+ status,
188
+ title: 'Plan',
189
+ created_at: now,
190
+ completed_at: now,
191
+ reason: 'another plan shipped it',
192
+ },
193
+ decodePlanManifestEntry,
194
+ ),
195
+ ).toBe(true);
196
+ }
197
+ });
198
+
180
199
  test('rejects an invalid status', () => {
181
200
  expect(
182
201
  isOk(
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
8
+ import { registerUpdatePlanTool } from '../tools/update-plan.js';
9
+
10
+ const runPlanIO = makePlanRuntime();
11
+
12
+ interface CapturedTool {
13
+ execute: (
14
+ id: string,
15
+ params: { plan: string; status: string; reason?: string },
16
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
17
+ }
18
+
19
+ function setup(): CapturedTool {
20
+ let tool: CapturedTool | undefined;
21
+ const pi = {
22
+ registerTool: (config: CapturedTool) => {
23
+ tool = config;
24
+ },
25
+ } as unknown as Parameters<typeof registerUpdatePlanTool>[0];
26
+ registerUpdatePlanTool(pi, runPlanIO);
27
+ return tool!;
28
+ }
29
+
30
+ const originalCwd = process.cwd();
31
+ let dir: string;
32
+ beforeEach(async () => {
33
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-update-plan-'));
34
+ chdir(dir);
35
+ });
36
+ afterEach(async () => {
37
+ chdir(originalCwd);
38
+ await rm(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe('update_plan tool', () => {
42
+ test('closes a plan as superseded with a reason', async () => {
43
+ await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
44
+ const tool = setup();
45
+ const result = await tool.execute('c', {
46
+ plan: 'p',
47
+ status: 'superseded',
48
+ reason: 'absorbed by q',
49
+ });
50
+ expect(result.content?.[0]?.text).toMatch(/in-progress → superseded/);
51
+ const [entry] = await runPlanIO(readPlansManifest());
52
+ expect(entry.status).toBe('superseded');
53
+ expect(entry.reason).toBe('absorbed by q');
54
+ });
55
+
56
+ test('accepts a .plans/<name> hint', async () => {
57
+ await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
58
+ const tool = setup();
59
+ await tool.execute('c', { plan: '.plans/p', status: 'abandoned', reason: 'rejected' });
60
+ const [entry] = await runPlanIO(readPlansManifest());
61
+ expect(entry.status).toBe('abandoned');
62
+ });
63
+
64
+ test('reports not_found for an unknown plan (no throw)', async () => {
65
+ const tool = setup();
66
+ const result = await tool.execute('c', { plan: 'ghost', status: 'done' });
67
+ expect((result.details as { error?: string }).error).toBe('not_found');
68
+ });
69
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerUpdateTaskTool } from '../tools/update-task.js';
3
+ import type { PlanData, TaskRecord, TaskStatus } from '../types.js';
4
+
5
+ const now = '2026-05-27T12:00:00.000Z';
6
+
7
+ interface CapturedTool {
8
+ execute: (
9
+ id: string,
10
+ params: {
11
+ task_id: string;
12
+ status: 'done' | 'skipped' | 'blocked';
13
+ notes?: string;
14
+ plan?: string;
15
+ },
16
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown; terminate?: boolean }>;
17
+ }
18
+
19
+ function setup(plan: PlanData | undefined, candidates: string[] = []) {
20
+ let tool: CapturedTool | undefined;
21
+ const updates: Array<{ taskId: string; status: string; notes?: string }> = [];
22
+ const pi = {
23
+ registerTool: (config: CapturedTool) => {
24
+ tool = config;
25
+ },
26
+ } as unknown as Parameters<typeof registerUpdateTaskTool>[0];
27
+
28
+ registerUpdateTaskTool(pi, {
29
+ resolvePlan: async () => ({ plan, candidates }),
30
+ onTaskUpdated: (taskId, status, notes) => {
31
+ const target = plan?.tasks.find((candidate) => candidate.id === taskId);
32
+ if (target) target.status = status;
33
+ updates.push({ taskId, status, notes });
34
+ },
35
+ });
36
+
37
+ return { tool: tool!, updates };
38
+ }
39
+
40
+ const basePlan = (tasks: TaskRecord[]): PlanData => ({
41
+ title: 'Plan',
42
+ planName: 'plan',
43
+ handoff: '',
44
+ tasks,
45
+ });
46
+ const planTask = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
47
+ _type: 'task',
48
+ id,
49
+ description: `task ${id}`,
50
+ status,
51
+ origin: 'plan',
52
+ created_at: now,
53
+ updated_at: now,
54
+ });
55
+
56
+ describe('update_task tool', () => {
57
+ test('marks a pending task and reports progress', async () => {
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' });
60
+ expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: 'did it' }]);
61
+ expect(result.content?.[0]?.text).toMatch(/Progress: 1\/2/);
62
+ });
63
+
64
+ test('blocked terminates the turn', async () => {
65
+ const { tool } = setup(basePlan([planTask('t-001')]));
66
+ const result = await tool.execute('c', { task_id: 't-001', status: 'blocked' });
67
+ expect(result.terminate).toBe(true);
68
+ });
69
+
70
+ test('soft-skips (no throw) when there is no active plan', async () => {
71
+ const { tool, updates } = setup(undefined, ['alpha', 'beta']);
72
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
73
+ expect((result.details as { skipped?: boolean }).skipped).toBe(true);
74
+ expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
75
+ expect(updates).toHaveLength(0);
76
+ });
77
+
78
+ test('soft result (no throw) for an unknown task id', async () => {
79
+ const { tool, updates } = setup(basePlan([planTask('t-001')]));
80
+ const result = await tool.execute('c', { task_id: 't-999', status: 'done' });
81
+ expect((result.details as { error?: string }).error).toBe('not_found');
82
+ expect(result.content?.[0]?.text).toMatch(/t-001/);
83
+ expect(updates).toHaveLength(0);
84
+ });
85
+
86
+ test('idempotent: re-marking the same status is a no-op success', async () => {
87
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
88
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
89
+ expect(result.content?.[0]?.text).toMatch(/no-op/);
90
+ expect(updates).toHaveLength(0);
91
+ });
92
+
93
+ test('corrects an already-resolved task (done → skipped) and reports it', async () => {
94
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
95
+ const result = await tool.execute('c', { task_id: 't-001', status: 'skipped' });
96
+ expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
97
+ expect(result.content?.[0]?.text).toMatch(/corrected \(done → skipped\)/);
98
+ });
99
+
100
+ test('unblocks a task (blocked → done) without terminating', async () => {
101
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'blocked')]));
102
+ const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
103
+ expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
104
+ expect(result.terminate).toBeUndefined();
105
+ });
106
+ });
@@ -15,9 +15,22 @@ export const PLAN_TOOLS = [
15
15
  'questionnaire',
16
16
  'search_skills',
17
17
  'subagent',
18
+ 'plan_status',
19
+ 'update_plan',
20
+ 'reconcile_plans',
18
21
  ];
19
22
 
20
- export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task', 'add_task'];
23
+ export const EXEC_TOOLS = [
24
+ 'read',
25
+ 'bash',
26
+ 'edit',
27
+ 'write',
28
+ 'update_task',
29
+ 'add_task',
30
+ 'plan_status',
31
+ 'update_plan',
32
+ 'reconcile_plans',
33
+ ];
21
34
 
22
35
  // ── Model + thinking presets ─────────────────────────────────────────────────
23
36
  export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
@@ -31,17 +31,22 @@ import { PlanModeState } from './state.js';
31
31
  import { makePlanRuntime } from './effects/runtime.js';
32
32
  import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
33
33
  import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
34
- import { upsertPlanEntry } from './storage/plans-manifest.js';
34
+ import { upsertPlanEntry, reconcilePlanStatus } from './storage/plans-manifest.js';
35
35
  import { updateUI } from './ui.js';
36
36
  import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
37
37
  import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
38
- import { activeTasksResolved, deferredTasks } from './task-status.js';
38
+ import { activeTasksResolved, deferredTasks, isPlanFinalizable } from './task-status.js';
39
39
  import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
40
40
  import { resumePlan, executeInNewSession } from './resume.js';
41
+ import { resolveActivePlan } from './resolve-plan.js';
42
+ import { collectPlanDrift } from './reconcile.js';
41
43
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
42
44
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
43
45
  import { registerUpdateTaskTool } from './tools/update-task.js';
44
46
  import { registerAddTaskTool } from './tools/add-task.js';
47
+ import { registerPlanStatusTool } from './tools/plan-status.js';
48
+ import { registerUpdatePlanTool } from './tools/update-plan.js';
49
+ import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
45
50
  import { isSafeCommand, isPlanPath } from './utils.js';
46
51
 
47
52
  export default function planMode(pi: ExtensionAPI): void {
@@ -68,7 +73,7 @@ export default function planMode(pi: ExtensionAPI): void {
68
73
  registerPreviewPrototypeTool(pi, runPlanIO);
69
74
 
70
75
  registerUpdateTaskTool(pi, {
71
- getPlan: () => state.plan,
76
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
72
77
  onTaskUpdated: async (taskId, status, notes) => {
73
78
  if (!state.plan || !state.planDir) return;
74
79
  const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
@@ -88,12 +93,40 @@ export default function planMode(pi: ExtensionAPI): void {
88
93
  state.plan.tasks,
89
94
  ),
90
95
  );
96
+ // FEEDBACK #1: registry status is a projection of task state. Re-derive it
97
+ // on every task write so completion is decoupled from in-session execution
98
+ // — cross-session / disk-tracked `update_task` now closes the plan too.
99
+ await runPlanIO(
100
+ reconcilePlanStatus(
101
+ state.plan.planName,
102
+ isPlanFinalizable(state.plan.tasks),
103
+ state.plan.title,
104
+ ),
105
+ );
91
106
  state.persist(pi);
92
107
  },
93
108
  });
94
109
 
110
+ registerPlanStatusTool(pi, {
111
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
112
+ listInProgress: async () => {
113
+ const rows = await runPlanIO(collectPlanDrift());
114
+ return rows
115
+ .filter((row) => row.registryStatus === 'in-progress')
116
+ .map((row) => ({
117
+ name: row.name,
118
+ title: row.title ?? row.name,
119
+ resolved: row.resolved ?? 0,
120
+ total: row.total ?? 0,
121
+ }));
122
+ },
123
+ });
124
+
125
+ registerUpdatePlanTool(pi, runPlanIO);
126
+ registerReconcilePlansTool(pi, runPlanIO);
127
+
95
128
  registerAddTaskTool(pi, {
96
- getPlan: () => state.plan,
129
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
97
130
  onTaskAdded: async (task) => {
98
131
  if (!state.plan || !state.planDir) return;
99
132
  state.plan.tasks.push(task);
@@ -109,6 +142,15 @@ export default function planMode(pi: ExtensionAPI): void {
109
142
  state.plan.tasks,
110
143
  ),
111
144
  );
145
+ // A new deferred follow-up means the plan is no longer finalizable: re-open
146
+ // it in the registry if it had been auto-marked done.
147
+ await runPlanIO(
148
+ reconcilePlanStatus(
149
+ state.plan.planName,
150
+ isPlanFinalizable(state.plan.tasks),
151
+ state.plan.title,
152
+ ),
153
+ );
112
154
  state.persist(pi);
113
155
  },
114
156
  });
@@ -116,13 +158,33 @@ export default function planMode(pi: ExtensionAPI): void {
116
158
  // ── Commands ──────────────────────────────────────────────────────────────
117
159
  pi.registerCommand('plan', {
118
160
  description:
119
- 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
161
+ 'Enter plan mode, optionally with a starting prompt. "/plan resume" picks up an existing plan; "/plan focus <name>" pins the active plan for tracking calls.',
120
162
  handler: async (args, ctx) => {
121
163
  const trimmed = args?.trim();
122
164
  if (trimmed === 'resume') {
123
165
  await resumePlan(state, pi, ctx, runPlanIO);
124
166
  return;
125
167
  }
168
+ // "/plan focus <name>" — pin a plan so update_task / add_task / plan_status
169
+ // default to it without repeating { plan: "<name>" } on every call (#5).
170
+ if (trimmed?.startsWith('focus')) {
171
+ const name = trimmed.slice('focus'.length).trim();
172
+ if (!name) {
173
+ ctx.ui.notify('Usage: /plan focus <name>', 'info');
174
+ return;
175
+ }
176
+ // Clear any stale in-memory plan so the hint re-attaches from disk.
177
+ state.plan = undefined;
178
+ state.planDir = undefined;
179
+ const { plan, candidates } = await resolveActivePlan(state, pi, runPlanIO, { name });
180
+ if (plan) {
181
+ ctx.ui.notify(`Focused plan: ${plan.title} (${plan.planName})`, 'info');
182
+ } else {
183
+ const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
184
+ ctx.ui.notify(`No plan named "${name}".${hint}`, 'error');
185
+ }
186
+ return;
187
+ }
126
188
  if (state.planEnabled || state.executing) {
127
189
  await exitPlanMode(state, pi, ctx);
128
190
  return;
@@ -477,6 +539,14 @@ export default function planMode(pi: ExtensionAPI): void {
477
539
  }
478
540
  }
479
541
 
542
+ // No plan attached from this session's entries or the exec handoff, but a
543
+ // plan may exist on disk (planning happened in another session). Attach the
544
+ // single in-progress plan so update_task / add_task work without an
545
+ // interactive /plan resume. Data only — does NOT enter execution mode.
546
+ if (!state.plan) {
547
+ await resolveActivePlan(state, pi, runPlanIO);
548
+ }
549
+
480
550
  // Apply tool restrictions, model, and thinking level
481
551
  if (state.planEnabled) {
482
552
  pi.setActiveTools(PLAN_TOOLS);