@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.
- package/CHANGELOG.md +25 -0
- package/README.md +46 -9
- package/bin/clean-plans.js +92 -66
- package/extensions/plan-mode/__tests__/plan-status.test.ts +28 -2
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +46 -0
- package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +83 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +114 -0
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +25 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +19 -0
- package/extensions/plan-mode/__tests__/update-plan.test.ts +69 -0
- package/extensions/plan-mode/__tests__/update-tasks.test.ts +130 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/index.ts +114 -11
- package/extensions/plan-mode/reconcile.ts +141 -0
- package/extensions/plan-mode/resolve-plan.ts +19 -5
- package/extensions/plan-mode/schema.ts +13 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +42 -3
- package/extensions/plan-mode/tools/plan-status.ts +32 -0
- package/extensions/plan-mode/tools/reconcile-plans.ts +118 -0
- package/extensions/plan-mode/tools/update-plan.ts +123 -0
- package/extensions/plan-mode/tools/update-tasks.ts +172 -0
- package/extensions/plan-mode/types.ts +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 ??
|
|
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.
|
|
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` →
|
|
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
|
-
|
|
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:
|
|
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({
|