@dreki-gg/pi-plan-mode 0.18.0 → 0.20.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
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerUpdateTasksTool } from '../tools/update-tasks.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
+ updates: Array<{ task_id: string; status: 'done' | 'skipped'; notes?: string }>;
12
+ plan?: string;
13
+ },
14
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown; terminate?: boolean }>;
15
+ }
16
+
17
+ function setup(plan: PlanData | undefined, candidates: string[] = []) {
18
+ let tool: CapturedTool | undefined;
19
+ const updates: Array<{ taskId: string; status: string; notes?: string }> = [];
20
+ // Count how many times the coalesced write callback fires — must be ≤ 1 per call.
21
+ let writeCount = 0;
22
+ const pi = {
23
+ registerTool: (config: CapturedTool) => {
24
+ tool = config;
25
+ },
26
+ } as unknown as Parameters<typeof registerUpdateTasksTool>[0];
27
+
28
+ registerUpdateTasksTool(pi, {
29
+ resolvePlan: async () => ({ plan, candidates }),
30
+ onTasksUpdated: (batch) => {
31
+ writeCount += 1;
32
+ for (const { taskId, status, notes } of batch) {
33
+ const target = plan?.tasks.find((candidate) => candidate.id === taskId);
34
+ if (target) target.status = status;
35
+ updates.push({ taskId, status, notes });
36
+ }
37
+ },
38
+ });
39
+
40
+ return { tool: tool!, updates, getWriteCount: () => writeCount };
41
+ }
42
+
43
+ const basePlan = (tasks: TaskRecord[]): PlanData => ({
44
+ title: 'Plan',
45
+ planName: 'plan',
46
+ handoff: '',
47
+ tasks,
48
+ });
49
+ const planTask = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
50
+ _type: 'task',
51
+ id,
52
+ description: `task ${id}`,
53
+ status,
54
+ origin: 'plan',
55
+ created_at: now,
56
+ updated_at: now,
57
+ });
58
+
59
+ describe('update_tasks tool', () => {
60
+ test('marks multiple pending tasks (mixed statuses) in a SINGLE write and reports progress', async () => {
61
+ const { tool, updates, getWriteCount } = setup(
62
+ basePlan([planTask('t-001'), planTask('t-002'), planTask('t-003')]),
63
+ );
64
+ const result = await tool.execute('c', {
65
+ updates: [
66
+ { task_id: 't-001', status: 'done', notes: 'a' },
67
+ { task_id: 't-002', status: 'skipped', notes: 'b' },
68
+ ],
69
+ });
70
+ expect(updates).toEqual([
71
+ { taskId: 't-001', status: 'done', notes: 'a' },
72
+ { taskId: 't-002', status: 'skipped', notes: 'b' },
73
+ ]);
74
+ // The whole point of the batch tool: one coalesced write, not one per task.
75
+ expect(getWriteCount()).toBe(1);
76
+ expect(result.content?.[0]?.text).toMatch(/Progress: 2\/3/);
77
+ expect(result.terminate).toBeUndefined();
78
+ });
79
+
80
+ test('soft-skips (no throw) when there is no active plan', async () => {
81
+ const { tool, updates } = setup(undefined, ['alpha', 'beta']);
82
+ const result = await tool.execute('c', { updates: [{ task_id: 't-001', status: 'done' }] });
83
+ expect((result.details as { skipped?: boolean }).skipped).toBe(true);
84
+ expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
85
+ expect(updates).toHaveLength(0);
86
+ });
87
+
88
+ test('no write when every item is a no-op or not_found', async () => {
89
+ const { tool, getWriteCount } = setup(basePlan([planTask('t-001', 'done')]));
90
+ await tool.execute('c', {
91
+ updates: [
92
+ { task_id: 't-001', status: 'done' },
93
+ { task_id: 't-999', status: 'done' },
94
+ ],
95
+ });
96
+ expect(getWriteCount()).toBe(0);
97
+ });
98
+
99
+ test('mixed batch: valid applied, unknown id reported as not_found', async () => {
100
+ const { tool, updates } = setup(basePlan([planTask('t-001'), planTask('t-002')]));
101
+ const result = await tool.execute('c', {
102
+ updates: [
103
+ { task_id: 't-001', status: 'done' },
104
+ { task_id: 't-999', status: 'done' },
105
+ ],
106
+ });
107
+ expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
108
+ const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
109
+ expect(details.results?.find((r) => r.task_id === 't-999')?.outcome).toBe('not_found');
110
+ });
111
+
112
+ test('idempotent: re-marking the same status is a per-item no-op', async () => {
113
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done'), planTask('t-002')]));
114
+ const result = await tool.execute('c', {
115
+ updates: [
116
+ { task_id: 't-001', status: 'done' },
117
+ { task_id: 't-002', status: 'done' },
118
+ ],
119
+ });
120
+ expect(updates).toEqual([{ taskId: 't-002', status: 'done', notes: undefined }]);
121
+ const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
122
+ expect(details.results?.find((r) => r.task_id === 't-001')?.outcome).toBe('noop');
123
+ });
124
+
125
+ test('corrects an already-resolved task (done → skipped)', async () => {
126
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
127
+ await tool.execute('c', { updates: [{ task_id: 't-001', status: 'skipped' }] });
128
+ expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
129
+ });
130
+ });
@@ -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 = [
@@ -24,8 +26,11 @@ export const EXEC_TOOLS = [
24
26
  'edit',
25
27
  'write',
26
28
  'update_task',
29
+ 'update_tasks',
27
30
  'add_task',
28
31
  'plan_status',
32
+ 'update_plan',
33
+ 'reconcile_plans',
29
34
  ];
30
35
 
31
36
  // ── Model + thinking presets ─────────────────────────────────────────────────
@@ -26,24 +26,28 @@ import {
26
26
  EXEC_MODEL,
27
27
  EXEC_THINKING,
28
28
  } from './constants.js';
29
- import type { ThinkingLevel } from './types.js';
29
+ import type { ThinkingLevel, TaskStatus } from './types.js';
30
30
  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';
46
+ import { registerUpdateTasksTool } from './tools/update-tasks.js';
45
47
  import { registerAddTaskTool } from './tools/add-task.js';
46
48
  import { registerPlanStatusTool } from './tools/plan-status.js';
49
+ import { registerUpdatePlanTool } from './tools/update-plan.js';
50
+ import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
47
51
  import { isSafeCommand, isPlanPath } from './utils.js';
48
52
 
49
53
  export default function planMode(pi: ExtensionAPI): void {
@@ -69,15 +73,64 @@ export default function planMode(pi: ExtensionAPI): void {
69
73
 
70
74
  registerPreviewPrototypeTool(pi, runPlanIO);
71
75
 
76
+ // Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
77
+ // and re-derive registry status. Used by both update_task and update_tasks.
78
+ const onTaskUpdated = async (
79
+ taskId: string,
80
+ status: Exclude<TaskStatus, 'pending'>,
81
+ notes?: string,
82
+ ) => {
83
+ if (!state.plan || !state.planDir) return;
84
+ const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
85
+ if (!task) return;
86
+ task.status = status;
87
+ task.updated_at = new Date().toISOString();
88
+ if (notes) task.notes = notes;
89
+ await runPlanIO(
90
+ writeTasksJsonl(
91
+ state.planDir,
92
+ {
93
+ _type: 'meta',
94
+ title: state.plan.title,
95
+ plan_name: state.plan.planName,
96
+ created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
97
+ },
98
+ state.plan.tasks,
99
+ ),
100
+ );
101
+ // FEEDBACK #1: registry status is a projection of task state. Re-derive it
102
+ // on every task write so completion is decoupled from in-session execution
103
+ // — cross-session / disk-tracked task updates now close the plan too.
104
+ await runPlanIO(
105
+ reconcilePlanStatus(
106
+ state.plan.planName,
107
+ isPlanFinalizable(state.plan.tasks),
108
+ state.plan.title,
109
+ ),
110
+ );
111
+ state.persist(pi);
112
+ };
113
+
72
114
  registerUpdateTaskTool(pi, {
73
115
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
74
- onTaskUpdated: async (taskId, status, notes) => {
116
+ onTaskUpdated,
117
+ });
118
+
119
+ registerUpdateTasksTool(pi, {
120
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
121
+ // Coalesced batch write: mutate every task in memory first, then perform a
122
+ // SINGLE tasks.jsonl write + ONE registry reconcile. This is the whole point
123
+ // of update_tasks — repeated single-task writes caused file-write contention.
124
+ onTasksUpdated: async (updates) => {
75
125
  if (!state.plan || !state.planDir) return;
76
- const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
77
- if (!task) return;
78
- task.status = status;
79
- task.updated_at = new Date().toISOString();
80
- if (notes) task.notes = notes;
126
+ const now = new Date().toISOString();
127
+ for (const { taskId, status, notes } of updates) {
128
+ const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
129
+ if (!task) continue;
130
+ task.status = status;
131
+ task.updated_at = now;
132
+ if (notes) task.notes = notes;
133
+ }
81
134
  await runPlanIO(
82
135
  writeTasksJsonl(
83
136
  state.planDir,
@@ -85,19 +138,40 @@ export default function planMode(pi: ExtensionAPI): void {
85
138
  _type: 'meta',
86
139
  title: state.plan.title,
87
140
  plan_name: state.plan.planName,
88
- created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
141
+ created_at: state.plan.tasks[0]?.created_at ?? now,
89
142
  },
90
143
  state.plan.tasks,
91
144
  ),
92
145
  );
146
+ await runPlanIO(
147
+ reconcilePlanStatus(
148
+ state.plan.planName,
149
+ isPlanFinalizable(state.plan.tasks),
150
+ state.plan.title,
151
+ ),
152
+ );
93
153
  state.persist(pi);
94
154
  },
95
155
  });
96
156
 
97
157
  registerPlanStatusTool(pi, {
98
158
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
159
+ listInProgress: async () => {
160
+ const rows = await runPlanIO(collectPlanDrift());
161
+ return rows
162
+ .filter((row) => row.registryStatus === 'in-progress')
163
+ .map((row) => ({
164
+ name: row.name,
165
+ title: row.title ?? row.name,
166
+ resolved: row.resolved ?? 0,
167
+ total: row.total ?? 0,
168
+ }));
169
+ },
99
170
  });
100
171
 
172
+ registerUpdatePlanTool(pi, runPlanIO);
173
+ registerReconcilePlansTool(pi, runPlanIO);
174
+
101
175
  registerAddTaskTool(pi, {
102
176
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
103
177
  onTaskAdded: async (task) => {
@@ -115,6 +189,15 @@ export default function planMode(pi: ExtensionAPI): void {
115
189
  state.plan.tasks,
116
190
  ),
117
191
  );
192
+ // A new deferred follow-up means the plan is no longer finalizable: re-open
193
+ // it in the registry if it had been auto-marked done.
194
+ await runPlanIO(
195
+ reconcilePlanStatus(
196
+ state.plan.planName,
197
+ isPlanFinalizable(state.plan.tasks),
198
+ state.plan.title,
199
+ ),
200
+ );
118
201
  state.persist(pi);
119
202
  },
120
203
  });
@@ -122,13 +205,33 @@ export default function planMode(pi: ExtensionAPI): void {
122
205
  // ── Commands ──────────────────────────────────────────────────────────────
123
206
  pi.registerCommand('plan', {
124
207
  description:
125
- 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
208
+ '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
209
  handler: async (args, ctx) => {
127
210
  const trimmed = args?.trim();
128
211
  if (trimmed === 'resume') {
129
212
  await resumePlan(state, pi, ctx, runPlanIO);
130
213
  return;
131
214
  }
215
+ // "/plan focus <name>" — pin a plan so update_task / add_task / plan_status
216
+ // default to it without repeating { plan: "<name>" } on every call (#5).
217
+ if (trimmed?.startsWith('focus')) {
218
+ const name = trimmed.slice('focus'.length).trim();
219
+ if (!name) {
220
+ ctx.ui.notify('Usage: /plan focus <name>', 'info');
221
+ return;
222
+ }
223
+ // Clear any stale in-memory plan so the hint re-attaches from disk.
224
+ state.plan = undefined;
225
+ state.planDir = undefined;
226
+ const { plan, candidates } = await resolveActivePlan(state, pi, runPlanIO, { name });
227
+ if (plan) {
228
+ ctx.ui.notify(`Focused plan: ${plan.title} (${plan.planName})`, 'info');
229
+ } else {
230
+ const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
231
+ ctx.ui.notify(`No plan named "${name}".${hint}`, 'error');
232
+ }
233
+ return;
234
+ }
132
235
  if (state.planEnabled || state.executing) {
133
236
  await exitPlanMode(state, pi, ctx);
134
237
  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({