@dreki-gg/pi-plan-mode 0.17.0 → 0.18.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,25 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Make `update_task` / `add_task` usable across sessions and resilient for autonomous agents.
8
+
9
+ The active plan was session-scoped: `update_task` / `add_task` only worked when a plan was submitted in the same session, restored from its entries, or handed off via the one-shot exec-pending marker. An agent executing an existing `.plans/<name>/` in a fresh session (the common plan-here / execute-there flow) hit a hard `No active plan` throw.
10
+
11
+ - **Disk-backed resolution** (`resolveActivePlan`): when nothing is attached in memory, the active plan is resolved from `.plans/plans.jsonl` — the sole in-progress plan auto-attaches (data only; does NOT enter execution mode / change tools / model). Wired into `session_start` and both tracking tools.
12
+ - **No hard throws on tracking calls**: `update_task` / `add_task` now return soft, non-terminating results (no active plan, unknown task id, already-resolved task) so a tracking miss never derails the real work. `update_task` is idempotent — re-marking the same status is a no-op success.
13
+ - **`plan` parameter**: both tools accept an optional `plan` (name or `.plans/<name>`) to disambiguate without the interactive `/plan resume` when multiple plans are in-progress.
14
+ - **`update_task` corrections**: a different status on an already-resolved task now applies as a correction (e.g. `done`→`skipped`, or `blocked`→`done` to unblock) and is reported as such, instead of being refused.
15
+ - **New `plan_status` tool**: read-only snapshot of the active plan — progress counts + every task id/status — so an agent can check what's active and which ids are valid (disk-backed; works in a fresh execution session) instead of probing with a failing `update_task`. Added to both tool sets.
16
+
17
+ ## 0.17.1
18
+
19
+ ### Patch Changes
20
+
21
+ - submit_plan no longer terminates the agent turn. After a plan is saved the agent can continue in the same turn (e.g. summarize or proceed) instead of cutting off.
22
+
3
23
  ## 0.17.0
4
24
 
5
25
  ### Minor Changes
@@ -7,8 +7,14 @@ const now = '2026-05-27T12:00:00.000Z';
7
7
  interface CapturedTool {
8
8
  execute: (
9
9
  id: string,
10
- params: { description: string; reason: string; details?: string; depends_on?: string[] },
11
- ) => Promise<{ details?: unknown }>;
10
+ params: {
11
+ description: string;
12
+ reason: string;
13
+ details?: string;
14
+ depends_on?: string[];
15
+ plan?: string;
16
+ },
17
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
12
18
  }
13
19
 
