@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.
- package/CHANGELOG.md +16 -0
- package/README.md +45 -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/constants.ts +4 -0
- package/extensions/plan-mode/index.ts +59 -3
- 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/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
|
+
});
|
|
@@ -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.
|
|
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` →
|
|
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({
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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>" }.`
|