@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,141 @@
1
+ /**
2
+ * Drift detection + repair between `tasks.jsonl` reality and registry status.
3
+ *
4
+ * Drift happens in both directions (FEEDBACK #6):
5
+ * - tasks all done but registry `in-progress` (completion never recorded), and
6
+ * - registry `in-progress`/`done` disagreeing with task state generally.
7
+ *
8
+ * It also surfaces two un-trackable classes:
9
+ * - registry-only plans (an entry with no `tasks.jsonl` directory), and
10
+ * - orphan task dirs (a `tasks.jsonl` with no registry entry).
11
+ *
12
+ * `collectPlanDrift` is a pure read; `applyReconcile` repairs only the safe
13
+ * `in-progress` ⇄ `done` projection and never touches terminal statuses.
14
+ */
15
+
16
+ import { Effect } from 'effect';
17
+ import { FileSystem } from './effects/filesystem.js';
18
+ import type {
19
+ JsonlParseError,
20
+ JsonlValidationError,
21
+ MissingMetaRecord,
22
+ PlanWriteError,
23
+ } from './errors.js';
24
+ import { readPlansManifest, reconcilePlanStatus } from './storage/plans-manifest.js';
25
+ import { readTasksJsonl } from './storage/task-storage.js';
26
+ import { isPlanFinalizable } from './task-status.js';
27
+ import type { PlanStatus } from './types.js';
28
+
29
+ const PLANS_DIR = '.plans';
30
+
31
+ export interface PlanDriftRow {
32
+ name: string;
33
+ /** Registry status, or `undefined` when there is a task dir but no entry. */
34
+ registryStatus?: PlanStatus;
35
+ title?: string;
36
+ /** Derived from tasks: `done` when finalizable, else `in-progress`. */
37
+ derivedStatus?: 'in-progress' | 'done';
38
+ /** Resolved/total task counts when a tasks.jsonl exists. */
39
+ resolved?: number;
40
+ total?: number;
41
+ /** True when a `tasks.jsonl` snapshot was found for this plan. */
42
+ hasTasks: boolean;
43
+ /**
44
+ * Drift class:
45
+ * - 'status' : registry status disagrees with derived task status
46
+ * - 'registry-only' : registry entry but no tasks.jsonl dir
47
+ * - 'orphan' : tasks.jsonl dir but no registry entry
48
+ * - undefined : in sync
49
+ */
50
+ drift?: 'status' | 'registry-only' | 'orphan';
51
+ }
52
+
53
+ type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
54
+
55
+ /** Walk every plan (registry + task dirs) and classify drift. Pure read. */
56
+ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError, FileSystem> {
57
+ return Effect.gen(function* () {
58
+ const fs = yield* FileSystem;
59
+ const manifest = yield* readPlansManifest();
60
+ const dirs = yield* Effect.orElseSucceed(fs.listDirectories(PLANS_DIR), () => [] as string[]);
61
+ // Ignore dotfile dirs like `.archive`.
62
+ const taskDirs = new Set(dirs.filter((name) => !name.startsWith('.')));
63
+
64
+ const rows: PlanDriftRow[] = [];
65
+ const seen = new Set<string>();
66
+
67
+ for (const entry of manifest) {
68
+ seen.add(entry.name);
69
+ const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${entry.name}`);
70
+ if (!snapshot) {
71
+ rows.push({
72
+ name: entry.name,
73
+ registryStatus: entry.status,
74
+ title: entry.title,
75
+ hasTasks: false,
76
+ drift: 'registry-only',
77
+ });
78
+ continue;
79
+ }
80
+ const total = snapshot.tasks.length;
81
+ const resolved = snapshot.tasks.filter(
82
+ (t) => t.status === 'done' || t.status === 'skipped',
83
+ ).length;
84
+ const derivedStatus = isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress';
85
+ // Terminal statuses (superseded/abandoned) are intentional — never drift.
86
+ const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
87
+ const drift = !isTerminalManual && entry.status !== derivedStatus ? 'status' : undefined;
88
+ rows.push({
89
+ name: entry.name,
90
+ registryStatus: entry.status,
91
+ title: entry.title,
92
+ derivedStatus,
93
+ resolved,
94
+ total,
95
+ hasTasks: true,
96
+ drift,
97
+ });
98
+ }
99
+
100
+ // Orphan task dirs: have tasks.jsonl but no registry entry.
101
+ for (const name of taskDirs) {
102
+ if (seen.has(name)) continue;
103
+ const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${name}`);
104
+ if (!snapshot) continue;
105
+ const total = snapshot.tasks.length;
106
+ const resolved = snapshot.tasks.filter(
107
+ (t) => t.status === 'done' || t.status === 'skipped',
108
+ ).length;
109
+ rows.push({
110
+ name,
111
+ title: snapshot.meta.title,
112
+ derivedStatus: isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress',
113
+ resolved,
114
+ total,
115
+ hasTasks: true,
116
+ drift: 'orphan',
117
+ });
118
+ }
119
+
120
+ return rows;
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Repair `status`-class drift by projecting derived status into the registry.
126
+ * Orphans and registry-only rows are reported but not auto-fixed (they need a
127
+ * human decision). Returns the rows that were repaired.
128
+ */
129
+ export function applyReconcile(
130
+ rows: PlanDriftRow[],
131
+ ): Effect.Effect<PlanDriftRow[], CollectError | PlanWriteError, FileSystem> {
132
+ return Effect.gen(function* () {
133
+ const repaired: PlanDriftRow[] = [];
134
+ for (const row of rows) {
135
+ if (row.drift !== 'status' || !row.derivedStatus) continue;
136
+ yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
137
+ repaired.push(row);
138
+ }
139
+ return repaired;
140
+ });
141
+ }
@@ -0,0 +1,118 @@
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: explicit `name` hint → in-memory `state.plan` → 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
+ * IMPORTANT (FEEDBACK #7): an explicit `name` hint ALWAYS wins over the
76
+ * in-memory `state.plan`. Previously `state.plan` short-circuited first, so a
77
+ * deliberate `{ plan: '<other>' }` was silently ignored and writes landed in
78
+ * whatever plan the last `submit_plan` attached — a data-corruption path. The
79
+ * hint is now resolved first; when it names a different plan we re-attach from
80
+ * disk so subsequent writes target the right `tasks.jsonl`.
81
+ */
82
+ export async function resolveActivePlan(
83
+ state: PlanModeState,
84
+ pi: ExtensionAPI,
85
+ runPlanIO: RunPlanIO,
86
+ opts: { name?: string } = {},
87
+ ): Promise<ResolvedPlan> {
88
+ // ── Explicit hint wins, even over an attached in-memory plan ──────────────
89
+ if (opts.name) {
90
+ const hint = normalizeName(opts.name);
91
+ // Already the attached plan? Return the in-memory copy (freshest task state).
92
+ if (state.plan && state.plan.planName === hint) return { plan: state.plan, candidates: [] };
93
+
94
+ const manifest = await runPlanIO(readPlansManifest());
95
+ const match = manifest.find((entry) => entry.name === hint);
96
+ // A hint that names a real plan attaches regardless of status (the caller
97
+ // asked for it explicitly); a hint that names nothing falls through to the
98
+ // ambiguity report so the caller sees the valid choices.
99
+ if (match) {
100
+ const plan = await attach(state, pi, runPlanIO, match.name);
101
+ if (plan) return { plan, candidates: [] };
102
+ }
103
+ const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
104
+ return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
105
+ }
106
+
107
+ // ── No hint: in-memory plan, else the single in-progress plan on disk ─────
108
+ if (state.plan) return { plan: state.plan, candidates: [] };
109
+
110
+ const manifest = await runPlanIO(readPlansManifest());
111
+ const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
112
+ if (inProgress.length === 1) {
113
+ const plan = await attach(state, pi, runPlanIO, inProgress[0]!.name);
114
+ if (plan) return { plan, candidates: [] };
115
+ }
116
+
117
+ return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
118
+ }
@@ -36,13 +36,25 @@ export const TaskMetaSchema = Schema.Struct({
36
36
  /** A single tasks.jsonl line is either the meta record or a task record. */
37
37
  export const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
38
38
 
39
+ /**
40
+ * Plan lifecycle statuses.
41
+ * - in-progress: active, tracked, eligible for auto-resolution
42
+ * - done: completed (all tasks resolved)
43
+ * - superseded: closed because another plan absorbed the work
44
+ * - abandoned: closed without shipping (rejected / won't do)
45
+ * Only `in-progress` is treated as active; the rest are terminal.
46
+ */
47
+ export const PlanStatusSchema = Schema.Literal('in-progress', 'done', 'superseded', 'abandoned');
48
+
39
49
  export const PlanManifestEntrySchema = Schema.Struct({
40
50
  _type: Schema.Literal('plan'),
41
51
  name: Schema.String,
42
- status: Schema.Literal('in-progress', 'done'),
52
+ status: PlanStatusSchema,
43
53
  title: Schema.String,
44
54
  created_at: Schema.String,
45
55
  completed_at: Schema.NullOr(Schema.String),
56
+ /** Optional human-readable reason, used for terminal statuses. */
57
+ reason: Schema.optional(Schema.String),
46
58
  });
47
59
 
48
60
  export const ExecPendingConfigSchema = Schema.Struct({
@@ -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: 'in-progress' | 'done';
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: 'in-progress' | 'done'; title?: string },
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
- completed_at: updates.status === 'done' ? now : null,
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
+ }
@@ -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 { PlanData, TaskRecord } from '../types.js';
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
- getPlan: () => PlanData | undefined;
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.getPlan();
45
- if (!plan) throw new Error('No active plan. Cannot add a follow-up task.');
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,155 @@
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 InProgressSummary {
17
+ name: string;
18
+ title: string;
19
+ resolved: number;
20
+ total: number;
21
+ }
22
+
23
+ export interface PlanStatusCallbacks {
24
+ /** Resolve the active plan, attaching from disk when none is in memory. */
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[]>;
32
+ }
33
+
34
+ const STATUS_GLYPH: Record<TaskStatus, string> = {
35
+ done: '✓',
36
+ skipped: '⊘',
37
+ blocked: '✗',
38
+ pending: '○',
39
+ deferred: '+',
40
+ };
41
+
42
+ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCallbacks): void {
43
+ pi.registerTool({
44
+ name: 'plan_status',
45
+ label: 'Plan Status',
46
+ description:
47
+ '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.',
48
+ promptSnippet: 'Show the active plan: progress + task ids/statuses',
49
+ promptGuidelines: [
50
+ 'Call plan_status when unsure whether a plan is active or which task ids exist — it is read-only and never mutates state.',
51
+ 'Prefer it over guessing task ids; the returned ids are what update_task expects.',
52
+ ],
53
+ parameters: Type.Object({
54
+ plan: Type.Optional(
55
+ Type.String({
56
+ description:
57
+ 'Plan name (or .plans/<name>) to inspect. Only needed to disambiguate when multiple plans are in-progress.',
58
+ }),
59
+ ),
60
+ }),
61
+
62
+ async execute(_toolCallId, params) {
63
+ const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
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
+ }
84
+ const hint =
85
+ candidates.length > 1
86
+ ? ` In-progress plans: ${candidates.join(', ')} — pass { plan: "<name>" }.`
87
+ : ' No in-progress plan found in .plans/plans.jsonl.';
88
+ const noPlanDetails: Record<string, unknown> = { active: false, candidates };
89
+ return {
90
+ content: [{ type: 'text' as const, text: `No active plan.${hint}` }],
91
+ details: noPlanDetails,
92
+ };
93
+ }
94
+
95
+ const counts: Record<TaskStatus, number> = {
96
+ done: 0,
97
+ skipped: 0,
98
+ blocked: 0,
99
+ pending: 0,
100
+ deferred: 0,
101
+ };
102
+ for (const task of plan.tasks) counts[task.status] += 1;
103
+ const resolved = counts.done + counts.skipped;
104
+
105
+ const parts = [
106
+ `done ${counts.done}`,
107
+ `skipped ${counts.skipped}`,
108
+ `pending ${counts.pending}`,
109
+ ];
110
+ if (counts.blocked) parts.push(`blocked ${counts.blocked}`);
111
+ if (counts.deferred) parts.push(`follow-up ${counts.deferred}`);
112
+
113
+ const lines = plan.tasks.map(
114
+ (task) => ` ${STATUS_GLYPH[task.status]} ${task.id} [${task.status}] ${task.description}`,
115
+ );
116
+ const text =
117
+ `Plan: ${plan.title} (${plan.planName})\n` +
118
+ `Progress: ${resolved}/${plan.tasks.length} resolved — ${parts.join(', ')}\n` +
119
+ `Tasks:\n${lines.join('\n')}`;
120
+
121
+ return {
122
+ content: [{ type: 'text' as const, text }],
123
+ details: {
124
+ active: true,
125
+ plan_name: plan.planName,
126
+ title: plan.title,
127
+ total: plan.tasks.length,
128
+ counts,
129
+ task_ids: plan.tasks.map((task) => task.id),
130
+ },
131
+ };
132
+ },
133
+
134
+ renderCall(args, theme) {
135
+ const name = (args as { plan?: string }).plan;
136
+ let content = theme.fg('toolTitle', theme.bold('plan_status'));
137
+ if (name) content += ' ' + theme.fg('muted', name);
138
+ return new Text(content, 0, 0);
139
+ },
140
+
141
+ renderResult(result, _options, theme) {
142
+ const details = result.details as
143
+ | { active?: boolean; plan_name?: string; total?: number; counts?: Record<string, number> }
144
+ | undefined;
145
+ if (!details?.active) return new Text(theme.fg('dim', 'No active plan'), 0, 0);
146
+ const resolved = (details.counts?.done ?? 0) + (details.counts?.skipped ?? 0);
147
+ return new Text(
148
+ theme.fg('toolTitle', `${details.plan_name ?? 'plan'} `) +
149
+ theme.fg('muted', `${resolved}/${details.total ?? 0} resolved`),
150
+ 0,
151
+ 0,
152
+ );
153
+ },
154
+ });
155
+ }