14
20
  function setup(plan: PlanData | undefined) {
@@ -21,7 +27,8 @@ function setup(plan: PlanData | undefined) {
21
27
  } as unknown as Parameters<typeof registerAddTaskTool>[0];
22
28
 
23
29
  registerAddTaskTool(pi, {
24
- getPlan: () => plan,
30
+ // Mirrors index.ts: in-memory plan wins; disk fallback returns candidates.
31
+ resolvePlan: async () => ({ plan, candidates: [] }),
25
32
  onTaskAdded: (task) => {
26
33
  added.push(task);
27
34
  },
@@ -66,10 +73,11 @@ describe('add_task tool', () => {
66
73
  expect(task.description).toBe('Extract shared helper');
67
74
  });
68
75
 
69
- test('throws when there is no active plan', async () => {
70
- const { tool } = setup(undefined);
71
- await expect(tool.execute('call-1', { description: 'x', reason: 'y' })).rejects.toThrow(
72
- /No active plan/,
73
- );
76
+ test('soft-skips (does not throw) when there is no active plan', async () => {
77
+ const { tool, added } = setup(undefined);
78
+ const result = await tool.execute('call-1', { description: 'x', reason: 'y' });
79
+ expect((result.details as { skipped?: boolean }).skipped).toBe(true);
80
+ expect(result.content?.[0]?.text).toMatch(/no active plan/i);
81
+ expect(added).toHaveLength(0);
74
82
  });
75
83
  });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerPlanStatusTool } from '../tools/plan-status.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: { plan?: string },
11
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
12
+ }
13
+
14
+ function setup(plan: PlanData | undefined, candidates: string[] = []) {
15
+ let tool: CapturedTool | undefined;
16
+ const pi = {
17
+ registerTool: (config: CapturedTool) => {
18
+ tool = config;
19
+ },
20
+ } as unknown as Parameters<typeof registerPlanStatusTool>[0];
21
+ registerPlanStatusTool(pi, { resolvePlan: async () => ({ plan, candidates }) });
22
+ return tool!;
23
+ }
24
+
25
+ const planTask = (id: string, status: TaskStatus): 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
+ describe('plan_status tool', () => {
36
+ test('summarizes progress + lists task ids and statuses', async () => {
37
+ const plan: PlanData = {
38
+ title: 'My Plan',
39
+ planName: 'my-plan',
40
+ handoff: '',
41
+ tasks: [
42
+ planTask('t-001', 'done'),
43
+ planTask('t-002', 'pending'),
44
+ planTask('t-003', 'blocked'),
45
+ ],
46
+ };
47
+ const tool = setup(plan);
48
+ const result = await tool.execute('c', {});
49
+
50
+ const text = result.content?.[0]?.text ?? '';
51
+ expect(text).toMatch(/My Plan \(my-plan\)/);
52
+ expect(text).toMatch(/1\/3 resolved/);
53
+ expect(text).toMatch(/blocked 1/);
54
+ expect(text).toMatch(/t-002 \[pending\]/);
55
+
56
+ const details = result.details as { active: boolean; task_ids: string[]; total: number };
57
+ expect(details.active).toBe(true);
58
+ expect(details.task_ids).toEqual(['t-001', 't-002', 't-003']);
59
+ expect(details.total).toBe(3);
60
+ });
61
+
62
+ test('reports no active plan (read-only, no throw) with candidates', async () => {
63
+ const tool = setup(undefined, ['alpha', 'beta']);
64
+ const result = await tool.execute('c', {});
65
+ expect((result.details as { active: boolean }).active).toBe(false);
66
+ expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
67
+ });
68
+ });
@@ -0,0 +1,129 @@
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('a name hint disambiguates among multiple in-progress plans', async () => {
100
+ await seedPlan('alpha', 'in-progress', ['t-001']);
101
+ await seedPlan('beta', 'in-progress', ['t-001']);
102
+ const state = new PlanModeState();
103
+ const { pi } = fakePi();
104
+
105
+ const result = await resolveActivePlan(state, pi, runPlanIO, { name: '.plans/beta' });
106
+
107
+ expect(result.plan?.planName).toBe('beta');
108
+ expect(state.planDir).toBe('.plans/beta');
109
+ });
110
+
111
+ test('ignores a done plan when auto-attaching', async () => {
112
+ await seedPlan('alpha', 'done', ['t-001']);
113
+ const state = new PlanModeState();
114
+ const { pi } = fakePi();
115
+
116
+ const result = await resolveActivePlan(state, pi, runPlanIO);
117
+
118
+ expect(result.plan).toBeUndefined();
119
+ expect(result.candidates).toEqual([]);
120
+ });
121
+
122
+ test('returns no plan + no candidates when .plans is empty', async () => {
123
+ const state = new PlanModeState();
124
+ const { pi } = fakePi();
125
+ const result = await resolveActivePlan(state, pi, runPlanIO);
126
+ expect(result.plan).toBeUndefined();
127
+ expect(result.candidates).toEqual([]);
128
+ });
129
+ });
@@ -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,18 @@ export const PLAN_TOOLS = [
15
15
  'questionnaire',
16
16
  'search_skills',
17
17
  'subagent',
18
+ 'plan_status',
18
19
  ];
19
20
 
20
- export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task', 'add_task'];
21
+ export const EXEC_TOOLS = [
22
+ 'read',
23
+ 'bash',
24
+ 'edit',
25
+ 'write',
26
+ 'update_task',
27
+ 'add_task',
28
+ 'plan_status',
29
+ ];
21
30
 
22
31
  // ── Model + thinking presets ─────────────────────────────────────────────────
23
32
  export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
@@ -38,10 +38,12 @@ import { filterExecutionMessages, filterStalePlanMessages } from './context-filt
38
38
  import { activeTasksResolved, deferredTasks } 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';
41
42
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
42
43
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
43
44
  import { registerUpdateTaskTool } from './tools/update-task.js';
44
45
  import { registerAddTaskTool } from './tools/add-task.js';
46
+ import { registerPlanStatusTool } from './tools/plan-status.js';
45
47
  import { isSafeCommand, isPlanPath } from './utils.js';
46
48
 
