@dreki-gg/pi-plan-mode 0.18.0 → 0.20.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 +25 -0
- package/README.md +46 -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/__tests__/update-tasks.test.ts +130 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/index.ts +114 -11
- 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/tools/update-tasks.ts +172 -0
- package/extensions/plan-mode/types.ts +6 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { Effect, Either, Option } from 'effect';
|
|
|
2
2
|
import { FileSystem } from '../effects/filesystem.js';
|
|
3
3
|
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
4
4
|
import { decodePlanManifestEntry } from '../schema.js';
|
|
5
|
+
import type { PlanStatus } from '../types.js';
|
|
5
6
|
|
|
6
7
|
const MANIFEST_DIR = '.plans';
|
|
7
8
|
const MANIFEST_PATH = '.plans/plans.jsonl';
|
|
@@ -9,10 +10,16 @@ const MANIFEST_PATH = '.plans/plans.jsonl';
|
|
|
9
10
|
export interface PlanManifestEntry {
|
|
10
11
|
_type: 'plan';
|
|
11
12
|
name: string;
|
|
12
|
-
status:
|
|
13
|
+
status: PlanStatus;
|
|
13
14
|
title: string;
|
|
14
15
|
created_at: string;
|
|
15
16
|
completed_at: string | null;
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
21
|
+
export function isTerminalStatus(status: PlanStatus): boolean {
|
|
22
|
+
return status !== 'in-progress';
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
type ReadError = JsonlParseError | JsonlValidationError;
|
|
@@ -62,7 +69,7 @@ export function writePlansManifest(
|
|
|
62
69
|
|
|
63
70
|
export function upsertPlanEntry(
|
|
64
71
|
name: string,
|
|
65
|
-
updates: { status:
|
|
72
|
+
updates: { status: PlanStatus; title?: string; reason?: string },
|
|
66
73
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
67
74
|
return Effect.gen(function* () {
|
|
68
75
|
const entries = yield* readPlansManifest();
|
|
@@ -75,10 +82,42 @@ export function upsertPlanEntry(
|
|
|
75
82
|
status: updates.status,
|
|
76
83
|
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
77
84
|
created_at: existing?.created_at ?? now,
|
|
78
|
-
|
|
85
|
+
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
86
|
+
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
87
|
+
reason: updates.reason ?? existing?.reason,
|
|
79
88
|
};
|
|
80
89
|
if (index === -1) entries.push(entry);
|
|
81
90
|
else entries[index] = entry;
|
|
82
91
|
yield* writePlansManifest(entries);
|
|
83
92
|
});
|
|
84
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reconcile a plan's registry status from its task state.
|
|
97
|
+
*
|
|
98
|
+
* The registry `status` is a PROJECTION of task state, not a parallel flag.
|
|
99
|
+
* Call this wherever tasks are written so completion is never coupled to a
|
|
100
|
+
* formal in-session execution run (see FEEDBACK #1). `finalizable` means every
|
|
101
|
+
* active task is resolved AND no deferred follow-ups remain.
|
|
102
|
+
*
|
|
103
|
+
* Guard: a manually-set terminal status (`superseded` / `abandoned`) is never
|
|
104
|
+
* auto-overridden — only `in-progress` ⇄ `done` is derived from tasks.
|
|
105
|
+
*/
|
|
106
|
+
export function reconcilePlanStatus(
|
|
107
|
+
name: string,
|
|
108
|
+
finalizable: boolean,
|
|
109
|
+
title?: string,
|
|
110
|
+
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
111
|
+
return Effect.gen(function* () {
|
|
112
|
+
const entries = yield* readPlansManifest();
|
|
113
|
+
const existing = entries.find((entry) => entry.name === name);
|
|
114
|
+
// Reconcile only reflects task state for KNOWN plans; never conjure an
|
|
115
|
+
// entry for an unregistered plan (orphans are surfaced, not auto-created).
|
|
116
|
+
if (!existing) return;
|
|
117
|
+
// Do not resurrect / clobber an explicitly closed plan.
|
|
118
|
+
if (existing.status === 'superseded' || existing.status === 'abandoned') return;
|
|
119
|
+
const status: PlanStatus = finalizable ? 'done' : 'in-progress';
|
|
120
|
+
if (existing.status === status) return; // no change
|
|
121
|
+
yield* upsertPlanEntry(name, { status, title });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -13,9 +13,22 @@ import { Type } from 'typebox';
|
|
|
13
13
|
import type { TaskStatus } from '../types.js';
|
|
14
14
|
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
15
15
|
|
|
16
|
+
export interface InProgressSummary {
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
resolved: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export interface PlanStatusCallbacks {
|
|
17
24
|
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
18
25
|
resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
|
|
26
|
+
/**
|
|
27
|
+
* List every in-progress plan with progress counts. Used to render a
|
|
28
|
+
* progress-at-a-glance table when no single plan can be resolved
|
|
29
|
+
* (multiple in-progress) — surfaces drift (e.g. a 17/17 plan still listed).
|
|
30
|
+
*/
|
|
31
|
+
listInProgress?: () => Promise<InProgressSummary[]>;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
const STATUS_GLYPH: Record<TaskStatus, string> = {
|
|
@@ -49,6 +62,25 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
|
|
|
49
62
|
async execute(_toolCallId, params) {
|
|
50
63
|
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
51
64
|
if (!plan) {
|
|
65
|
+
// Multiple in-progress plans: show a progress-at-a-glance table rather
|
|
66
|
+
// than a bare name list (FEEDBACK #5). The counts surface drift too —
|
|
67
|
+
// a fully-resolved plan still listed in-progress is a reconcile cue.
|
|
68
|
+
if (candidates.length > 1 && callbacks.listInProgress) {
|
|
69
|
+
const summaries = await callbacks.listInProgress();
|
|
70
|
+
const rows = summaries
|
|
71
|
+
.map((s) => {
|
|
72
|
+
const flag = s.total > 0 && s.resolved === s.total ? ' ⚠ done?' : '';
|
|
73
|
+
return ` ${s.resolved}/${s.total} ${s.name} — ${s.title}${flag}`;
|
|
74
|
+
})
|
|
75
|
+
.join('\n');
|
|
76
|
+
const text =
|
|
77
|
+
`No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } to target one.\n` +
|
|
78
|
+
`Progress:\n${rows}`;
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: 'text' as const, text }],
|
|
81
|
+
details: { active: false, candidates, in_progress: summaries },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
52
84
|
const hint =
|
|
53
85
|
candidates.length > 1
|
|
54
86
|
? ` In-progress plans: ${candidates.join(', ')} — pass { plan: "<name>" }.`
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update_tasks tool — batch sibling of update_task.
|
|
3
|
+
*
|
|
4
|
+
* Marks several tasks done/skipped in a single call. Unlike update_task it
|
|
5
|
+
* never blocks (blocking pauses the turn, which is ambiguous mid-batch) and
|
|
6
|
+
* therefore never terminates. Each item mirrors update_task's per-task
|
|
7
|
+
* semantics: idempotent no-ops, corrections, and soft not_found handling.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
12
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
13
|
+
import { Type } from 'typebox';
|
|
14
|
+
import type { TaskStatus } from '../types.js';
|
|
15
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
16
|
+
|
|
17
|
+
export interface UpdateTasksCallbacks {
|
|
18
|
+
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
19
|
+
resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
|
|
20
|
+
/**
|
|
21
|
+
* Apply ALL accepted updates in one shot. The implementation mutates the
|
|
22
|
+
* in-memory tasks, then performs a SINGLE coalesced tasks.jsonl write and
|
|
23
|
+
* registry reconcile — avoiding the per-task write storm that caused file
|
|
24
|
+
* write contention with repeated update_task calls.
|
|
25
|
+
*/
|
|
26
|
+
onTasksUpdated: (
|
|
27
|
+
updates: Array<{
|
|
28
|
+
taskId: string;
|
|
29
|
+
status: Exclude<TaskStatus, 'pending' | 'blocked' | 'deferred'>;
|
|
30
|
+
notes?: string;
|
|
31
|
+
}>,
|
|
32
|
+
) => void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A non-terminating tool result — task tracking must never derail real work. */
|
|
36
|
+
function soft(text: string, details: Record<string, unknown>) {
|
|
37
|
+
return { content: [{ type: 'text' as const, text }], details };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type ItemOutcome = 'updated' | 'noop' | 'not_found';
|
|
41
|
+
|
|
42
|
+
interface ItemResult {
|
|
43
|
+
task_id: string;
|
|
44
|
+
outcome: ItemOutcome;
|
|
45
|
+
status?: 'done' | 'skipped';
|
|
46
|
+
prior?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerUpdateTasksTool(pi: ExtensionAPI, callbacks: UpdateTasksCallbacks): void {
|
|
50
|
+
pi.registerTool({
|
|
51
|
+
name: 'update_tasks',
|
|
52
|
+
label: 'Update Tasks',
|
|
53
|
+
description:
|
|
54
|
+
'Mark several plan tasks done or skipped in one call. Use when you finished multiple tasks in a turn. Cannot block — use update_task for that.',
|
|
55
|
+
promptSnippet: 'Mark multiple plan tasks done or skipped in one call',
|
|
56
|
+
promptGuidelines: [
|
|
57
|
+
'Use update_tasks to resolve several finished tasks at once instead of repeated update_task calls.',
|
|
58
|
+
'Each item is { task_id, status: done|skipped, notes? }. To block a task, use update_task instead.',
|
|
59
|
+
'Always include notes summarizing what was done or why skipped.',
|
|
60
|
+
],
|
|
61
|
+
parameters: Type.Object({
|
|
62
|
+
updates: Type.Array(
|
|
63
|
+
Type.Object({
|
|
64
|
+
task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
|
|
65
|
+
status: StringEnum(['done', 'skipped'] as const),
|
|
66
|
+
notes: Type.Optional(
|
|
67
|
+
Type.String({ description: 'What was done or why skipped' }),
|
|
68
|
+
),
|
|
69
|
+
}),
|
|
70
|
+
{ description: 'Tasks to mark, each with its own status and notes', minItems: 1 },
|
|
71
|
+
),
|
|
72
|
+
plan: Type.Optional(
|
|
73
|
+
Type.String({
|
|
74
|
+
description:
|
|
75
|
+
'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.',
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
async execute(_toolCallId, params) {
|
|
81
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
82
|
+
// No active plan is a tracking miss, not an error: return a soft result
|
|
83
|
+
// (non-terminating) so the agent keeps doing the real work.
|
|
84
|
+
if (!plan) {
|
|
85
|
+
const hint =
|
|
86
|
+
candidates.length > 1
|
|
87
|
+
? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
|
|
88
|
+
: ' No in-progress plan found in .plans/plans.jsonl.';
|
|
89
|
+
return soft(`Skipped task tracking — no active plan.${hint}`, {
|
|
90
|
+
skipped: true,
|
|
91
|
+
candidates,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const results: ItemResult[] = [];
|
|
96
|
+
const accepted: Array<{ taskId: string; status: 'done' | 'skipped'; notes?: string }> = [];
|
|
97
|
+
for (const update of params.updates) {
|
|
98
|
+
const task = plan.tasks.find((candidate) => candidate.id === update.task_id);
|
|
99
|
+
if (!task) {
|
|
100
|
+
results.push({ task_id: update.task_id, outcome: 'not_found' });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Idempotent: re-marking the same status is a no-op (safe to retry).
|
|
104
|
+
if (task.status === update.status) {
|
|
105
|
+
results.push({ task_id: update.task_id, outcome: 'noop', status: update.status });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A different status on an already-resolved task is a CORRECTION — apply
|
|
109
|
+
// it. The plan queue recomputes from the new status.
|
|
110
|
+
results.push({
|
|
111
|
+
task_id: update.task_id,
|
|
112
|
+
outcome: 'updated',
|
|
113
|
+
status: update.status,
|
|
114
|
+
prior: task.status,
|
|
115
|
+
});
|
|
116
|
+
accepted.push({ taskId: update.task_id, status: update.status, notes: update.notes });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// SINGLE coalesced write: apply every accepted change in one callback,
|
|
120
|
+
// which performs exactly one tasks.jsonl write + one registry reconcile.
|
|
121
|
+
if (accepted.length > 0) await callbacks.onTasksUpdated(accepted);
|
|
122
|
+
|
|
123
|
+
const updated = results.filter((r) => r.outcome === 'updated');
|
|
124
|
+
const noop = results.filter((r) => r.outcome === 'noop');
|
|
125
|
+
const notFound = results.filter((r) => r.outcome === 'not_found');
|
|
126
|
+
|
|
127
|
+
const done = plan.tasks.filter((candidate) => candidate.status === 'done').length;
|
|
128
|
+
const skipped = plan.tasks.filter((candidate) => candidate.status === 'skipped').length;
|
|
129
|
+
const resolved = done + skipped;
|
|
130
|
+
const next = plan.tasks.find((candidate) => candidate.status === 'pending');
|
|
131
|
+
|
|
132
|
+
const parts = [`Updated ${updated.length} task(s).`];
|
|
133
|
+
if (noop.length) parts.push(`${noop.length} no-op.`);
|
|
134
|
+
if (notFound.length) {
|
|
135
|
+
const ids = plan.tasks.map((candidate) => candidate.id).join(', ');
|
|
136
|
+
parts.push(`Not found: ${notFound.map((r) => r.task_id).join(', ')} (valid: ${ids}).`);
|
|
137
|
+
}
|
|
138
|
+
let text = parts.join(' ');
|
|
139
|
+
text += ` Progress: ${resolved}/${plan.tasks.length}`;
|
|
140
|
+
text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
|
|
141
|
+
|
|
142
|
+
// Never terminate: update_tasks has no blocked path, and completion is
|
|
143
|
+
// handled out-of-band by the agent_end handler in index.ts.
|
|
144
|
+
return { content: [{ type: 'text' as const, text }], details: { results } };
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
renderCall(args, theme) {
|
|
148
|
+
const updates = (args as { updates?: Array<{ task_id?: string }> }).updates ?? [];
|
|
149
|
+
let content = theme.fg('toolTitle', theme.bold('update_tasks '));
|
|
150
|
+
content += theme.fg('muted', updates.map((u) => u.task_id ?? '?').join(', '));
|
|
151
|
+
return new Text(content, 0, 0);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
renderResult(result, _options, theme) {
|
|
155
|
+
const details = result.details as { results?: ItemResult[] } | undefined;
|
|
156
|
+
const results = details?.results ?? [];
|
|
157
|
+
if (results.length === 0) return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
158
|
+
const icon: Record<string, string> = {
|
|
159
|
+
done: theme.fg('success', '✓'),
|
|
160
|
+
skipped: theme.fg('warning', '⊘'),
|
|
161
|
+
};
|
|
162
|
+
const line = results
|
|
163
|
+
.map((r) => {
|
|
164
|
+
if (r.outcome === 'not_found') return theme.fg('error', `✗ ${r.task_id}`);
|
|
165
|
+
if (r.outcome === 'noop') return theme.fg('dim', `= ${r.task_id}`);
|
|
166
|
+
return `${icon[r.status ?? ''] ?? ''} ${r.task_id}`;
|
|
167
|
+
})
|
|
168
|
+
.join(' ');
|
|
169
|
+
return new Text(line, 0, 0);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -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