@dreki-gg/pi-plan-mode 0.18.0 → 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.
@@ -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
+ });
@@ -16,6 +16,8 @@ export const PLAN_TOOLS = [
16
16
  'search_skills',
17
17
  'subagent',
18
18
  'plan_status',
19
+ 'update_plan',
20
+ 'reconcile_plans',
19
21
  ];
20
22
 
21
23
  export const EXEC_TOOLS = [
@@ -26,6 +28,8 @@ export const EXEC_TOOLS = [
26
28
  'update_task',
27
29
  'add_task',
28
30
  'plan_status',
31
+ 'update_plan',
32
+ 'reconcile_plans',
29
33
  ];
30
34
 
31
35
  // ── Model + thinking presets ─────────────────────────────────────────────────
@@ -31,19 +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
41
  import { resolveActivePlan } from './resolve-plan.js';
42
+ import { collectPlanDrift } from './reconcile.js';
42
43
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
43
44
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
44
45
  import { registerUpdateTaskTool } from './tools/update-task.js';
45
46
  import { registerAddTaskTool } from './tools/add-task.js';
46
47
  import { registerPlanStatusTool } from './tools/plan-status.js';
48
+ import { registerUpdatePlanTool } from './tools/update-plan.js';
49
+ import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
47
50
  import { isSafeCommand, isPlanPath } from './utils.js';
48
51
 
49
52
  export default function planMode(pi: ExtensionAPI): void {
@@ -90,14 +93,38 @@ export default function planMode(pi: ExtensionAPI): void {
90
93
  state.plan.tasks,
91
94
  ),
92
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
+ );
93
106
  state.persist(pi);
94
107
  },
95
108
  });
96
109
 
97
110
  registerPlanStatusTool(pi, {
98
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
+ },
99
123
  });
100
124
 
125
+ registerUpdatePlanTool(pi, runPlanIO);
126
+ registerReconcilePlansTool(pi, runPlanIO);
127
+
101
128
  registerAddTaskTool(pi, {
102
129
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
103
130
  onTaskAdded: async (task) => {
@@ -115,6 +142,15 @@ export default function planMode(pi: ExtensionAPI): void {
115
142
  state.plan.tasks,
116
143
  ),
117
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
+ );
118
154
  state.persist(pi);
119
155
  },
120
156
  });