47
49
  export default function planMode(pi: ExtensionAPI): void {
@@ -68,7 +70,7 @@ export default function planMode(pi: ExtensionAPI): void {
68
70
  registerPreviewPrototypeTool(pi, runPlanIO);
69
71
 
70
72
  registerUpdateTaskTool(pi, {
71
- getPlan: () => state.plan,
73
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
72
74
  onTaskUpdated: async (taskId, status, notes) => {
73
75
  if (!state.plan || !state.planDir) return;
74
76
  const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
@@ -92,8 +94,12 @@ export default function planMode(pi: ExtensionAPI): void {
92
94
  },
93
95
  });
94
96
 
97
+ registerPlanStatusTool(pi, {
98
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
99
+ });
100
+
95
101
  registerAddTaskTool(pi, {
96
- getPlan: () => state.plan,
102
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
97
103
  onTaskAdded: async (task) => {
98
104
  if (!state.plan || !state.planDir) return;
99
105
  state.plan.tasks.push(task);
@@ -477,6 +483,14 @@ export default function planMode(pi: ExtensionAPI): void {
477
483
  }
478
484
  }
479
485
 
486
+ // No plan attached from this session's entries or the exec handoff, but a
487
+ // plan may exist on disk (planning happened in another session). Attach the
488
+ // single in-progress plan so update_task / add_task work without an
489
+ // interactive /plan resume. Data only — does NOT enter execution mode.
490
+ if (!state.plan) {
491
+ await resolveActivePlan(state, pi, runPlanIO);
492
+ }
493
+
480
494
  // Apply tool restrictions, model, and thinking level
481
495
  if (state.planEnabled) {
482
496
  pi.setActiveTools(PLAN_TOOLS);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Disk-backed active-plan resolution.
3
+ *
4
+ * `state.plan` is session-scoped: it is only populated when a plan is submitted
5
+ * in *this* session, restored from this session's entries, or handed off via
6
+ * the one-shot exec-pending file. But `.plans/<name>/tasks.jsonl` is the real
7
+ * source of truth, and execution routinely happens in a different session than
8
+ * planning. This bridges that gap: when nothing is attached in memory, resolve
9
+ * the plan from disk so `update_task` / `add_task` work without an interactive
10
+ * `/plan resume`.
11
+ *
12
+ * Attaching only loads the plan DATA into `state` (so tracking writes land in
13
+ * `tasks.jsonl`); it intentionally does NOT flip `executing` / tools / model —
14
+ * that stays the user's explicit choice via `/plan-exec` or `/plan resume`.
15
+ */
16
+
17
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
18
+ import type { PlanModeState } from './state.js';
19
+ import type { PlanData } from './types.js';
20
+ import type { RunPlanIO } from './effects/runtime.js';
21
+ import { readPlansManifest } from './storage/plans-manifest.js';
22
+ import { readTasksJsonl } from './storage/task-storage.js';
23
+ import { loadHandoff } from './storage/plan-storage.js';
24
+
25
+ export interface ResolvedPlan {
26
+ /** The attached plan, when resolvable. Already written into `state`. */
27
+ plan?: PlanData;
28
+ /**
29
+ * In-progress plan names, surfaced when resolution was ambiguous (multiple
30
+ * in-progress and no usable `name` hint) or a hint missed. Lets a caller
31
+ * report actionable choices instead of dead-ending.
32
+ */
33
+ candidates: string[];
34
+ }
35
+
36
+ /** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
37
+ function normalizeName(hint: string): string {
38
+ return hint
39
+ .replace(/^\.plans\//, '')
40
+ .replace(/\/+$/, '')
41
+ .trim();
42
+ }
43
+
44
+ /** Load `.plans/<name>` from disk into `state` (data only). Returns the plan,
45
+ * or undefined when the tasks file is missing/empty. */
46
+ async function attach(
47
+ state: PlanModeState,
48
+ pi: ExtensionAPI,
49
+ runPlanIO: RunPlanIO,
50
+ name: string,
51
+ ): Promise<PlanData | undefined> {
52
+ const dir = `.plans/${name}`;
53
+ const snapshot = await runPlanIO(readTasksJsonl(dir));
54
+ if (!snapshot) return undefined;
55
+ const plan: PlanData = {
56
+ title: snapshot.meta.title,
57
+ planName: snapshot.meta.plan_name,
58
+ handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
59
+ tasks: snapshot.tasks,
60
+ };
61
+ state.plan = plan;
62
+ state.planDir = dir;
63
+ state.persist(pi);
64
+ return plan;
65
+ }
66
+
67
+ /**
68
+ * Resolve the active plan, attaching from disk when nothing is in memory.
69
+ *
70
+ * Order: in-memory `state.plan` → explicit `name` hint → the single
71
+ * in-progress plan in `.plans/plans.jsonl`. Ambiguous (multiple in-progress,
72
+ * no hint) returns `{ plan: undefined, candidates }` so the caller can prompt
73
+ * for a `name`.
74
+ */
75
+ export async function resolveActivePlan(
76
+ state: PlanModeState,
77
+ pi: ExtensionAPI,
78
+ runPlanIO: RunPlanIO,
79
+ opts: { name?: string } = {},
80
+ ): Promise<ResolvedPlan> {
81
+ if (state.plan) return { plan: state.plan, candidates: [] };
82
+
83
+ const manifest = await runPlanIO(readPlansManifest());
84
+
85
+ if (opts.name) {
86
+ const hint = normalizeName(opts.name);
87
+ const match = manifest.find((entry) => entry.name === hint);
88
+ // A hint that names a real plan attaches regardless of status (the caller
89
+ // asked for it explicitly); a hint that names nothing falls through to the
90
+ // ambiguity report so the caller sees the valid choices.
91
+ if (match) {
92
+ const plan = await attach(state, pi, runPlanIO, match.name);
93
+ if (plan) return { plan, candidates: [] };
94
+ }
95
+ }
96
+
97
+ const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
98
+ if (inProgress.length === 1) {
99
+ const plan = await attach(state, pi, runPlanIO, inProgress[0]!.name);
100
+ if (plan) return { plan, candidates: [] };
101
+ }
102
+
103
+ return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
104
+ }
@@ -9,11 +9,13 @@
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 type { PlanData, TaskRecord } from '../types.js';
12
+ import type { TaskRecord } from '../types.js';
13
+ import type { ResolvedPlan } from '../resolve-plan.js';
13
14
  import { nextTaskId } from '../utils.js';
14
15
 
15
16
  export interface AddTaskCallbacks {
16
- getPlan: () => PlanData | undefined;
17
+ /** Resolve the active plan, attaching from disk when none is in memory. */
18
+ resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
17
19
  onTaskAdded: (task: TaskRecord) => void | Promise<void>;
18
20
  }
19
21
 
@@ -38,11 +40,31 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
38
40
  Type.String({ description: 'Optional fuller implementation notes for the follow-up' }),
39
41
  ),
40
42
  depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
43
+ plan: Type.Optional(
44
+ Type.String({
45
+ description:
46
+ 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress.',
47
+ }),
48
+ ),
41
49
  }),
42
50
 
43
51
  async execute(_toolCallId, params) {
44
- const plan = callbacks.getPlan();
45
- if (!plan) throw new Error('No active plan. Cannot add a follow-up task.');
52
+ const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
53
+ // No active plan is a tracking miss, not an error — return a soft,
54
+ // non-terminating result so real work continues.
55
+ if (!plan) {
56
+ const hint =
57
+ candidates.length > 1
58
+ ? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
59
+ : ' No in-progress plan found in .plans/plans.jsonl.';
60
+ const details: Record<string, unknown> = { skipped: true, candidates };
61
+ return {
62
+ content: [
63
+ { type: 'text' as const, text: `Skipped follow-up capture — no active plan.${hint}` },
64
+ ],
65
+ details,
66
+ };
67
+ }
46
68
 
47
69
  const now = new Date().toISOString();
48
70
  const task: TaskRecord = {
@@ -0,0 +1,123 @@
1
+ /**
2
+ * plan_status tool — read-only snapshot of the active plan.
3
+ *
4
+ * Lets an agent proactively learn whether a plan is active, its progress, and
5
+ * the valid task ids — instead of discovering that by a failed `update_task`.
6
+ * Resolves disk-backed (attaches the sole in-progress plan), so it works in a
7
+ * fresh execution session. Pure read: never mutates plan state.
8
+ */
9
+
10
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
11
+ import { Text } from '@earendil-works/pi-tui';
12
+ import { Type } from 'typebox';
13
+ import type { TaskStatus } from '../types.js';
14
+ import type { ResolvedPlan } from '../resolve-plan.js';
15
+
16
+ export interface PlanStatusCallbacks {
17
+ /** Resolve the active plan, attaching from disk when none is in memory. */
18
+ resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
19
+ }
20
+
21
+ const STATUS_GLYPH: Record<TaskStatus, string> = {
22
+ done: '✓',
23
+ skipped: '⊘',
24
+ blocked: '✗',
25
+ pending: '○',
26
+ deferred: '+',
27
+ };
28
+
29
+ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCallbacks): void {
30
+ pi.registerTool({
31
+ name: 'plan_status',
32
+ label: 'Plan Status',
33
+ description:
34
+ 'Read-only snapshot of the active plan: progress counts and every task id + status. Use to check whether a plan is active and what the valid task ids are before calling update_task.',
35
+ promptSnippet: 'Show the active plan: progress + task ids/statuses',
36
+ promptGuidelines: [
37
+ 'Call plan_status when unsure whether a plan is active or which task ids exist — it is read-only and never mutates state.',
38
+ 'Prefer it over guessing task ids; the returned ids are what update_task expects.',
39
+ ],
40
+ parameters: Type.Object({
41
+ plan: Type.Optional(
42
+ Type.String({
43
+ description:
44
+ 'Plan name (or .plans/<name>) to inspect. Only needed to disambiguate when multiple plans are in-progress.',
45
+ }),
46
+ ),
47
+ }),
48
+
49
+ async execute(_toolCallId, params) {
50
+ const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
51
+ if (!plan) {
52
+ const hint =
53
+ candidates.length > 1
54
+ ? ` In-progress plans: ${candidates.join(', ')} — pass { plan: "<name>" }.`
55
+ : ' No in-progress plan found in .plans/plans.jsonl.';
56
+ const noPlanDetails: Record<string, unknown> = { active: false, candidates };
57
+ return {
58
+ content: [{ type: 'text' as const, text: `No active plan.${hint}` }],
59
+ details: noPlanDetails,
60
+ };
61
+ }
62
+
63
+ const counts: Record<TaskStatus, number> = {
64
+ done: 0,
65
+ skipped: 0,
66
+ blocked: 0,
67
+ pending: 0,
68
+ deferred: 0,
69
+ };
70
+ for (const task of plan.tasks) counts[task.status] += 1;
71
+ const resolved = counts.done + counts.skipped;
72
+
73
+ const parts = [
74
+ `done ${counts.done}`,
75
+ `skipped ${counts.skipped}`,
76
+ `pending ${counts.pending}`,
77
+ ];
78
+ if (counts.blocked) parts.push(`blocked ${counts.blocked}`);
79
+ if (counts.deferred) parts.push(`follow-up ${counts.deferred}`);
80
+
81
+ const lines = plan.tasks.map(
82
+ (task) => ` ${STATUS_GLYPH[task.status]} ${task.id} [${task.status}] ${task.description}`,
83
+ );
84
+ const text =
85
+ `Plan: ${plan.title} (${plan.planName})\n` +
86
+ `Progress: ${resolved}/${plan.tasks.length} resolved — ${parts.join(', ')}\n` +
87
+ `Tasks:\n${lines.join('\n')}`;
88
+
89
+ return {
90
+ content: [{ type: 'text' as const, text }],
91
+ details: {
92
+ active: true,
93
+ plan_name: plan.planName,
94
+ title: plan.title,
95
+ total: plan.tasks.length,
96
+ counts,
97
+ task_ids: plan.tasks.map((task) => task.id),
98
+ },
99
+ };
100
+ },
101
+
102
+ renderCall(args, theme) {
103
+ const name = (args as { plan?: string }).plan;
104
+ let content = theme.fg('toolTitle', theme.bold('plan_status'));
105
+ if (name) content += ' ' + theme.fg('muted', name);
106
+ return new Text(content, 0, 0);
107
+ },
108
+
109
+ renderResult(result, _options, theme) {
110
+ const details = result.details as
111
+ | { active?: boolean; plan_name?: string; total?: number; counts?: Record<string, number> }
112
+ | undefined;
113
+ if (!details?.active) return new Text(theme.fg('dim', 'No active plan'), 0, 0);
114
+ const resolved = (details.counts?.done ?? 0) + (details.counts?.skipped ?? 0);
115
+ return new Text(
116
+ theme.fg('toolTitle', `${details.plan_name ?? 'plan'} `) +
117
+ theme.fg('muted', `${resolved}/${details.total ?? 0} resolved`),
118
+ 0,
119
+ 0,
120
+ );
121
+ },
122
+ });
123
+ }
@@ -99,7 +99,6 @@ export function registerSubmitPlanTool(
99
99
  },
100
100
  ],
101
101
  details: { planDir, plan },
102
- terminate: true,
103
102
  };
104
103
  },
105
104
 
@@ -6,10 +6,12 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
6
  import { StringEnum } from '@earendil-works/pi-ai';
7
7
  import { Text } from '@earendil-works/pi-tui';
8
8
  import { Type } from 'typebox';
9
- import type { PlanData, TaskStatus } from '../types.js';
9
+ import type { TaskStatus } from '../types.js';
10
+ import type { ResolvedPlan } from '../resolve-plan.js';
10
11
 
11
12
  export interface UpdateTaskCallbacks {
12
- getPlan: () => PlanData | undefined;
13
+ /** Resolve the active plan, attaching from disk when none is in memory. */
14
+ resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
13
15
  onTaskUpdated: (
14
16
  taskId: string,
15
17
  status: Exclude<TaskStatus, 'pending'>,
@@ -17,6 +19,11 @@ export interface UpdateTaskCallbacks {
17
19
  ) => void | Promise<void>;
18
20
  }
19
21
 
22
+ /** A non-terminating tool result — task tracking must never derail real work. */
23
+ function soft(text: string, details: Record<string, unknown>) {
24
+ return { content: [{ type: 'text' as const, text }], details };
25
+ }
26
+
20
27
  export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
21
28
  pi.registerTool({
22
29
  name: 'update_task',
@@ -35,19 +42,51 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
35
42
  notes: Type.Optional(
36
43
  Type.String({ description: 'What was done, why skipped, or why blocked' }),
37
44
  ),
45
+ plan: Type.Optional(
46
+ Type.String({
47
+ description:
48
+ 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress; otherwise the active / sole in-progress plan is used.',
49
+ }),
50
+ ),
38
51
  }),
39
52
 
40
53
  async execute(_toolCallId, params) {
41
- const plan = callbacks.getPlan();
42
- if (!plan) throw new Error('No active plan. Cannot update task.');
54
+ const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
55
+ // No active plan is a tracking miss, not an error: return a soft result
56
+ // (non-terminating) so the agent keeps doing the real work.
57
+ if (!plan) {
58
+ const hint =
59
+ candidates.length > 1
60
+ ? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
61
+ : ' No in-progress plan found in .plans/plans.jsonl.';
62
+ return soft(`Skipped task tracking — no active plan.${hint}`, {
63
+ skipped: true,
64
+ candidates,
65
+ });
66
+ }
43
67
 
44
68
  const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
45
- if (!task) throw new Error(`Task not found: ${params.task_id}`);
46
- if (task.status !== 'pending') {
47
- throw new Error(
48
- `Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`,
49
- );
69
+ if (!task) {
70
+ const ids = plan.tasks.map((candidate) => candidate.id).join(', ');
71
+ return soft(`Task not found: ${params.task_id}. Valid ids: ${ids || '(none)'}`, {
72
+ error: 'not_found',
73
+ task_id: params.task_id,
74
+ });
75
+ }
76
+ // Idempotent: re-marking the same status is a no-op success (safe to
77
+ // retry).
78
+ if (task.status === params.status) {
79
+ return soft(`Task ${params.task_id} already ${params.status} (no-op).`, {
80
+ task_id: params.task_id,
81
+ status: params.status,
82
+ description: task.description,
83
+ });
50
84
  }
85
+ // A different status on an already-resolved task is a CORRECTION — apply
86
+ // it (e.g. done→skipped, or blocked→done to unblock). The status is the
87
+ // edit; the plan queue recomputes from it.
88
+ const wasCorrection = task.status !== 'pending';
89
+ const priorStatus = task.status;
51
90
 
52
91
  await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
53
92
 
@@ -75,7 +114,8 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
75
114
  const resolved = done + skipped;
76
115
  const next = plan.tasks.find((candidate) => candidate.status === 'pending');
77
116
  const statusEmoji = params.status === 'done' ? '✓' : '⊘';
78
- let text = `${statusEmoji} Task ${params.task_id} ${params.status}. Progress: ${resolved}/${plan.tasks.length}`;
117
+ const verb = wasCorrection ? `corrected (${priorStatus} ${params.status})` : params.status;
118
+ let text = `${statusEmoji} Task ${params.task_id} ${verb}. Progress: ${resolved}/${plan.tasks.length}`;
79
119
  if (params.notes) text += ` — ${params.notes}`;
80
120
  text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
81
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.17.0",
3
+ "version": "0.18.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"