@dreki-gg/pi-plan-mode 0.17.1 → 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 +30 -0
- package/README.md +45 -9
- package/bin/clean-plans.js +92 -66
- package/extensions/plan-mode/__tests__/add-task.test.ts +16 -8
- package/extensions/plan-mode/__tests__/plan-status.test.ts +94 -0
- 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 +154 -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-task.test.ts +106 -0
- package/extensions/plan-mode/constants.ts +14 -1
- package/extensions/plan-mode/index.ts +75 -5
- package/extensions/plan-mode/reconcile.ts +141 -0
- package/extensions/plan-mode/resolve-plan.ts +118 -0
- package/extensions/plan-mode/schema.ts +13 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +42 -3
- package/extensions/plan-mode/tools/add-task.ts +26 -4
- package/extensions/plan-mode/tools/plan-status.ts +155 -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-task.ts +50 -10
- package/extensions/plan-mode/types.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
+
import {
|
|
9
|
+
readPlansManifest,
|
|
10
|
+
upsertPlanEntry,
|
|
11
|
+
writePlansManifest,
|
|
12
|
+
} from '../storage/plans-manifest.js';
|
|
13
|
+
import { applyReconcile, collectPlanDrift } from '../reconcile.js';
|
|
14
|
+
import type { TaskMeta, TaskRecord, TaskStatus } from '../types.js';
|
|
15
|
+
|
|
16
|
+
const runPlanIO = makePlanRuntime();
|
|
17
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
18
|
+
|
|
19
|
+
const meta = (name: string): TaskMeta => ({
|
|
20
|
+
_type: 'meta',
|
|
21
|
+
title: `Title ${name}`,
|
|
22
|
+
plan_name: name,
|
|
23
|
+
created_at: now,
|
|
24
|
+
});
|
|
25
|
+
const task = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
|
|
26
|
+
_type: 'task',
|
|
27
|
+
id,
|
|
28
|
+
description: `task ${id}`,
|
|
29
|
+
status,
|
|
30
|
+
origin: 'plan',
|
|
31
|
+
created_at: now,
|
|
32
|
+
updated_at: now,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const originalCwd = process.cwd();
|
|
36
|
+
let dir: string;
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-reconcile-'));
|
|
39
|
+
chdir(dir);
|
|
40
|
+
});
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
chdir(originalCwd);
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('collectPlanDrift', () => {
|
|
47
|
+
test('flags a fully-done plan still registered in-progress as status drift', async () => {
|
|
48
|
+
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
|
|
49
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
50
|
+
|
|
51
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
52
|
+
const alpha = rows.find((r) => r.name === 'alpha')!;
|
|
53
|
+
expect(alpha.drift).toBe('status');
|
|
54
|
+
expect(alpha.derivedStatus).toBe('done');
|
|
55
|
+
expect(alpha.resolved).toBe(1);
|
|
56
|
+
expect(alpha.total).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('reports an in-sync plan with no drift', async () => {
|
|
60
|
+
await runPlanIO(writeTasksJsonl('.plans/beta', meta('beta'), [task('t-001', 'pending')]));
|
|
61
|
+
await runPlanIO(upsertPlanEntry('beta', { status: 'in-progress', title: 'Title beta' }));
|
|
62
|
+
|
|
63
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
64
|
+
expect(rows.find((r) => r.name === 'beta')!.drift).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('flags registry-only plans (no tasks.jsonl)', async () => {
|
|
68
|
+
await runPlanIO(upsertPlanEntry('ghost', { status: 'in-progress', title: 'Ghost' }));
|
|
69
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
70
|
+
const ghost = rows.find((r) => r.name === 'ghost')!;
|
|
71
|
+
expect(ghost.drift).toBe('registry-only');
|
|
72
|
+
expect(ghost.hasTasks).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('flags orphan task dirs (tasks.jsonl, no registry entry)', async () => {
|
|
76
|
+
await runPlanIO(writeTasksJsonl('.plans/orphan', meta('orphan'), [task('t-001', 'done')]));
|
|
77
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
78
|
+
const orphan = rows.find((r) => r.name === 'orphan')!;
|
|
79
|
+
expect(orphan.drift).toBe('orphan');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('never flags a manually-closed terminal status as drift', async () => {
|
|
83
|
+
await runPlanIO(writeTasksJsonl('.plans/sup', meta('sup'), [task('t-001', 'pending')]));
|
|
84
|
+
await runPlanIO(
|
|
85
|
+
writePlansManifest([
|
|
86
|
+
{
|
|
87
|
+
_type: 'plan',
|
|
88
|
+
name: 'sup',
|
|
89
|
+
status: 'superseded',
|
|
90
|
+
title: 'Title sup',
|
|
91
|
+
created_at: now,
|
|
92
|
+
completed_at: now,
|
|
93
|
+
reason: 'absorbed',
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
);
|
|
97
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
98
|
+
expect(rows.find((r) => r.name === 'sup')!.drift).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('applyReconcile', () => {
|
|
103
|
+
test('repairs only status drift and projects derived status into the registry', async () => {
|
|
104
|
+
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'done')]));
|
|
105
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
106
|
+
|
|
107
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
108
|
+
const repaired = await runPlanIO(applyReconcile(rows));
|
|
109
|
+
expect(repaired.map((r) => r.name)).toEqual(['alpha']);
|
|
110
|
+
|
|
111
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
112
|
+
expect(entry.status).toBe('done');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
+
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
9
|
+
import { saveHandoff } from '../storage/plan-storage.js';
|
|
10
|
+
import { PlanModeState } from '../state.js';
|
|
11
|
+
import { resolveActivePlan } from '../resolve-plan.js';
|
|
12
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
13
|
+
|
|
14
|
+
const runPlanIO = makePlanRuntime();
|
|
15
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
16
|
+
|
|
17
|
+
const meta = (name: string): TaskMeta => ({
|
|
18
|
+
_type: 'meta',
|
|
19
|
+
title: `Title ${name}`,
|
|
20
|
+
plan_name: name,
|
|
21
|
+
created_at: now,
|
|
22
|
+
});
|
|
23
|
+
const task = (id: string): TaskRecord => ({
|
|
24
|
+
_type: 'task',
|
|
25
|
+
id,
|
|
26
|
+
description: `task ${id}`,
|
|
27
|
+
status: 'pending',
|
|
28
|
+
origin: 'plan',
|
|
29
|
+
created_at: now,
|
|
30
|
+
updated_at: now,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Minimal pi stub — `state.persist` only needs `appendEntry`. */
|
|
34
|
+
function fakePi() {
|
|
35
|
+
const entries: unknown[] = [];
|
|
36
|
+
return {
|
|
37
|
+
pi: { appendEntry: (_t: string, d: unknown) => entries.push(d) },
|
|
38
|
+
entries,
|
|
39
|
+
} as unknown as {
|
|
40
|
+
pi: Parameters<typeof resolveActivePlan>[1];
|
|
41
|
+
entries: unknown[];
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedPlan(name: string, status: 'in-progress' | 'done', ids: string[]) {
|
|
46
|
+
await runPlanIO(writeTasksJsonl(`.plans/${name}`, meta(name), ids.map(task)));
|
|
47
|
+
await runPlanIO(saveHandoff(`.plans/${name}`, `# Handoff ${name}`));
|
|
48
|
+
await runPlanIO(upsertPlanEntry(name, { status, title: `Title ${name}` }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const originalCwd = process.cwd();
|
|
52
|
+
let dir: string;
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-resolve-'));
|
|
55
|
+
chdir(dir);
|
|
56
|
+
});
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
chdir(originalCwd);
|
|
59
|
+
await rm(dir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('resolveActivePlan', () => {
|
|
63
|
+
test('returns the in-memory plan without touching disk', async () => {
|
|
64
|
+
const state = new PlanModeState();
|
|
65
|
+
state.plan = { title: 'mem', planName: 'mem', handoff: '', tasks: [task('t-001')] };
|
|
66
|
+
const { pi } = fakePi();
|
|
67
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
68
|
+
expect(result.plan?.planName).toBe('mem');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('auto-attaches the single in-progress plan from disk', async () => {
|
|
72
|
+
await seedPlan('alpha', 'in-progress', ['t-001', 't-002']);
|
|
73
|
+
const state = new PlanModeState();
|
|
74
|
+
const { pi } = fakePi();
|
|
75
|
+
|
|
76
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
77
|
+
|
|
78
|
+
expect(result.plan?.planName).toBe('alpha');
|
|
79
|
+
expect(result.plan?.tasks).toHaveLength(2);
|
|
80
|
+
expect(result.plan?.handoff).toBe('# Handoff alpha');
|
|
81
|
+
// Attached into state (data only — execution mode untouched).
|
|
82
|
+
expect(state.planDir).toBe('.plans/alpha');
|
|
83
|
+
expect(state.executing).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('is ambiguous (no attach) when multiple plans are in-progress', async () => {
|
|
87
|
+
await seedPlan('alpha', 'in-progress', ['t-001']);
|
|
88
|
+
await seedPlan('beta', 'in-progress', ['t-001']);
|
|
89
|
+
const state = new PlanModeState();
|
|
90
|
+
const { pi } = fakePi();
|
|
91
|
+
|
|
92
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
93
|
+
|
|
94
|
+
expect(result.plan).toBeUndefined();
|
|
95
|
+
expect(result.candidates.sort()).toEqual(['alpha', 'beta']);
|
|
96
|
+
expect(state.plan).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an explicit name hint wins over an attached in-memory plan (FEEDBACK #7)', async () => {
|
|
100
|
+
// Two plans on disk; a *different* plan is pinned in memory.
|
|
101
|
+
await seedPlan('alpha', 'in-progress', ['t-001']);
|
|
102
|
+
await seedPlan('beta', 'in-progress', ['t-001']);
|
|
103
|
+
const state = new PlanModeState();
|
|
104
|
+
state.plan = { title: 'alpha', planName: 'alpha', handoff: '', tasks: [task('t-001')] };
|
|
105
|
+
state.planDir = '.plans/alpha';
|
|
106
|
+
const { pi } = fakePi();
|
|
107
|
+
|
|
108
|
+
// The explicit hint must re-attach beta, not silently keep alpha.
|
|
109
|
+
const result = await resolveActivePlan(state, pi, runPlanIO, { name: 'beta' });
|
|
110
|
+
|
|
111
|
+
expect(result.plan?.planName).toBe('beta');
|
|
112
|
+
expect(state.plan?.planName).toBe('beta');
|
|
113
|
+
expect(state.planDir).toBe('.plans/beta');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a hint matching the in-memory plan returns it without touching disk', async () => {
|
|
117
|
+
const state = new PlanModeState();
|
|
118
|
+
state.plan = { title: 'mem', planName: 'mem', handoff: '', tasks: [task('t-001')] };
|
|
119
|
+
const { pi } = fakePi();
|
|
120
|
+
const result = await resolveActivePlan(state, pi, runPlanIO, { name: 'mem' });
|
|
121
|
+
expect(result.plan?.planName).toBe('mem');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a name hint disambiguates among multiple in-progress plans', async () => {
|
|
125
|
+
await seedPlan('alpha', 'in-progress', ['t-001']);
|
|
126
|
+
await seedPlan('beta', 'in-progress', ['t-001']);
|
|
127
|
+
const state = new PlanModeState();
|
|
128
|
+
const { pi } = fakePi();
|
|
129
|
+
|
|
130
|
+
const result = await resolveActivePlan(state, pi, runPlanIO, { name: '.plans/beta' });
|
|
131
|
+
|
|
132
|
+
expect(result.plan?.planName).toBe('beta');
|
|
133
|
+
expect(state.planDir).toBe('.plans/beta');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('ignores a done plan when auto-attaching', async () => {
|
|
137
|
+
await seedPlan('alpha', 'done', ['t-001']);
|
|
138
|
+
const state = new PlanModeState();
|
|
139
|
+
const { pi } = fakePi();
|
|
140
|
+
|
|
141
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
142
|
+
|
|
143
|
+
expect(result.plan).toBeUndefined();
|
|
144
|
+
expect(result.candidates).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('returns no plan + no candidates when .plans is empty', async () => {
|
|
148
|
+
const state = new PlanModeState();
|
|
149
|
+
const { pi } = fakePi();
|
|
150
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
151
|
+
expect(result.plan).toBeUndefined();
|
|
152
|
+
expect(result.candidates).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -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,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { registerUpdateTaskTool } from '../tools/update-task.js';
|
|
3
|
+
import type { PlanData, TaskRecord, TaskStatus } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
6
|
+
|
|
7
|
+
interface CapturedTool {
|
|
8
|
+
execute: (
|
|
9
|
+
id: string,
|
|
10
|
+
params: {
|
|
11
|
+
task_id: string;
|
|
12
|
+
status: 'done' | 'skipped' | 'blocked';
|
|
13
|
+
notes?: string;
|
|
14
|
+
plan?: string;
|
|
15
|
+
},
|
|
16
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown; terminate?: boolean }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function setup(plan: PlanData | undefined, candidates: string[] = []) {
|
|
20
|
+
let tool: CapturedTool | undefined;
|
|
21
|
+
const updates: Array<{ taskId: string; status: string; notes?: string }> = [];
|
|
22
|
+
const pi = {
|
|
23
|
+
registerTool: (config: CapturedTool) => {
|
|
24
|
+
tool = config;
|
|
25
|
+
},
|
|
26
|
+
} as unknown as Parameters<typeof registerUpdateTaskTool>[0];
|
|
27
|
+
|
|
28
|
+
registerUpdateTaskTool(pi, {
|
|
29
|
+
resolvePlan: async () => ({ plan, candidates }),
|
|
30
|
+
onTaskUpdated: (taskId, status, notes) => {
|
|
31
|
+
const target = plan?.tasks.find((candidate) => candidate.id === taskId);
|
|
32
|
+
if (target) target.status = status;
|
|
33
|
+
updates.push({ taskId, status, notes });
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return { tool: tool!, updates };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const basePlan = (tasks: TaskRecord[]): PlanData => ({
|
|
41
|
+
title: 'Plan',
|
|
42
|
+
planName: 'plan',
|
|
43
|
+
handoff: '',
|
|
44
|
+
tasks,
|
|
45
|
+
});
|
|
46
|
+
const planTask = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
|
|
47
|
+
_type: 'task',
|
|
48
|
+
id,
|
|
49
|
+
description: `task ${id}`,
|
|
50
|
+
status,
|
|
51
|
+
origin: 'plan',
|
|
52
|
+
created_at: now,
|
|
53
|
+
updated_at: now,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('update_task tool', () => {
|
|
57
|
+
test('marks a pending task and reports progress', async () => {
|
|
58
|
+
const { tool, updates } = setup(basePlan([planTask('t-001'), planTask('t-002')]));
|
|
59
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'done', notes: 'did it' });
|
|
60
|
+
expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: 'did it' }]);
|
|
61
|
+
expect(result.content?.[0]?.text).toMatch(/Progress: 1\/2/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('blocked terminates the turn', async () => {
|
|
65
|
+
const { tool } = setup(basePlan([planTask('t-001')]));
|
|
66
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'blocked' });
|
|
67
|
+
expect(result.terminate).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('soft-skips (no throw) when there is no active plan', async () => {
|
|
71
|
+
const { tool, updates } = setup(undefined, ['alpha', 'beta']);
|
|
72
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
|
|
73
|
+
expect((result.details as { skipped?: boolean }).skipped).toBe(true);
|
|
74
|
+
expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
|
|
75
|
+
expect(updates).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('soft result (no throw) for an unknown task id', async () => {
|
|
79
|
+
const { tool, updates } = setup(basePlan([planTask('t-001')]));
|
|
80
|
+
const result = await tool.execute('c', { task_id: 't-999', status: 'done' });
|
|
81
|
+
expect((result.details as { error?: string }).error).toBe('not_found');
|
|
82
|
+
expect(result.content?.[0]?.text).toMatch(/t-001/);
|
|
83
|
+
expect(updates).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('idempotent: re-marking the same status is a no-op success', async () => {
|
|
87
|
+
const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
|
|
88
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
|
|
89
|
+
expect(result.content?.[0]?.text).toMatch(/no-op/);
|
|
90
|
+
expect(updates).toHaveLength(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('corrects an already-resolved task (done → skipped) and reports it', async () => {
|
|
94
|
+
const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
|
|
95
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'skipped' });
|
|
96
|
+
expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
|
|
97
|
+
expect(result.content?.[0]?.text).toMatch(/corrected \(done → skipped\)/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('unblocks a task (blocked → done) without terminating', async () => {
|
|
101
|
+
const { tool, updates } = setup(basePlan([planTask('t-001', 'blocked')]));
|
|
102
|
+
const result = await tool.execute('c', { task_id: 't-001', status: 'done' });
|
|
103
|
+
expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
|
|
104
|
+
expect(result.terminate).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -15,9 +15,22 @@ export const PLAN_TOOLS = [
|
|
|
15
15
|
'questionnaire',
|
|
16
16
|
'search_skills',
|
|
17
17
|
'subagent',
|
|
18
|
+
'plan_status',
|
|
19
|
+
'update_plan',
|
|
20
|
+
'reconcile_plans',
|
|
18
21
|
];
|
|
19
22
|
|
|
20
|
-
export const EXEC_TOOLS = [
|
|
23
|
+
export const EXEC_TOOLS = [
|
|
24
|
+
'read',
|
|
25
|
+
'bash',
|
|
26
|
+
'edit',
|
|
27
|
+
'write',
|
|
28
|
+
'update_task',
|
|
29
|
+
'add_task',
|
|
30
|
+
'plan_status',
|
|
31
|
+
'update_plan',
|
|
32
|
+
'reconcile_plans',
|
|
33
|
+
];
|
|
21
34
|
|
|
22
35
|
// ── Model + thinking presets ─────────────────────────────────────────────────
|
|
23
36
|
export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
|
|
@@ -31,17 +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
|
+
import { resolveActivePlan } from './resolve-plan.js';
|
|
42
|
+
import { collectPlanDrift } from './reconcile.js';
|
|
41
43
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
42
44
|
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
43
45
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
44
46
|
import { registerAddTaskTool } from './tools/add-task.js';
|
|
47
|
+
import { registerPlanStatusTool } from './tools/plan-status.js';
|
|
48
|
+
import { registerUpdatePlanTool } from './tools/update-plan.js';
|
|
49
|
+
import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
|
|
45
50
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
46
51
|
|
|
47
52
|
export default function planMode(pi: ExtensionAPI): void {
|
|
@@ -68,7 +73,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
68
73
|
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
69
74
|
|
|
70
75
|
registerUpdateTaskTool(pi, {
|
|
71
|
-
|
|
76
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
72
77
|
onTaskUpdated: async (taskId, status, notes) => {
|
|
73
78
|
if (!state.plan || !state.planDir) return;
|
|
74
79
|
const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
|
|
@@ -88,12 +93,40 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
88
93
|
state.plan.tasks,
|
|
89
94
|
),
|
|
90
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
|
+
);
|
|
91
106
|
state.persist(pi);
|
|
92
107
|
},
|
|
93
108
|
});
|
|
94
109
|
|
|
110
|
+
registerPlanStatusTool(pi, {
|
|
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
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
registerUpdatePlanTool(pi, runPlanIO);
|
|
126
|
+
registerReconcilePlansTool(pi, runPlanIO);
|
|
127
|
+
|
|
95
128
|
registerAddTaskTool(pi, {
|
|
96
|
-
|
|
129
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
97
130
|
onTaskAdded: async (task) => {
|
|
98
131
|
if (!state.plan || !state.planDir) return;
|
|
99
132
|
state.plan.tasks.push(task);
|
|
@@ -109,6 +142,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
109
142
|
state.plan.tasks,
|
|
110
143
|
),
|
|
111
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
|
+
);
|
|
112
154
|
state.persist(pi);
|
|
113
155
|
},
|
|
114
156
|
});
|
|
@@ -116,13 +158,33 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
116
158
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
117
159
|
pi.registerCommand('plan', {
|
|
118
160
|
description:
|
|
119
|
-
'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.',
|
|
120
162
|
handler: async (args, ctx) => {
|
|
121
163
|
const trimmed = args?.trim();
|
|
122
164
|
if (trimmed === 'resume') {
|
|
123
165
|
await resumePlan(state, pi, ctx, runPlanIO);
|
|
124
166
|
return;
|
|
125
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
|
+
}
|
|
126
188
|
if (state.planEnabled || state.executing) {
|
|
127
189
|
await exitPlanMode(state, pi, ctx);
|
|
128
190
|
return;
|
|
@@ -477,6 +539,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
477
539
|
}
|
|
478
540
|
}
|
|
479
541
|
|
|
542
|
+
// No plan attached from this session's entries or the exec handoff, but a
|
|
543
|
+
// plan may exist on disk (planning happened in another session). Attach the
|
|
544
|
+
// single in-progress plan so update_task / add_task work without an
|
|
545
|
+
// interactive /plan resume. Data only — does NOT enter execution mode.
|
|
546
|
+
if (!state.plan) {
|
|
547
|
+
await resolveActivePlan(state, pi, runPlanIO);
|
|
548
|
+
}
|
|
549
|
+
|
|
480
550
|
// Apply tool restrictions, model, and thinking level
|
|
481
551
|
if (state.planEnabled) {
|
|
482
552
|
pi.setActiveTools(PLAN_TOOLS);
|