@@ -122,13 +158,33 @@ export default function planMode(pi: ExtensionAPI): void {
122
158
  // ── Commands ──────────────────────────────────────────────────────────────
123
159
  pi.registerCommand('plan', {
124
160
  description:
125
- '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.',
126
162
  handler: async (args, ctx) => {
127
163
  const trimmed = args?.trim();
128
164
  if (trimmed === 'resume') {
129
165
  await resumePlan(state, pi, ctx, runPlanIO);
130
166
  return;
131
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
+ }
132
188
  if (state.planEnabled || state.executing) {
133
189
  await exitPlanMode(state, pi, ctx);
134
190
  return;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Drift detection + repair between `tasks.jsonl` reality and registry status.
3
+ *
4
+ * Drift happens in both directions (FEEDBACK #6):
5
+ * - tasks all done but registry `in-progress` (completion never recorded), and
6
+ * - registry `in-progress`/`done` disagreeing with task state generally.
7
+ *
8
+ * It also surfaces two un-trackable classes:
9
+ * - registry-only plans (an entry with no `tasks.jsonl` directory), and
10
+ * - orphan task dirs (a `tasks.jsonl` with no registry entry).
11
+ *
12
+ * `collectPlanDrift` is a pure read; `applyReconcile` repairs only the safe
13
+ * `in-progress` ⇄ `done` projection and never touches terminal statuses.
14
+ */
15
+
16
+ import { Effect } from 'effect';
17
+ import { FileSystem } from './effects/filesystem.js';
18
+ import type {
19
+ JsonlParseError,
20
+ JsonlValidationError,
21
+ MissingMetaRecord,
22
+ PlanWriteError,
23
+ } from './errors.js';
24
+ import { readPlansManifest, reconcilePlanStatus } from './storage/plans-manifest.js';
25
+ import { readTasksJsonl } from './storage/task-storage.js';
26
+ import { isPlanFinalizable } from './task-status.js';
27
+ import type { PlanStatus } from './types.js';
28
+
29
+ const PLANS_DIR = '.plans';
30
+
31
+ export interface PlanDriftRow {
32
+ name: string;
33
+ /** Registry status, or `undefined` when there is a task dir but no entry. */
34
+ registryStatus?: PlanStatus;
35
+ title?: string;
36
+ /** Derived from tasks: `done` when finalizable, else `in-progress`. */
37
+ derivedStatus?: 'in-progress' | 'done';
38
+ /** Resolved/total task counts when a tasks.jsonl exists. */
39
+ resolved?: number;
40
+ total?: number;
41
+ /** True when a `tasks.jsonl` snapshot was found for this plan. */
42
+ hasTasks: boolean;
43
+ /**
44
+ * Drift class:
45
+ * - 'status' : registry status disagrees with derived task status
46
+ * - 'registry-only' : registry entry but no tasks.jsonl dir
47
+ * - 'orphan' : tasks.jsonl dir but no registry entry
48
+ * - undefined : in sync
49
+ */
50
+ drift?: 'status' | 'registry-only' | 'orphan';
51
+ }
52
+
53
+ type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
54
+
55
+ /** Walk every plan (registry + task dirs) and classify drift. Pure read. */
56
+ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError, FileSystem> {
57
+ return Effect.gen(function* () {
58
+ const fs = yield* FileSystem;
59
+ const manifest = yield* readPlansManifest();
60
+ const dirs = yield* Effect.orElseSucceed(fs.listDirectories(PLANS_DIR), () => [] as string[]);
61
+ // Ignore dotfile dirs like `.archive`.
62
+ const taskDirs = new Set(dirs.filter((name) => !name.startsWith('.')));
63
+
64
+ const rows: PlanDriftRow[] = [];
65
+ const seen = new Set<string>();
66
+
67
+ for (const entry of manifest) {
68
+ seen.add(entry.name);
69
+ const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${entry.name}`);
70
+ if (!snapshot) {
71
+ rows.push({
72
+ name: entry.name,
73
+ registryStatus: entry.status,
74
+ title: entry.title,
75
+ hasTasks: false,
76
+ drift: 'registry-only',
77
+ });
78
+ continue;
79
+ }
80
+ const total = snapshot.tasks.length;
81
+ const resolved = snapshot.tasks.filter(
82
+ (t) => t.status === 'done' || t.status === 'skipped',
83
+ ).length;
84
+ const derivedStatus = isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress';
85
+ // Terminal statuses (superseded/abandoned) are intentional — never drift.
86
+ const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
87
+ const drift = !isTerminalManual && entry.status !== derivedStatus ? 'status' : undefined;
88
+ rows.push({
89
+ name: entry.name,
90
+ registryStatus: entry.status,
91
+ title: entry.title,
92
+ derivedStatus,
93
+ resolved,
94
+ total,
95
+ hasTasks: true,
96
+ drift,
97
+ });
98
+ }
99
+
100
+ // Orphan task dirs: have tasks.jsonl but no registry entry.
101
+ for (const name of taskDirs) {
102
+ if (seen.has(name)) continue;
103
+ const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${name}`);
104
+ if (!snapshot) continue;
105
+ const total = snapshot.tasks.length;
106
+ const resolved = snapshot.tasks.filter(
107
+ (t) => t.status === 'done' || t.status === 'skipped',
108
+ ).length;
109
+ rows.push({
110
+ name,
111
+ title: snapshot.meta.title,
112
+ derivedStatus: isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress',
113
+ resolved,
114
+ total,
115
+ hasTasks: true,
116
+ drift: 'orphan',
117
+ });
118
+ }
119
+
120
+ return rows;
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Repair `status`-class drift by projecting derived status into the registry.
126
+ * Orphans and registry-only rows are reported but not auto-fixed (they need a
127
+ * human decision). Returns the rows that were repaired.
128
+ */
129
+ export function applyReconcile(
130
+ rows: PlanDriftRow[],
131
+ ): Effect.Effect<PlanDriftRow[], CollectError | PlanWriteError, FileSystem> {
132
+ return Effect.gen(function* () {
133
+ const repaired: PlanDriftRow[] = [];
134
+ for (const row of rows) {
135
+ if (row.drift !== 'status' || !row.derivedStatus) continue;
136
+ yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
137
+ repaired.push(row);
138
+ }
139
+ return repaired;
140
+ });
141
+ }
@@ -67,10 +67,17 @@ async function attach(
67
67
  /**
68
68
  * Resolve the active plan, attaching from disk when nothing is in memory.
69
69
  *
70
- * Order: in-memory `state.plan` → explicit `name` hint → the single
70
+ * Order: explicit `name` hint → in-memory `state.plan` → the single
71
71
  * in-progress plan in `.plans/plans.jsonl`. Ambiguous (multiple in-progress,
72
72
  * no hint) returns `{ plan: undefined, candidates }` so the caller can prompt
73
73
  * for a `name`.
74
+ *
75
+ * IMPORTANT (FEEDBACK #7): an explicit `name` hint ALWAYS wins over the
76
+ * in-memory `state.plan`. Previously `state.plan` short-circuited first, so a
77
+ * deliberate `{ plan: '<other>' }` was silently ignored and writes landed in
78
+ * whatever plan the last `submit_plan` attached — a data-corruption path. The
79
+ * hint is now resolved first; when it names a different plan we re-attach from
80
+ * disk so subsequent writes target the right `tasks.jsonl`.
74
81
  */
75
82
  export async function resolveActivePlan(
76
83
  state: PlanModeState,
@@ -78,12 +85,13 @@ export async function resolveActivePlan(
78
85
  runPlanIO: RunPlanIO,
79
86
  opts: { name?: string } = {},
80
87
  ): Promise<ResolvedPlan> {
81
- if (state.plan) return { plan: state.plan, candidates: [] };
82
-
83
- const manifest = await runPlanIO(readPlansManifest());
84
-
88
+ // ── Explicit hint wins, even over an attached in-memory plan ──────────────
85
89
  if (opts.name) {
86
90
  const hint = normalizeName(opts.name);
91
+ // Already the attached plan? Return the in-memory copy (freshest task state).
92
+ if (state.plan && state.plan.planName === hint) return { plan: state.plan, candidates: [] };
93
+
94
+ const manifest = await runPlanIO(readPlansManifest());
87
95
  const match = manifest.find((entry) => entry.name === hint);
88
96
  // A hint that names a real plan attaches regardless of status (the caller
89
97
  // asked for it explicitly); a hint that names nothing falls through to the
@@ -92,8 +100,14 @@ export async function resolveActivePlan(
92
100
  const plan = await attach(state, pi, runPlanIO, match.name);
93
101
  if (plan) return { plan, candidates: [] };
94
102
  }
103
+ const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
104
+ return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
95
105
  }
96
106
 
107
+ // ── No hint: in-memory plan, else the single in-progress plan on disk ─────
108
+ if (state.plan) return { plan: state.plan, candidates: [] };
109
+
110
+ const manifest = await runPlanIO(readPlansManifest());
97
111
  const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
98
112
  if (inProgress.length === 1) {
99
113
  const plan = await attach(state, pi, runPlanIO, inProgress[0]!.name);
@@ -36,13 +36,25 @@ export const TaskMetaSchema = Schema.Struct({
36
36
  /** A single tasks.jsonl line is either the meta record or a task record. */
37
37
  export const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
38
38
 
39
+ /**
40
+ * Plan lifecycle statuses.
41
+ * - in-progress: active, tracked, eligible for auto-resolution
42
+ * - done: completed (all tasks resolved)
43
+ * - superseded: closed because another plan absorbed the work
44
+ * - abandoned: closed without shipping (rejected / won't do)
45
+ * Only `in-progress` is treated as active; the rest are terminal.
46
+ */
47
+ export const PlanStatusSchema = Schema.Literal('in-progress', 'done', 'superseded', 'abandoned');
48
+
39
49
  export const PlanManifestEntrySchema = Schema.Struct({
40
50
  _type: Schema.Literal('plan'),
41
51
  name: Schema.String,
42
- status: Schema.Literal('in-progress', 'done'),
52
+ status: PlanStatusSchema,
43
53
  title: Schema.String,
44
54
  created_at: Schema.String,
45
55
  completed_at: Schema.NullOr(Schema.String),
56
+ /** Optional human-readable reason, used for terminal statuses. */
57
+ reason: Schema.optional(Schema.String),
46
58
  });
47
59
 
48
60
  export const ExecPendingConfigSchema = Schema.Struct({
@@ -2,6 +2,7 @@ import { Effect, Either, Option } from 'effect';
2
2
  import { FileSystem } from '../effects/filesystem.js';
3
3
  import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
4
4
  import { decodePlanManifestEntry } from '../schema.js';
5
+ import type { PlanStatus } from '../types.js';
5
6
 
6
7
  const MANIFEST_DIR = '.plans';
7
8
  const MANIFEST_PATH = '.plans/plans.jsonl';
@@ -9,10 +10,16 @@ const MANIFEST_PATH = '.plans/plans.jsonl';
9
10
  export interface PlanManifestEntry {
10
11
  _type: 'plan';
11
12
  name: string;
12
- status: 'in-progress' | 'done';
13
+ status: PlanStatus;
13
14
  title: string;
14
15
  created_at: string;
15
16
  completed_at: string | null;
17
+ reason?: string;
18
+ }
19
+
20
+ /** A status is terminal (closed) when it is anything other than in-progress. */
21
+ export function isTerminalStatus(status: PlanStatus): boolean {
22
+ return status !== 'in-progress';
16
23
  }
17
24
 
18
25
  type ReadError = JsonlParseError | JsonlValidationError;
@@ -62,7 +69,7 @@ export function writePlansManifest(
62
69
 
63
70
  export function upsertPlanEntry(
64
71
  name: string,
65
- updates: { status: 'in-progress' | 'done'; title?: string },
72
+ updates: { status: PlanStatus; title?: string; reason?: string },
66
73
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
67
74
  return Effect.gen(function* () {
68
75
  const entries = yield* readPlansManifest();
@@ -75,10 +82,42 @@ export function upsertPlanEntry(
75
82
  status: updates.status,
76
83
  title: updates.title ?? existing?.title ?? 'Untitled plan',
77
84
  created_at: existing?.created_at ?? now,
78
- completed_at: updates.status === 'done' ? now : null,
85
+ // Terminal statuses record a completion timestamp; reopening clears it.
86
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
87
+ reason: updates.reason ?? existing?.reason,
79
88
  };
80
89
  if (index === -1) entries.push(entry);
81
90
  else entries[index] = entry;
82
91
  yield* writePlansManifest(entries);
83
92
  });
84
93
  }
94
+
95
+ /**
96
+ * Reconcile a plan's registry status from its task state.
97
+ *
98
+ * The registry `status` is a PROJECTION of task state, not a parallel flag.
99
+ * Call this wherever tasks are written so completion is never coupled to a
100
+ * formal in-session execution run (see FEEDBACK #1). `finalizable` means every
101
+ * active task is resolved AND no deferred follow-ups remain.
102
+ *
103
+ * Guard: a manually-set terminal status (`superseded` / `abandoned`) is never
104
+ * auto-overridden — only `in-progress` ⇄ `done` is derived from tasks.
105
+ */
106
+ export function reconcilePlanStatus(
107
+ name: string,
108
+ finalizable: boolean,
109
+ title?: string,
110
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
111
+ return Effect.gen(function* () {
112
+ const entries = yield* readPlansManifest();
113
+ const existing = entries.find((entry) => entry.name === name);
114
+ // Reconcile only reflects task state for KNOWN plans; never conjure an
115
+ // entry for an unregistered plan (orphans are surfaced, not auto-created).
116
+ if (!existing) return;
117
+ // Do not resurrect / clobber an explicitly closed plan.
118
+ if (existing.status === 'superseded' || existing.status === 'abandoned') return;
119
+ const status: PlanStatus = finalizable ? 'done' : 'in-progress';
120
+ if (existing.status === status) return; // no change
121
+ yield* upsertPlanEntry(name, { status, title });
122
+ });
123
+ }
@@ -13,9 +13,22 @@ import { Type } from 'typebox';
13
13
  import type { TaskStatus } from '../types.js';
14
14
  import type { ResolvedPlan } from '../resolve-plan.js';
15
15
 
16
+ export interface InProgressSummary {
17
+ name: string;
18
+ title: string;
19
+ resolved: number;
20
+ total: number;
21
+ }
22
+
16
23
  export interface PlanStatusCallbacks {
17
24
  /** Resolve the active plan, attaching from disk when none is in memory. */
18
25
  resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
26
+ /**
27
+ * List every in-progress plan with progress counts. Used to render a
28
+ * progress-at-a-glance table when no single plan can be resolved
29
+ * (multiple in-progress) — surfaces drift (e.g. a 17/17 plan still listed).
30
+ */
31
+ listInProgress?: () => Promise<InProgressSummary[]>;
19
32
  }
20
33
 
21
34
  const STATUS_GLYPH: Record<TaskStatus, string> = {
@@ -49,6 +62,25 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
49
62
  async execute(_toolCallId, params) {
50
63
  const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
51
64
  if (!plan) {
65
+ // Multiple in-progress plans: show a progress-at-a-glance table rather
66
+ // than a bare name list (FEEDBACK #5). The counts surface drift too —
67
+ // a fully-resolved plan still listed in-progress is a reconcile cue.
68
+ if (candidates.length > 1 && callbacks.listInProgress) {
69
+ const summaries = await callbacks.listInProgress();
70
+ const rows = summaries
71
+ .map((s) => {
72
+ const flag = s.total > 0 && s.resolved === s.total ? ' ⚠ done?' : '';
73
+ return ` ${s.resolved}/${s.total} ${s.name} — ${s.title}${flag}`;
74
+ })
75
+ .join('\n');
76
+ const text =
77
+ `No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } to target one.\n` +
78
+ `Progress:\n${rows}`;
79
+ return {
80
+ content: [{ type: 'text' as const, text }],
81
+ details: { active: false, candidates, in_progress: summaries },
82
+ };
83
+ }
52
84
  const hint =
53
85
  candidates.length > 1
54
86
  ? ` In-progress plans: ${candidates.join(', ')} — pass { plan: "<name>" }.`