@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.
@@ -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 { PlanData, TaskStatus } from '../types.js';
9
+ import type { TaskStatus } from '../types.js';
10
+ import type { ResolvedPlan } from '../resolve-plan.js';
10
11
 
11
12
  export interface UpdateTaskCallbacks {
12
- getPlan: () => PlanData | undefined;
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.getPlan();
42
- if (!plan) throw new Error('No active plan. Cannot update task.');
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) throw new Error(`Task not found: ${params.task_id}`);
46
- if (task.status !== 'pending') {
47
- throw new Error(
48
- `Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`,
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
- let text = `${statusEmoji} Task ${params.task_id} ${params.status}. Progress: ${resolved}/${plan.tasks.length}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"