@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile_plans tool — detect & repair drift between tasks and registry.
|
|
3
|
+
*
|
|
4
|
+
* Walks every plan, compares the registry `status` against the status derived
|
|
5
|
+
* from `tasks.jsonl`, and reports the diff (FEEDBACK #6). With `apply: true` it
|
|
6
|
+
* repairs the safe `in-progress` ⇄ `done` projection; orphans and
|
|
7
|
+
* registry-only plans are surfaced for a human decision but never auto-changed.
|
|
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 { RunPlanIO } from '../effects/runtime.js';
|
|
14
|
+
import { applyReconcile, collectPlanDrift, type PlanDriftRow } from '../reconcile.js';
|
|
15
|
+
|
|
16
|
+
function describeRow(row: PlanDriftRow): string {
|
|
17
|
+
const progress = row.hasTasks ? ` (${row.resolved}/${row.total})` : '';
|
|
18
|
+
if (row.drift === 'registry-only') {
|
|
19
|
+
return ` ⚠ ${row.name} — registry ${row.registryStatus}, no tasks.jsonl (un-trackable)`;
|
|
20
|
+
}
|
|
21
|
+
if (row.drift === 'orphan') {
|
|
22
|
+
return ` ⚠ ${row.name}${progress} — tasks.jsonl with no registry entry (orphan)`;
|
|
23
|
+
}
|
|
24
|
+
if (row.drift === 'status') {
|
|
25
|
+
return ` ✗ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus}`;
|
|
26
|
+
}
|
|
27
|
+
return ` ✓ ${row.name}${progress} — ${row.registryStatus} (in sync)`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name: 'reconcile_plans',
|
|
33
|
+
label: 'Reconcile Plans',
|
|
34
|
+
description:
|
|
35
|
+
'Walk every plan and report where the registry status disagrees with task state (drift), plus orphan task dirs and registry-only plans. Pass apply:true to repair safe in-progress↔done drift.',
|
|
36
|
+
promptSnippet: 'Detect/repair drift between tasks.jsonl and the plan registry',
|
|
37
|
+
promptGuidelines: [
|
|
38
|
+
'Use reconcile_plans to audit .plans/ when registry status looks stale (e.g. a fully-done plan still in-progress).',
|
|
39
|
+
'Run it read-only first; pass apply:true once you have reviewed the reported drift.',
|
|
40
|
+
],
|
|
41
|
+
parameters: Type.Object({
|
|
42
|
+
apply: Type.Optional(
|
|
43
|
+
Type.Boolean({
|
|
44
|
+
description: 'Repair status-drift by projecting derived task status into the registry.',
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
async execute(_toolCallId, params) {
|
|
50
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
51
|
+
const drifted = rows.filter((row) => row.drift);
|
|
52
|
+
|
|
53
|
+
let repaired: PlanDriftRow[] = [];
|
|
54
|
+
if (params.apply) {
|
|
55
|
+
repaired = await runPlanIO(applyReconcile(rows));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lines = rows.map(describeRow);
|
|
59
|
+
const statusDrift = drifted.filter((r) => r.drift === 'status').length;
|
|
60
|
+
const orphans = drifted.filter((r) => r.drift === 'orphan').length;
|
|
61
|
+
const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
|
|
62
|
+
|
|
63
|
+
const header = params.apply
|
|
64
|
+
? `Reconciled ${repaired.length} plan(s).`
|
|
65
|
+
: drifted.length === 0
|
|
66
|
+
? 'All plans in sync.'
|
|
67
|
+
: `${drifted.length} drift issue(s) found (run with apply:true to repair status drift).`;
|
|
68
|
+
|
|
69
|
+
const summary = [
|
|
70
|
+
`status-drift ${statusDrift}`,
|
|
71
|
+
`orphan ${orphans}`,
|
|
72
|
+
`registry-only ${registryOnly}`,
|
|
73
|
+
].join(', ');
|
|
74
|
+
|
|
75
|
+
const text = `${header}\n${summary}\n${lines.join('\n')}`;
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: 'text' as const, text }],
|
|
78
|
+
details: {
|
|
79
|
+
applied: Boolean(params.apply),
|
|
80
|
+
repaired: repaired.map((r) => r.name),
|
|
81
|
+
drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
82
|
+
total: rows.length,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
renderCall(args, theme) {
|
|
88
|
+
const apply = (args as { apply?: boolean }).apply;
|
|
89
|
+
let content = theme.fg('toolTitle', theme.bold('reconcile_plans'));
|
|
90
|
+
if (apply) content += ' ' + theme.fg('warning', 'apply');
|
|
91
|
+
return new Text(content, 0, 0);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
renderResult(result, _options, theme) {
|
|
95
|
+
const details = result.details as
|
|
96
|
+
| { applied?: boolean; repaired?: string[]; drift?: unknown[]; total?: number }
|
|
97
|
+
| undefined;
|
|
98
|
+
if (!details) return new Text(theme.fg('dim', 'Reconciled'), 0, 0);
|
|
99
|
+
const driftCount = details.drift?.length ?? 0;
|
|
100
|
+
if (details.applied) {
|
|
101
|
+
return new Text(
|
|
102
|
+
theme.fg('success', `✓ repaired ${details.repaired?.length ?? 0}`) +
|
|
103
|
+
theme.fg('muted', ` / ${details.total ?? 0} plans`),
|
|
104
|
+
0,
|
|
105
|
+
0,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return new Text(
|
|
109
|
+
driftCount === 0
|
|
110
|
+
? theme.fg('success', `✓ ${details.total ?? 0} plans in sync`)
|
|
111
|
+
: theme.fg('warning', `${driftCount} drift`) +
|
|
112
|
+
theme.fg('muted', ` / ${details.total ?? 0} plans`),
|
|
113
|
+
0,
|
|
114
|
+
0,
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update_plan tool — plan-level lifecycle control.
|
|
3
|
+
*
|
|
4
|
+
* `update_task` tracks task status; this is its plan-level counterpart
|
|
5
|
+
* (FEEDBACK #2/#3). It lets an agent or user close a plan without a full
|
|
6
|
+
* execution run or hand-editing `.plans/plans.jsonl`:
|
|
7
|
+
* - done — completed (work shipped)
|
|
8
|
+
* - superseded — another plan absorbed the work
|
|
9
|
+
* - abandoned — won't do / rejected
|
|
10
|
+
* - in-progress — reopen
|
|
11
|
+
* `reason` is recorded for honest history (esp. superseded/abandoned).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
15
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
16
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
17
|
+
import { Type } from 'typebox';
|
|
18
|
+
import type { PlanStatus } from '../types.js';
|
|
19
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
20
|
+
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
21
|
+
|
|
22
|
+
/** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
|
|
23
|
+
function normalizeName(hint: string): string {
|
|
24
|
+
return hint
|
|
25
|
+
.replace(/^\.plans\//, '')
|
|
26
|
+
.replace(/\/+$/, '')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerUpdatePlanTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name: 'update_plan',
|
|
33
|
+
label: 'Update Plan',
|
|
34
|
+
description:
|
|
35
|
+
'Set a plan-level status (done, superseded, abandoned, or reopen to in-progress) with an optional reason. Use to close a plan without a full execution run instead of hand-editing the registry.',
|
|
36
|
+
promptSnippet: 'Close or reopen a plan (done/superseded/abandoned/in-progress) with a reason',
|
|
37
|
+
promptGuidelines: [
|
|
38
|
+
'Use update_plan to close a plan that will not be executed to completion: superseded (another plan shipped it) or abandoned (rejected / won\u2019t do).',
|
|
39
|
+
'Always pass a reason for superseded/abandoned so the registry keeps honest history.',
|
|
40
|
+
'Prefer update_task for per-task progress; update_plan is for the whole plan\u2019s lifecycle.',
|
|
41
|
+
],
|
|
42
|
+
parameters: Type.Object({
|
|
43
|
+
plan: Type.String({ description: 'Plan name (or .plans/<name>) to update' }),
|
|
44
|
+
status: StringEnum(['in-progress', 'done', 'superseded', 'abandoned'] as const),
|
|
45
|
+
reason: Type.Optional(
|
|
46
|
+
Type.String({ description: 'Why — recorded in the registry (esp. superseded/abandoned)' }),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
async execute(_toolCallId, params) {
|
|
51
|
+
const name = normalizeName(params.plan);
|
|
52
|
+
const manifest = await runPlanIO(readPlansManifest());
|
|
53
|
+
const existing = manifest.find((entry) => entry.name === name);
|
|
54
|
+
if (!existing) {
|
|
55
|
+
const names = manifest.map((entry) => entry.name).join(', ');
|
|
56
|
+
const notFound: Record<string, unknown> = { error: 'not_found', plan: name };
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: 'text' as const,
|
|
61
|
+
text: `Plan not found: ${name}. Known plans: ${names || '(none)'}`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
details: notFound,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await runPlanIO(
|
|
69
|
+
upsertPlanEntry(name, {
|
|
70
|
+
status: params.status as PlanStatus,
|
|
71
|
+
title: existing.title,
|
|
72
|
+
reason: params.reason,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const reasonSuffix = params.reason ? ` — ${params.reason}` : '';
|
|
77
|
+
const okDetails: Record<string, unknown> = {
|
|
78
|
+
plan: name,
|
|
79
|
+
from: existing.status,
|
|
80
|
+
status: params.status,
|
|
81
|
+
reason: params.reason,
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: 'text' as const,
|
|
87
|
+
text: `Plan ${name}: ${existing.status} → ${params.status}${reasonSuffix}`,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
details: okDetails,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
renderCall(args, theme) {
|
|
95
|
+
const name = (args as { plan?: string }).plan ?? 'plan';
|
|
96
|
+
const status = (args as { status?: string }).status ?? '';
|
|
97
|
+
let content = theme.fg('toolTitle', theme.bold('update_plan '));
|
|
98
|
+
content += theme.fg('accent', name);
|
|
99
|
+
if (status) content += ' ' + theme.fg('muted', status);
|
|
100
|
+
return new Text(content, 0, 0);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
renderResult(result, _options, theme) {
|
|
104
|
+
const details = result.details as
|
|
105
|
+
| { plan?: string; from?: string; status?: string }
|
|
106
|
+
| undefined;
|
|
107
|
+
if (!details?.status) return new Text(theme.fg('dim', 'Plan updated'), 0, 0);
|
|
108
|
+
const color =
|
|
109
|
+
details.status === 'done'
|
|
110
|
+
? 'success'
|
|
111
|
+
: details.status === 'in-progress'
|
|
112
|
+
? 'accent'
|
|
113
|
+
: 'warning';
|
|
114
|
+
return new Text(
|
|
115
|
+
theme.fg('muted', `${details.plan ?? 'plan'} `) +
|
|
116
|
+
theme.fg('dim', `${details.from ?? ''} → `) +
|
|
117
|
+
theme.fg(color, details.status),
|
|
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
|
|
|
@@ -7,6 +7,12 @@ export type TaskStatus = 'pending' | 'done' | 'skipped' | 'blocked' | 'deferred'
|
|
|
7
7
|
/** Where a task came from: the original submitted plan, or discovered during execution. */
|
|
8
8
|
export type TaskOrigin = 'plan' | 'discovered';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Plan lifecycle status. Only `in-progress` is active; `done`, `superseded`,
|
|
12
|
+
* and `abandoned` are terminal and drop out of active-plan resolution.
|
|
13
|
+
*/
|
|
14
|
+
export type PlanStatus = 'in-progress' | 'done' | 'superseded' | 'abandoned';
|
|
15
|
+
|
|
10
16
|
export interface TaskRecord {
|
|
11
17
|
_type: 'task';
|
|
12
18
|
id: string;
|
package/package.json
CHANGED