@dreki-gg/pi-plan-mode 0.17.1 → 0.18.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 +14 -0
- package/extensions/plan-mode/__tests__/add-task.test.ts +16 -8
- package/extensions/plan-mode/__tests__/plan-status.test.ts +68 -0
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +129 -0
- package/extensions/plan-mode/__tests__/update-task.test.ts +106 -0
- package/extensions/plan-mode/constants.ts +10 -1
- package/extensions/plan-mode/index.ts +16 -2
- package/extensions/plan-mode/resolve-plan.ts +104 -0
- package/extensions/plan-mode/tools/add-task.ts +26 -4
- package/extensions/plan-mode/tools/plan-status.ts +123 -0
- package/extensions/plan-mode/tools/update-task.ts +50 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Make `update_task` / `add_task` usable across sessions and resilient for autonomous agents.
|
|
8
|
+
|
|
9
|
+
The active plan was session-scoped: `update_task` / `add_task` only worked when a plan was submitted in the same session, restored from its entries, or handed off via the one-shot exec-pending marker. An agent executing an existing `.plans/<name>/` in a fresh session (the common plan-here / execute-there flow) hit a hard `No active plan` throw.
|
|
10
|
+
|
|
11
|
+
- **Disk-backed resolution** (`resolveActivePlan`): when nothing is attached in memory, the active plan is resolved from `.plans/plans.jsonl` — the sole in-progress plan auto-attaches (data only; does NOT enter execution mode / change tools / model). Wired into `session_start` and both tracking tools.
|
|
12
|
+
- **No hard throws on tracking calls**: `update_task` / `add_task` now return soft, non-terminating results (no active plan, unknown task id, already-resolved task) so a tracking miss never derails the real work. `update_task` is idempotent — re-marking the same status is a no-op success.
|
|
13
|
+
- **`plan` parameter**: both tools accept an optional `plan` (name or `.plans/<name>`) to disambiguate without the interactive `/plan resume` when multiple plans are in-progress.
|
|
14
|
+
- **`update_task` corrections**: a different status on an already-resolved task now applies as a correction (e.g. `done`→`skipped`, or `blocked`→`done` to unblock) and is reported as such, instead of being refused.
|
|
15
|
+
- **New `plan_status` tool**: read-only snapshot of the active plan — progress counts + every task id/status — so an agent can check what's active and which ids are valid (disk-backed; works in a fresh execution session) instead of probing with a failing `update_task`. Added to both tool sets.
|
|
16
|
+
|
|
3
17
|
## 0.17.1
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -7,8 +7,14 @@ const now = '2026-05-27T12:00:00.000Z';
|
|
|
7
7
|
interface CapturedTool {
|
|
8
8
|
execute: (
|
|
9
9
|
id: string,
|
|
10
|
-
params: {
|
|
11
|
-
|
|
10
|
+
params: {
|
|
11
|
+
description: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
details?: string;
|
|
14
|
+
depends_on?: string[];
|
|
15
|
+
plan?: string;
|
|
16
|
+
},
|
|
17
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function setup(plan: PlanData | undefined) {
|
|
@@ -21,7 +27,8 @@ function setup(plan: PlanData | undefined) {
|
|
|
21
27
|
} as unknown as Parameters<typeof registerAddTaskTool>[0];
|
|
22
28
|
|
|
23
29
|
registerAddTaskTool(pi, {
|
|
24
|
-
|
|
30
|
+
// Mirrors index.ts: in-memory plan wins; disk fallback returns candidates.
|
|
31
|
+
resolvePlan: async () => ({ plan, candidates: [] }),
|
|
25
32
|
onTaskAdded: (task) => {
|
|
26
33
|
added.push(task);
|
|
27
34
|
},
|
|
@@ -66,10 +73,11 @@ describe('add_task tool', () => {
|
|
|
66
73
|
expect(task.description).toBe('Extract shared helper');
|
|
67
74
|
});
|
|
68
75
|
|
|
69
|
-
test('
|
|
70
|
-
const { tool } = setup(undefined);
|
|
71
|
-
await
|
|
72
|
-
|
|
73
|
-
);
|
|
76
|
+
test('soft-skips (does not throw) when there is no active plan', async () => {
|
|
77
|
+
const { tool, added } = setup(undefined);
|
|
78
|
+
const result = await tool.execute('call-1', { description: 'x', reason: 'y' });
|
|
79
|
+
expect((result.details as { skipped?: boolean }).skipped).toBe(true);
|
|
80
|
+
expect(result.content?.[0]?.text).toMatch(/no active plan/i);
|
|
81
|
+
expect(added).toHaveLength(0);
|
|
74
82
|
});
|
|
75
83
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { registerPlanStatusTool } from '../tools/plan-status.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: { plan?: string },
|
|
11
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function setup(plan: PlanData | undefined, candidates: string[] = []) {
|
|
15
|
+
let tool: CapturedTool | undefined;
|
|
16
|
+
const pi = {
|
|
17
|
+
registerTool: (config: CapturedTool) => {
|
|
18
|
+
tool = config;
|
|
19
|
+
},
|
|
20
|
+
} as unknown as Parameters<typeof registerPlanStatusTool>[0];
|
|
21
|
+
registerPlanStatusTool(pi, { resolvePlan: async () => ({ plan, candidates }) });
|
|
22
|
+
return tool!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const planTask = (id: string, status: TaskStatus): 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
|
+
describe('plan_status tool', () => {
|
|
36
|
+
test('summarizes progress + lists task ids and statuses', async () => {
|
|
37
|
+
const plan: PlanData = {
|
|
38
|
+
title: 'My Plan',
|
|
39
|
+
planName: 'my-plan',
|
|
40
|
+
handoff: '',
|
|
41
|
+
tasks: [
|
|
42
|
+
planTask('t-001', 'done'),
|
|
43
|
+
planTask('t-002', 'pending'),
|
|
44
|
+
planTask('t-003', 'blocked'),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const tool = setup(plan);
|
|
48
|
+
const result = await tool.execute('c', {});
|
|
49
|
+
|
|
50
|
+
const text = result.content?.[0]?.text ?? '';
|
|
51
|
+
expect(text).toMatch(/My Plan \(my-plan\)/);
|
|
52
|
+
expect(text).toMatch(/1\/3 resolved/);
|
|
53
|
+
expect(text).toMatch(/blocked 1/);
|
|
54
|
+
expect(text).toMatch(/t-002 \[pending\]/);
|
|
55
|
+
|
|
56
|
+
const details = result.details as { active: boolean; task_ids: string[]; total: number };
|
|
57
|
+
expect(details.active).toBe(true);
|
|
58
|
+
expect(details.task_ids).toEqual(['t-001', 't-002', 't-003']);
|
|
59
|
+
expect(details.total).toBe(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('reports no active plan (read-only, no throw) with candidates', async () => {
|
|
63
|
+
const tool = setup(undefined, ['alpha', 'beta']);
|
|
64
|
+
const result = await tool.execute('c', {});
|
|
65
|
+
expect((result.details as { active: boolean }).active).toBe(false);
|
|
66
|
+
expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
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('a name hint disambiguates among multiple in-progress plans', async () => {
|
|
100
|
+
await seedPlan('alpha', 'in-progress', ['t-001']);
|
|
101
|
+
await seedPlan('beta', 'in-progress', ['t-001']);
|
|
102
|
+
const state = new PlanModeState();
|
|
103
|
+
const { pi } = fakePi();
|
|
104
|
+
|
|
105
|
+
const result = await resolveActivePlan(state, pi, runPlanIO, { name: '.plans/beta' });
|
|
106
|
+
|
|
107
|
+
expect(result.plan?.planName).toBe('beta');
|
|
108
|
+
expect(state.planDir).toBe('.plans/beta');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('ignores a done plan when auto-attaching', async () => {
|
|
112
|
+
await seedPlan('alpha', 'done', ['t-001']);
|
|
113
|
+
const state = new PlanModeState();
|
|
114
|
+
const { pi } = fakePi();
|
|
115
|
+
|
|
116
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
117
|
+
|
|
118
|
+
expect(result.plan).toBeUndefined();
|
|
119
|
+
expect(result.candidates).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('returns no plan + no candidates when .plans is empty', async () => {
|
|
123
|
+
const state = new PlanModeState();
|
|
124
|
+
const { pi } = fakePi();
|
|
125
|
+
const result = await resolveActivePlan(state, pi, runPlanIO);
|
|
126
|
+
expect(result.plan).toBeUndefined();
|
|
127
|
+
expect(result.candidates).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -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,18 @@ export const PLAN_TOOLS = [
|
|
|
15
15
|
'questionnaire',
|
|
16
16
|
'search_skills',
|
|
17
17
|
'subagent',
|
|
18
|
+
'plan_status',
|
|
18
19
|
];
|
|
19
20
|
|
|
20
|
-
export const EXEC_TOOLS = [
|
|
21
|
+
export const EXEC_TOOLS = [
|
|
22
|
+
'read',
|
|
23
|
+
'bash',
|
|
24
|
+
'edit',
|
|
25
|
+
'write',
|
|
26
|
+
'update_task',
|
|
27
|
+
'add_task',
|
|
28
|
+
'plan_status',
|
|
29
|
+
];
|
|
21
30
|
|
|
22
31
|
// ── Model + thinking presets ─────────────────────────────────────────────────
|
|
23
32
|
export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
|
|
@@ -38,10 +38,12 @@ import { filterExecutionMessages, filterStalePlanMessages } from './context-filt
|
|
|
38
38
|
import { activeTasksResolved, deferredTasks } 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';
|
|
41
42
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
42
43
|
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
43
44
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
44
45
|
import { registerAddTaskTool } from './tools/add-task.js';
|
|
46
|
+
import { registerPlanStatusTool } from './tools/plan-status.js';
|
|
45
47
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
46
48
|
|
|
47
49
|
export default function planMode(pi: ExtensionAPI): void {
|
|
@@ -68,7 +70,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
68
70
|
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
69
71
|
|
|
70
72
|
registerUpdateTaskTool(pi, {
|
|
71
|
-
|
|
73
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
72
74
|
onTaskUpdated: async (taskId, status, notes) => {
|
|
73
75
|
if (!state.plan || !state.planDir) return;
|
|
74
76
|
const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
|
|
@@ -92,8 +94,12 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
92
94
|
},
|
|
93
95
|
});
|
|
94
96
|
|
|
97
|
+
registerPlanStatusTool(pi, {
|
|
98
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
99
|
+
});
|
|
100
|
+
|
|
95
101
|
registerAddTaskTool(pi, {
|
|
96
|
-
|
|
102
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
97
103
|
onTaskAdded: async (task) => {
|
|
98
104
|
if (!state.plan || !state.planDir) return;
|
|
99
105
|
state.plan.tasks.push(task);
|
|
@@ -477,6 +483,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
477
483
|
}
|
|
478
484
|
}
|
|
479
485
|
|
|
486
|
+
// No plan attached from this session's entries or the exec handoff, but a
|
|
487
|
+
// plan may exist on disk (planning happened in another session). Attach the
|
|
488
|
+
// single in-progress plan so update_task / add_task work without an
|
|
489
|
+
// interactive /plan resume. Data only — does NOT enter execution mode.
|
|
490
|
+
if (!state.plan) {
|
|
491
|
+
await resolveActivePlan(state, pi, runPlanIO);
|
|
492
|
+
}
|
|
493
|
+
|
|
480
494
|
// Apply tool restrictions, model, and thinking level
|
|
481
495
|
if (state.planEnabled) {
|
|
482
496
|
pi.setActiveTools(PLAN_TOOLS);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-backed active-plan resolution.
|
|
3
|
+
*
|
|
4
|
+
* `state.plan` is session-scoped: it is only populated when a plan is submitted
|
|
5
|
+
* in *this* session, restored from this session's entries, or handed off via
|
|
6
|
+
* the one-shot exec-pending file. But `.plans/<name>/tasks.jsonl` is the real
|
|
7
|
+
* source of truth, and execution routinely happens in a different session than
|
|
8
|
+
* planning. This bridges that gap: when nothing is attached in memory, resolve
|
|
9
|
+
* the plan from disk so `update_task` / `add_task` work without an interactive
|
|
10
|
+
* `/plan resume`.
|
|
11
|
+
*
|
|
12
|
+
* Attaching only loads the plan DATA into `state` (so tracking writes land in
|
|
13
|
+
* `tasks.jsonl`); it intentionally does NOT flip `executing` / tools / model —
|
|
14
|
+
* that stays the user's explicit choice via `/plan-exec` or `/plan resume`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
18
|
+
import type { PlanModeState } from './state.js';
|
|
19
|
+
import type { PlanData } from './types.js';
|
|
20
|
+
import type { RunPlanIO } from './effects/runtime.js';
|
|
21
|
+
import { readPlansManifest } from './storage/plans-manifest.js';
|
|
22
|
+
import { readTasksJsonl } from './storage/task-storage.js';
|
|
23
|
+
import { loadHandoff } from './storage/plan-storage.js';
|
|
24
|
+
|
|
25
|
+
export interface ResolvedPlan {
|
|
26
|
+
/** The attached plan, when resolvable. Already written into `state`. */
|
|
27
|
+
plan?: PlanData;
|
|
28
|
+
/**
|
|
29
|
+
* In-progress plan names, surfaced when resolution was ambiguous (multiple
|
|
30
|
+
* in-progress and no usable `name` hint) or a hint missed. Lets a caller
|
|
31
|
+
* report actionable choices instead of dead-ending.
|
|
32
|
+
*/
|
|
33
|
+
candidates: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
|
|
37
|
+
function normalizeName(hint: string): string {
|
|
38
|
+
return hint
|
|
39
|
+
.replace(/^\.plans\//, '')
|
|
40
|
+
.replace(/\/+$/, '')
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Load `.plans/<name>` from disk into `state` (data only). Returns the plan,
|
|
45
|
+
* or undefined when the tasks file is missing/empty. */
|
|
46
|
+
async function attach(
|
|
47
|
+
state: PlanModeState,
|
|
48
|
+
pi: ExtensionAPI,
|
|
49
|
+
runPlanIO: RunPlanIO,
|
|
50
|
+
name: string,
|
|
51
|
+
): Promise<PlanData | undefined> {
|
|
52
|
+
const dir = `.plans/${name}`;
|
|
53
|
+
const snapshot = await runPlanIO(readTasksJsonl(dir));
|
|
54
|
+
if (!snapshot) return undefined;
|
|
55
|
+
const plan: PlanData = {
|
|
56
|
+
title: snapshot.meta.title,
|
|
57
|
+
planName: snapshot.meta.plan_name,
|
|
58
|
+
handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
|
|
59
|
+
tasks: snapshot.tasks,
|
|
60
|
+
};
|
|
61
|
+
state.plan = plan;
|
|
62
|
+
state.planDir = dir;
|
|
63
|
+
state.persist(pi);
|
|
64
|
+
return plan;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the active plan, attaching from disk when nothing is in memory.
|
|
69
|
+
*
|
|
70
|
+
* Order: in-memory `state.plan` → explicit `name` hint → the single
|
|
71
|
+
* in-progress plan in `.plans/plans.jsonl`. Ambiguous (multiple in-progress,
|
|
72
|
+
* no hint) returns `{ plan: undefined, candidates }` so the caller can prompt
|
|
73
|
+
* for a `name`.
|
|
74
|
+
*/
|
|
75
|
+
export async function resolveActivePlan(
|
|
76
|
+
state: PlanModeState,
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
runPlanIO: RunPlanIO,
|
|
79
|
+
opts: { name?: string } = {},
|
|
80
|
+
): Promise<ResolvedPlan> {
|
|
81
|
+
if (state.plan) return { plan: state.plan, candidates: [] };
|
|
82
|
+
|
|
83
|
+
const manifest = await runPlanIO(readPlansManifest());
|
|
84
|
+
|
|
85
|
+
if (opts.name) {
|
|
86
|
+
const hint = normalizeName(opts.name);
|
|
87
|
+
const match = manifest.find((entry) => entry.name === hint);
|
|
88
|
+
// A hint that names a real plan attaches regardless of status (the caller
|
|
89
|
+
// asked for it explicitly); a hint that names nothing falls through to the
|
|
90
|
+
// ambiguity report so the caller sees the valid choices.
|
|
91
|
+
if (match) {
|
|
92
|
+
const plan = await attach(state, pi, runPlanIO, match.name);
|
|
93
|
+
if (plan) return { plan, candidates: [] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
|
|
98
|
+
if (inProgress.length === 1) {
|
|
99
|
+
const plan = await attach(state, pi, runPlanIO, inProgress[0]!.name);
|
|
100
|
+
if (plan) return { plan, candidates: [] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
|
|
104
|
+
}
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import { Text } from '@earendil-works/pi-tui';
|
|
11
11
|
import { Type } from 'typebox';
|
|
12
|
-
import type {
|
|
12
|
+
import type { TaskRecord } from '../types.js';
|
|
13
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
13
14
|
import { nextTaskId } from '../utils.js';
|
|
14
15
|
|
|
15
16
|
export interface AddTaskCallbacks {
|
|
16
|
-
|
|
17
|
+
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
18
|
+
resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
|
|
17
19
|
onTaskAdded: (task: TaskRecord) => void | Promise<void>;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -38,11 +40,31 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
|
|
|
38
40
|
Type.String({ description: 'Optional fuller implementation notes for the follow-up' }),
|
|
39
41
|
),
|
|
40
42
|
depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
|
|
43
|
+
plan: Type.Optional(
|
|
44
|
+
Type.String({
|
|
45
|
+
description:
|
|
46
|
+
'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress.',
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
41
49
|
}),
|
|
42
50
|
|
|
43
51
|
async execute(_toolCallId, params) {
|
|
44
|
-
const plan = callbacks.
|
|
45
|
-
|
|
52
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
53
|
+
// No active plan is a tracking miss, not an error — return a soft,
|
|
54
|
+
// non-terminating result so real work continues.
|
|
55
|
+
if (!plan) {
|
|
56
|
+
const hint =
|
|
57
|
+
candidates.length > 1
|
|
58
|
+
? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
|
|
59
|
+
: ' No in-progress plan found in .plans/plans.jsonl.';
|
|
60
|
+
const details: Record<string, unknown> = { skipped: true, candidates };
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{ type: 'text' as const, text: `Skipped follow-up capture — no active plan.${hint}` },
|
|
64
|
+
],
|
|
65
|
+
details,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
46
68
|
|
|
47
69
|
const now = new Date().toISOString();
|
|
48
70
|
const task: TaskRecord = {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan_status tool — read-only snapshot of the active plan.
|
|
3
|
+
*
|
|
4
|
+
* Lets an agent proactively learn whether a plan is active, its progress, and
|
|
5
|
+
* the valid task ids — instead of discovering that by a failed `update_task`.
|
|
6
|
+
* Resolves disk-backed (attaches the sole in-progress plan), so it works in a
|
|
7
|
+
* fresh execution session. Pure read: never mutates plan state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
12
|
+
import { Type } from 'typebox';
|
|
13
|
+
import type { TaskStatus } from '../types.js';
|
|
14
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
15
|
+
|
|
16
|
+
export interface PlanStatusCallbacks {
|
|
17
|
+
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
18
|
+
resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUS_GLYPH: Record<TaskStatus, string> = {
|
|
22
|
+
done: '✓',
|
|
23
|
+
skipped: '⊘',
|
|
24
|
+
blocked: '✗',
|
|
25
|
+
pending: '○',
|
|
26
|
+
deferred: '+',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCallbacks): void {
|
|
30
|
+
pi.registerTool({
|
|
31
|
+
name: 'plan_status',
|
|
32
|
+
label: 'Plan Status',
|
|
33
|
+
description:
|
|
34
|
+
'Read-only snapshot of the active plan: progress counts and every task id + status. Use to check whether a plan is active and what the valid task ids are before calling update_task.',
|
|
35
|
+
promptSnippet: 'Show the active plan: progress + task ids/statuses',
|
|
36
|
+
promptGuidelines: [
|
|
37
|
+
'Call plan_status when unsure whether a plan is active or which task ids exist — it is read-only and never mutates state.',
|
|
38
|
+
'Prefer it over guessing task ids; the returned ids are what update_task expects.',
|
|
39
|
+
],
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
plan: Type.Optional(
|
|
42
|
+
Type.String({
|
|
43
|
+
description:
|
|
44
|
+
'Plan name (or .plans/<name>) to inspect. Only needed to disambiguate when multiple plans are in-progress.',
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
async execute(_toolCallId, params) {
|
|
50
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
51
|
+
if (!plan) {
|
|
52
|
+
const hint =
|
|
53
|
+
candidates.length > 1
|
|
54
|
+
? ` In-progress plans: ${candidates.join(', ')} — pass { plan: "<name>" }.`
|
|
55
|
+
: ' No in-progress plan found in .plans/plans.jsonl.';
|
|
56
|
+
const noPlanDetails: Record<string, unknown> = { active: false, candidates };
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text' as const, text: `No active plan.${hint}` }],
|
|
59
|
+
details: noPlanDetails,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const counts: Record<TaskStatus, number> = {
|
|
64
|
+
done: 0,
|
|
65
|
+
skipped: 0,
|
|
66
|
+
blocked: 0,
|
|
67
|
+
pending: 0,
|
|
68
|
+
deferred: 0,
|
|
69
|
+
};
|
|
70
|
+
for (const task of plan.tasks) counts[task.status] += 1;
|
|
71
|
+
const resolved = counts.done + counts.skipped;
|
|
72
|
+
|
|
73
|
+
const parts = [
|
|
74
|
+
`done ${counts.done}`,
|
|
75
|
+
`skipped ${counts.skipped}`,
|
|
76
|
+
`pending ${counts.pending}`,
|
|
77
|
+
];
|
|
78
|
+
if (counts.blocked) parts.push(`blocked ${counts.blocked}`);
|
|
79
|
+
if (counts.deferred) parts.push(`follow-up ${counts.deferred}`);
|
|
80
|
+
|
|
81
|
+
const lines = plan.tasks.map(
|
|
82
|
+
(task) => ` ${STATUS_GLYPH[task.status]} ${task.id} [${task.status}] ${task.description}`,
|
|
83
|
+
);
|
|
84
|
+
const text =
|
|
85
|
+
`Plan: ${plan.title} (${plan.planName})\n` +
|
|
86
|
+
`Progress: ${resolved}/${plan.tasks.length} resolved — ${parts.join(', ')}\n` +
|
|
87
|
+
`Tasks:\n${lines.join('\n')}`;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: 'text' as const, text }],
|
|
91
|
+
details: {
|
|
92
|
+
active: true,
|
|
93
|
+
plan_name: plan.planName,
|
|
94
|
+
title: plan.title,
|
|
95
|
+
total: plan.tasks.length,
|
|
96
|
+
counts,
|
|
97
|
+
task_ids: plan.tasks.map((task) => task.id),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
renderCall(args, theme) {
|
|
103
|
+
const name = (args as { plan?: string }).plan;
|
|
104
|
+
let content = theme.fg('toolTitle', theme.bold('plan_status'));
|
|
105
|
+
if (name) content += ' ' + theme.fg('muted', name);
|
|
106
|
+
return new Text(content, 0, 0);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
renderResult(result, _options, theme) {
|
|
110
|
+
const details = result.details as
|
|
111
|
+
| { active?: boolean; plan_name?: string; total?: number; counts?: Record<string, number> }
|
|
112
|
+
| undefined;
|
|
113
|
+
if (!details?.active) return new Text(theme.fg('dim', 'No active plan'), 0, 0);
|
|
114
|
+
const resolved = (details.counts?.done ?? 0) + (details.counts?.skipped ?? 0);
|
|
115
|
+
return new Text(
|
|
116
|
+
theme.fg('toolTitle', `${details.plan_name ?? 'plan'} `) +
|
|
117
|
+
theme.fg('muted', `${resolved}/${details.total ?? 0} resolved`),
|
|
118
|
+
0,
|
|
119
|
+
0,
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -6,10 +6,12 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
6
6
|
import { StringEnum } from '@earendil-works/pi-ai';
|
|
7
7
|
import { Text } from '@earendil-works/pi-tui';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
|
-
import type {
|
|
9
|
+
import type { TaskStatus } from '../types.js';
|
|
10
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
10
11
|
|
|
11
12
|
export interface UpdateTaskCallbacks {
|
|
12
|
-
|
|
13
|
+
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
14
|
+
resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
|
|
13
15
|
onTaskUpdated: (
|
|
14
16
|
taskId: string,
|
|
15
17
|
status: Exclude<TaskStatus, 'pending'>,
|
|
@@ -17,6 +19,11 @@ export interface UpdateTaskCallbacks {
|
|
|
17
19
|
) => void | Promise<void>;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** A non-terminating tool result — task tracking must never derail real work. */
|
|
23
|
+
function soft(text: string, details: Record<string, unknown>) {
|
|
24
|
+
return { content: [{ type: 'text' as const, text }], details };
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
|
|
21
28
|
pi.registerTool({
|
|
22
29
|
name: 'update_task',
|
|
@@ -35,19 +42,51 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
35
42
|
notes: Type.Optional(
|
|
36
43
|
Type.String({ description: 'What was done, why skipped, or why blocked' }),
|
|
37
44
|
),
|
|
45
|
+
plan: Type.Optional(
|
|
46
|
+
Type.String({
|
|
47
|
+
description:
|
|
48
|
+
'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress; otherwise the active / sole in-progress plan is used.',
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
38
51
|
}),
|
|
39
52
|
|
|
40
53
|
async execute(_toolCallId, params) {
|
|
41
|
-
const plan = callbacks.
|
|
42
|
-
|
|
54
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
55
|
+
// No active plan is a tracking miss, not an error: return a soft result
|
|
56
|
+
// (non-terminating) so the agent keeps doing the real work.
|
|
57
|
+
if (!plan) {
|
|
58
|
+
const hint =
|
|
59
|
+
candidates.length > 1
|
|
60
|
+
? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
|
|
61
|
+
: ' No in-progress plan found in .plans/plans.jsonl.';
|
|
62
|
+
return soft(`Skipped task tracking — no active plan.${hint}`, {
|
|
63
|
+
skipped: true,
|
|
64
|
+
candidates,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
43
67
|
|
|
44
68
|
const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
|
|
45
|
-
if (!task)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
if (!task) {
|
|
70
|
+
const ids = plan.tasks.map((candidate) => candidate.id).join(', ');
|
|
71
|
+
return soft(`Task not found: ${params.task_id}. Valid ids: ${ids || '(none)'}`, {
|
|
72
|
+
error: 'not_found',
|
|
73
|
+
task_id: params.task_id,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Idempotent: re-marking the same status is a no-op success (safe to
|
|
77
|
+
// retry).
|
|
78
|
+
if (task.status === params.status) {
|
|
79
|
+
return soft(`Task ${params.task_id} already ${params.status} (no-op).`, {
|
|
80
|
+
task_id: params.task_id,
|
|
81
|
+
status: params.status,
|
|
82
|
+
description: task.description,
|
|
83
|
+
});
|
|
50
84
|
}
|
|
85
|
+
// A different status on an already-resolved task is a CORRECTION — apply
|
|
86
|
+
// it (e.g. done→skipped, or blocked→done to unblock). The status is the
|
|
87
|
+
// edit; the plan queue recomputes from it.
|
|
88
|
+
const wasCorrection = task.status !== 'pending';
|
|
89
|
+
const priorStatus = task.status;
|
|
51
90
|
|
|
52
91
|
await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
|
|
53
92
|
|
|
@@ -75,7 +114,8 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
|
|
|
75
114
|
const resolved = done + skipped;
|
|
76
115
|
const next = plan.tasks.find((candidate) => candidate.status === 'pending');
|
|
77
116
|
const statusEmoji = params.status === 'done' ? '✓' : '⊘';
|
|
78
|
-
|
|
117
|
+
const verb = wasCorrection ? `corrected (${priorStatus} → ${params.status})` : params.status;
|
|
118
|
+
let text = `${statusEmoji} Task ${params.task_id} ${verb}. Progress: ${resolved}/${plan.tasks.length}`;
|
|
79
119
|
if (params.notes) text += ` — ${params.notes}`;
|
|
80
120
|
text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
|
|
81
121
|
|
package/package.json
CHANGED