@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
|
@@ -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
|
+
}
|
|
@@ -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