@dreki-gg/pi-plan-mode 0.23.0 → 0.24.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +48 -5
  3. package/bin/clean-plans.js +67 -49
  4. package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +85 -0
  5. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
  6. package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
  7. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
  8. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
  9. package/extensions/plan-mode/__tests__/reconcile.test.ts +75 -1
  10. package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
  11. package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
  12. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
  13. package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
  14. package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
  15. package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
  16. package/extensions/plan-mode/constants.ts +5 -0
  17. package/extensions/plan-mode/index.ts +22 -0
  18. package/extensions/plan-mode/initiative.ts +172 -0
  19. package/extensions/plan-mode/prompts.ts +4 -0
  20. package/extensions/plan-mode/reconcile.ts +96 -2
  21. package/extensions/plan-mode/schema.ts +28 -0
  22. package/extensions/plan-mode/storage/file-lock.ts +49 -0
  23. package/extensions/plan-mode/storage/initiatives-manifest.ts +154 -0
  24. package/extensions/plan-mode/storage/plan-storage.ts +11 -0
  25. package/extensions/plan-mode/storage/plans-manifest.ts +76 -25
  26. package/extensions/plan-mode/storage/task-storage.ts +20 -14
  27. package/extensions/plan-mode/tools/initiative-status.ts +191 -0
  28. package/extensions/plan-mode/tools/reconcile-plans.ts +46 -8
  29. package/extensions/plan-mode/tools/revise-plan.ts +22 -0
  30. package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
  31. package/extensions/plan-mode/tools/submit-plan.ts +37 -4
  32. package/extensions/plan-mode/tools/update-initiative.ts +120 -0
  33. package/extensions/plan-mode/tools/update-plan.ts +3 -0
  34. package/extensions/plan-mode/types.ts +3 -0
  35. package/package.json +1 -1
  36. package/skills/planning-context/SKILL.md +11 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `.plans/initiatives.jsonl` registry — the initiative-level sibling of
3
+ * `plans-manifest.ts`.
4
+ *
5
+ * An initiative groups multiple plans. Its `status` is a PROJECTION of its
6
+ * member plans' statuses (see `reconcileInitiativeStatus` in `../initiative.ts`
7
+ * for the projection wiring): `done` when every member plan is terminal,
8
+ * `in-progress` otherwise. Manually-set terminal statuses (`superseded` /
9
+ * `abandoned` via `update_initiative`) are never auto-overridden.
10
+ *
11
+ * This module is intentionally dependency-light: it knows how to read/write the
12
+ * registry. The projection (which must read the PLANS manifest) lives in
13
+ * `../initiative.ts` to keep the dependency direction one-way and cycle-free.
14
+ */
15
+
16
+ import { Effect, Either, Option } from 'effect';
17
+ import { FileSystem } from '../effects/filesystem.js';
18
+ import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
19
+ import { decodeInitiativeManifestEntry } from '../schema.js';
20
+ import type { InitiativeStatus } from '../types.js';
21
+ import { withFileLock } from './file-lock.js';
22
+
23
+ const MANIFEST_DIR = '.plans';
24
+ const MANIFEST_PATH = '.plans/initiatives.jsonl';
25
+
26
+ export interface InitiativeManifestEntry {
27
+ _type: 'initiative';
28
+ name: string;
29
+ status: InitiativeStatus;
30
+ title: string;
31
+ created_at: string;
32
+ completed_at: string | null;
33
+ reason?: string;
34
+ }
35
+
36
+ /** A status is terminal (closed) when it is anything other than in-progress. */
37
+ export function isTerminalStatus(status: InitiativeStatus): boolean {
38
+ return status !== 'in-progress';
39
+ }
40
+
41
+ type ReadError = JsonlParseError | JsonlValidationError;
42
+
43
+ export function readInitiativesManifest(): Effect.Effect<
44
+ InitiativeManifestEntry[],
45
+ ReadError,
46
+ FileSystem
47
+ > {
48
+ return Effect.gen(function* () {
49
+ const fs = yield* FileSystem;
50
+ // A missing or unreadable manifest is treated as "no initiatives".
51
+ const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
52
+ if (Option.isNone(maybeText)) return [];
53
+
54
+ const entries: InitiativeManifestEntry[] = [];
55
+ for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
56
+ if (!raw.trim()) continue;
57
+ const line = index + 1;
58
+
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ } catch (cause) {
63
+ return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
64
+ }
65
+
66
+ const decoded = decodeInitiativeManifestEntry(parsed);
67
+ if (Either.isLeft(decoded)) {
68
+ return yield* Effect.fail(
69
+ new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
70
+ );
71
+ }
72
+ entries.push(decoded.right);
73
+ }
74
+ return entries;
75
+ });
76
+ }
77
+
78
+ export function writeInitiativesManifest(
79
+ entries: InitiativeManifestEntry[],
80
+ ): Effect.Effect<void, PlanWriteError, FileSystem> {
81
+ return Effect.gen(function* () {
82
+ const fs = yield* FileSystem;
83
+ yield* fs.makeDir(MANIFEST_DIR);
84
+ const content =
85
+ entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
86
+ yield* fs.writeFileAtomic(MANIFEST_PATH, content);
87
+ });
88
+ }
89
+
90
+ export interface InitiativeUpsert {
91
+ status: InitiativeStatus;
92
+ title?: string;
93
+ reason?: string;
94
+ }
95
+
96
+ /**
97
+ * Pure transform: upsert `name` into the in-memory `entries` array, preserving
98
+ * created_at from any existing entry. No IO — shared by the locked
99
+ * `upsertInitiativeEntry` and `reconcileInitiativeStatus` so both flow through
100
+ * one serialized read-modify-write and never nest locks.
101
+ */
102
+ export function applyInitiativeUpsert(
103
+ entries: InitiativeManifestEntry[],
104
+ name: string,
105
+ updates: InitiativeUpsert,
106
+ ): void {
107
+ const now = new Date().toISOString();
108
+ const index = entries.findIndex((entry) => entry.name === name);
109
+ const existing = index === -1 ? undefined : entries[index];
110
+ const entry: InitiativeManifestEntry = {
111
+ _type: 'initiative',
112
+ name,
113
+ status: updates.status,
114
+ title: updates.title ?? existing?.title ?? 'Untitled initiative',
115
+ created_at: existing?.created_at ?? now,
116
+ // Terminal statuses record a completion timestamp; reopening clears it.
117
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
118
+ reason: updates.reason ?? existing?.reason,
119
+ };
120
+ if (index === -1) entries.push(entry);
121
+ else entries[index] = entry;
122
+ }
123
+
124
+ /**
125
+ * Serialized read-modify-write of the initiatives registry. Holds a
126
+ * process-wide lock on the manifest path across the whole read → transform →
127
+ * write so concurrent tool calls cannot clobber each other. `transform` may run
128
+ * IO (e.g. read the plans manifest to project status) and mutates the entries
129
+ * array in place, returning `true` when it changed something.
130
+ */
131
+ export function mutateInitiativesManifest<E, R>(
132
+ transform: (entries: InitiativeManifestEntry[]) => Effect.Effect<boolean, E, R>,
133
+ ): Effect.Effect<void, ReadError | PlanWriteError | E, FileSystem | R> {
134
+ return withFileLock(
135
+ MANIFEST_PATH,
136
+ Effect.gen(function* () {
137
+ const entries = yield* readInitiativesManifest();
138
+ const changed = yield* transform(entries);
139
+ if (changed) yield* writeInitiativesManifest(entries);
140
+ }),
141
+ );
142
+ }
143
+
144
+ export function upsertInitiativeEntry(
145
+ name: string,
146
+ updates: InitiativeUpsert,
147
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
148
+ return mutateInitiativesManifest((entries) =>
149
+ Effect.sync(() => {
150
+ applyInitiativeUpsert(entries, name, updates);
151
+ return true;
152
+ }),
153
+ );
154
+ }
@@ -75,3 +75,14 @@ export function loadHandoff(planDir: string): Effect.Effect<string | undefined,
75
75
  return Option.getOrUndefined(maybeText);
76
76
  });
77
77
  }
78
+
79
+ export function saveInitiative(
80
+ initiativeDir: string,
81
+ content: string,
82
+ ): Effect.Effect<void, PlanWriteError, FileSystem> {
83
+ return Effect.gen(function* () {
84
+ const fs = yield* FileSystem;
85
+ yield* fs.makeDir(initiativeDir);
86
+ yield* fs.writeFileString(`${initiativeDir}/INITIATIVE.md`, content);
87
+ });
88
+ }
@@ -3,6 +3,7 @@ import { FileSystem } from '../effects/filesystem.js';
3
3
  import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
4
4
  import { decodePlanManifestEntry } from '../schema.js';
5
5
  import type { PlanStatus } from '../types.js';
6
+ import { withFileLock } from './file-lock.js';
6
7
 
7
8
  const MANIFEST_DIR = '.plans';
8
9
  const MANIFEST_PATH = '.plans/plans.jsonl';
@@ -15,6 +16,10 @@ export interface PlanManifestEntry {
15
16
  created_at: string;
16
17
  completed_at: string | null;
17
18
  reason?: string;
19
+ /** Parent initiative name (kebab). Absent = standalone flat plan. */
20
+ initiative?: string;
21
+ /** Plan-level dependencies (plan names). Cross-initiative allowed. */
22
+ depends_on?: string[];
18
23
  }
19
24
 
20
25
  /** A status is terminal (closed) when it is anything other than in-progress. */
@@ -67,28 +72,74 @@ export function writePlansManifest(
67
72
  });
68
73
  }
69
74
 
75
+ export interface PlanUpsert {
76
+ status: PlanStatus;
77
+ title?: string;
78
+ reason?: string;
79
+ /** Parent initiative name; preserved when omitted. */
80
+ initiative?: string;
81
+ /** Plan-level dependencies (plan names); preserved when omitted. */
82
+ depends_on?: string[];
83
+ }
84
+
85
+ /**
86
+ * Pure transform: upsert `name` into the in-memory `entries` array, preserving
87
+ * created_at / membership / deps from any existing entry. No IO — shared by the
88
+ * locked `upsertPlanEntry` and `reconcilePlanStatus` so both flow through one
89
+ * serialized read-modify-write and never nest locks.
90
+ */
91
+ export function applyPlanUpsert(
92
+ entries: PlanManifestEntry[],
93
+ name: string,
94
+ updates: PlanUpsert,
95
+ ): void {
96
+ const now = new Date().toISOString();
97
+ const index = entries.findIndex((entry) => entry.name === name);
98
+ const existing = index === -1 ? undefined : entries[index];
99
+ const entry: PlanManifestEntry = {
100
+ _type: 'plan',
101
+ name,
102
+ status: updates.status,
103
+ title: updates.title ?? existing?.title ?? 'Untitled plan',
104
+ created_at: existing?.created_at ?? now,
105
+ // Terminal statuses record a completion timestamp; reopening clears it.
106
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
107
+ reason: updates.reason ?? existing?.reason,
108
+ // Membership + plan-level deps are preserved across status-only upserts.
109
+ initiative: updates.initiative ?? existing?.initiative,
110
+ depends_on: updates.depends_on ?? existing?.depends_on,
111
+ };
112
+ if (index === -1) entries.push(entry);
113
+ else entries[index] = entry;
114
+ }
115
+
116
+ /**
117
+ * Serialized read-modify-write of the plans registry. Holds a process-wide lock
118
+ * on the manifest path across the whole read → transform → write so concurrent
119
+ * tool calls cannot clobber each other (lost-update race). `transform` mutates
120
+ * the entries array in place and returns `true` when it changed something
121
+ * (return `false` to skip the rewrite).
122
+ */
123
+ export function mutatePlansManifest(
124
+ transform: (entries: PlanManifestEntry[]) => boolean,
125
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
126
+ return withFileLock(
127
+ MANIFEST_PATH,
128
+ Effect.gen(function* () {
129
+ const entries = yield* readPlansManifest();
130
+ const changed = transform(entries);
131
+ if (changed) yield* writePlansManifest(entries);
132
+ }),
133
+ );
134
+ }
135
+
70
136
  export function upsertPlanEntry(
71
137
  name: string,
72
- updates: { status: PlanStatus; title?: string; reason?: string },
138
+ updates: PlanUpsert,
73
139
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
74
- return Effect.gen(function* () {
75
- const entries = yield* readPlansManifest();
76
- const now = new Date().toISOString();
77
- const index = entries.findIndex((entry) => entry.name === name);
78
- const existing = index === -1 ? undefined : entries[index];
79
- const entry: PlanManifestEntry = {
80
- _type: 'plan',
81
- name,
82
- status: updates.status,
83
- title: updates.title ?? existing?.title ?? 'Untitled plan',
84
- created_at: existing?.created_at ?? now,
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,
88
- };
89
- if (index === -1) entries.push(entry);
90
- else entries[index] = entry;
91
- yield* writePlansManifest(entries);
140
+ return mutatePlansManifest((entries) => {
141
+ applyPlanUpsert(entries, name, updates);
142
+ return true;
92
143
  });
93
144
  }
94
145
 
@@ -108,16 +159,16 @@ export function reconcilePlanStatus(
108
159
  finalizable: boolean,
109
160
  title?: string,
110
161
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
111
- return Effect.gen(function* () {
112
- const entries = yield* readPlansManifest();
162
+ return mutatePlansManifest((entries) => {
113
163
  const existing = entries.find((entry) => entry.name === name);
114
164
  // Reconcile only reflects task state for KNOWN plans; never conjure an
115
165
  // entry for an unregistered plan (orphans are surfaced, not auto-created).
116
- if (!existing) return;
166
+ if (!existing) return false;
117
167
  // Do not resurrect / clobber an explicitly closed plan.
118
- if (existing.status === 'superseded' || existing.status === 'abandoned') return;
168
+ if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
119
169
  const status: PlanStatus = finalizable ? 'done' : 'in-progress';
120
- if (existing.status === status) return; // no change
121
- yield* upsertPlanEntry(name, { status, title });
170
+ if (existing.status === status) return false; // no change
171
+ applyPlanUpsert(entries, name, { status, title });
172
+ return true;
122
173
  });
123
174
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from '../errors.js';
12
12
  import { decodeTasksLine } from '../schema.js';
13
13
  import type { TaskMeta, TaskRecord } from '../types.js';
14
+ import { withFileLock } from './file-lock.js';
14
15
 
15
16
  const TASKS_FILE = 'tasks.jsonl';
16
17
 
@@ -86,20 +87,25 @@ export function updateTask(
86
87
  ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
87
88
  FileSystem
88
89
  > {
89
- return Effect.gen(function* () {
90
- const snapshot = yield* readTasksJsonl(planDir);
91
- if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
90
+ // Serialize the read-modify-write so concurrent task updates to the same plan
91
+ // (e.g. parallel update_task / update_tasks calls) cannot clobber each other.
92
+ return withFileLock(
93
+ join(planDir, TASKS_FILE),
94
+ Effect.gen(function* () {
95
+ const snapshot = yield* readTasksJsonl(planDir);
96
+ if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
92
97
 
93
- const index = snapshot.tasks.findIndex((task) => task.id === taskId);
94
- if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
98
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
99
+ if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
95
100
 
96
- const updated: TaskRecord = {
97
- ...snapshot.tasks[index],
98
- ...updates,
99
- updated_at: new Date().toISOString(),
100
- };
101
- snapshot.tasks[index] = updated;
102
- yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
103
- return updated;
104
- });
101
+ const updated: TaskRecord = {
102
+ ...snapshot.tasks[index],
103
+ ...updates,
104
+ updated_at: new Date().toISOString(),
105
+ };
106
+ snapshot.tasks[index] = updated;
107
+ yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
108
+ return updated;
109
+ }),
110
+ );
105
111
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * initiative_status tool — read-only snapshot of an initiative.
3
+ *
4
+ * The initiative sibling of plan_status. Shows each member plan with its task
5
+ * progress (resolved/total), lifecycle status, and ready/blocked-by readiness
6
+ * derived from plan-level dependencies. This is the at-a-glance view for
7
+ * splitting an initiative's work across sessions or subagents: "ready" plans
8
+ * have all dependencies satisfied and can be picked up now.
9
+ */
10
+
11
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
12
+ import { Text } from '@earendil-works/pi-tui';
13
+ import { Type } from 'typebox';
14
+ import { Effect } from 'effect';
15
+ import type { RunPlanIO } from '../effects/runtime.js';
16
+ import { FileSystem } from '../effects/filesystem.js';
17
+ import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
18
+ import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
19
+ import { readTasksJsonl } from '../storage/task-storage.js';
20
+ import { initiativeRollup, membersOf, type InitiativeRollup } from '../initiative.js';
21
+
22
+ /** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
23
+ function normalizeName(hint: string): string {
24
+ return hint
25
+ .replace(/^\.plans\//, '')
26
+ .replace(/\/+$/, '')
27
+ .trim();
28
+ }
29
+
30
+ interface TaskCount {
31
+ resolved: number;
32
+ total: number;
33
+ }
34
+
35
+ /** Load member task counts for a set of plans (resolved = done + skipped). */
36
+ function loadTaskCounts(
37
+ plans: readonly PlanManifestEntry[],
38
+ ): Effect.Effect<Map<string, TaskCount>, never, FileSystem> {
39
+ return Effect.gen(function* () {
40
+ const counts = new Map<string, TaskCount>();
41
+ for (const plan of plans) {
42
+ const snapshot = yield* Effect.orElseSucceed(
43
+ readTasksJsonl(`.plans/${plan.name}`),
44
+ () => undefined,
45
+ );
46
+ const total = snapshot?.tasks.length ?? 0;
47
+ const resolved =
48
+ snapshot?.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length ?? 0;
49
+ counts.set(plan.name, { resolved, total });
50
+ }
51
+ return counts;
52
+ });
53
+ }
54
+
55
+ const STATUS_GLYPH: Record<string, string> = {
56
+ 'in-progress': '○',
57
+ done: '✓',
58
+ superseded: '🔄',
59
+ abandoned: '✗',
60
+ };
61
+
62
+ export function registerInitiativeStatusTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
63
+ pi.registerTool({
64
+ name: 'initiative_status',
65
+ label: 'Initiative Status',
66
+ description:
67
+ 'Read-only snapshot of an initiative: member plans with task progress, status, and ready/blocked-by. Use to see what work is unblocked and can be picked up next.',
68
+ promptSnippet: 'Show an initiative: member plans, progress, and ready/blocked work',
69
+ promptGuidelines: [
70
+ 'Call initiative_status to see which member plans are ready (dependencies satisfied) before dispatching work across sessions or subagents.',
71
+ 'It is read-only and never mutates state.',
72
+ ],
73
+ parameters: Type.Object({
74
+ initiative: Type.Optional(
75
+ Type.String({
76
+ description:
77
+ 'Initiative name (or .plans/<name>). Omit to use the sole in-progress initiative.',
78
+ }),
79
+ ),
80
+ }),
81
+
82
+ async execute(_toolCallId, params) {
83
+ const hint = params.initiative ? normalizeName(params.initiative) : undefined;
84
+
85
+ const snapshot = await runPlanIO(
86
+ Effect.gen(function* () {
87
+ const initiatives = yield* readInitiativesManifest();
88
+ const plans = yield* readPlansManifest();
89
+ return { initiatives, plans };
90
+ }),
91
+ );
92
+ const { initiatives, plans } = snapshot;
93
+
94
+ // Resolve the target initiative.
95
+ let targetName = hint;
96
+ if (!targetName) {
97
+ const inProgress = initiatives.filter((entry) => entry.status === 'in-progress');
98
+ if (inProgress.length === 1) targetName = inProgress[0]!.name;
99
+ else {
100
+ // Zero or many: list in-progress initiatives with rollup progress.
101
+ const rows = inProgress.map((entry) => {
102
+ const r = initiativeRollup(entry.name, plans);
103
+ return ` ${r.done}/${r.total} plans ${entry.name} — ${entry.title} (ready ${r.ready}, blocked ${r.blocked})`;
104
+ });
105
+ const text = inProgress.length
106
+ ? `No single active initiative — ${inProgress.length} in-progress. Pass { initiative: "<name>" }.\n${rows.join('\n')}`
107
+ : 'No in-progress initiative found in .plans/initiatives.jsonl.';
108
+ return {
109
+ content: [{ type: 'text' as const, text }],
110
+ details: {
111
+ active: false,
112
+ in_progress: inProgress.map((e) => e.name),
113
+ } as Record<string, unknown>,
114
+ };
115
+ }
116
+ }
117
+
118
+ const entry = initiatives.find((e) => e.name === targetName);
119
+ if (!entry) {
120
+ const names = initiatives.map((e) => e.name).join(', ');
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text' as const,
125
+ text: `Initiative not found: ${targetName}. Known: ${names || '(none)'}`,
126
+ },
127
+ ],
128
+ details: {
129
+ active: false,
130
+ error: 'not_found',
131
+ initiative: targetName,
132
+ } as Record<string, unknown>,
133
+ };
134
+ }
135
+
136
+ const rollup: InitiativeRollup = initiativeRollup(entry.name, plans);
137
+ const taskCounts = await runPlanIO(loadTaskCounts(membersOf(entry.name, plans)));
138
+
139
+ const memberLines = rollup.members.map((m) => {
140
+ const glyph = STATUS_GLYPH[m.status] ?? '○';
141
+ const tc = taskCounts.get(m.name);
142
+ const progress = tc && tc.total > 0 ? ` ${tc.resolved}/${tc.total} tasks` : '';
143
+ let readiness = '';
144
+ if (m.status === 'in-progress') {
145
+ readiness = m.ready ? ' [ready]' : ` [blocked by ${m.blockedBy?.join(', ')}]`;
146
+ }
147
+ return ` ${glyph} ${m.name} [${m.status}]${progress}${readiness}`;
148
+ });
149
+
150
+ const text =
151
+ `Initiative: ${entry.title} (${entry.name}) — ${entry.status}\n` +
152
+ `Plans: ${rollup.done}/${rollup.total} done — in-progress ${rollup.inProgress} (ready ${rollup.ready}, blocked ${rollup.blocked})` +
153
+ (rollup.closed ? `, closed ${rollup.closed}` : '') +
154
+ '\n' +
155
+ (memberLines.length ? `Member plans:\n${memberLines.join('\n')}` : 'No member plans yet.');
156
+
157
+ return {
158
+ content: [{ type: 'text' as const, text }],
159
+ details: {
160
+ active: true,
161
+ initiative: entry.name,
162
+ status: entry.status,
163
+ rollup,
164
+ ready_plans: rollup.members.filter((m) => m.ready).map((m) => m.name),
165
+ } as Record<string, unknown>,
166
+ };
167
+ },
168
+
169
+ renderCall(args, theme) {
170
+ const name = (args as { initiative?: string }).initiative;
171
+ let content = theme.fg('toolTitle', theme.bold('initiative_status'));
172
+ if (name) content += ' ' + theme.fg('muted', name);
173
+ return new Text(content, 0, 0);
174
+ },
175
+
176
+ renderResult(result, _options, theme) {
177
+ const details = result.details as
178
+ | { active?: boolean; initiative?: string; rollup?: InitiativeRollup }
179
+ | undefined;
180
+ if (!details?.active || !details.rollup)
181
+ return new Text(theme.fg('dim', 'No active initiative'), 0, 0);
182
+ const r = details.rollup;
183
+ return new Text(
184
+ theme.fg('toolTitle', `${details.initiative ?? 'initiative'} `) +
185
+ theme.fg('muted', `${r.done}/${r.total} plans — ready ${r.ready}, blocked ${r.blocked}`),
186
+ 0,
187
+ 0,
188
+ );
189
+ },
190
+ });
191
+ }
@@ -11,7 +11,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
11
11
  import { Text } from '@earendil-works/pi-tui';
12
12
  import { Type } from 'typebox';
13
13
  import type { RunPlanIO } from '../effects/runtime.js';
14
- import { applyReconcile, collectPlanDrift, type PlanDriftRow } from '../reconcile.js';
14
+ import {
15
+ applyInitiativeReconcile,
16
+ applyReconcile,
17
+ collectInitiativeDrift,
18
+ collectPlanDrift,
19
+ type InitiativeDriftRow,
20
+ type PlanDriftRow,
21
+ } from '../reconcile.js';
15
22
 
16
23
  function describeRow(row: PlanDriftRow): string {
17
24
  const progress = row.hasTasks ? ` (${row.resolved}/${row.total})` : '';
@@ -22,6 +29,10 @@ function describeRow(row: PlanDriftRow): string {
22
29
  return ` ⚠ ${row.name}${progress} — tasks.jsonl with no registry entry (orphan)`;
23
30
  }
24
31
  if (row.drift === 'status') {
32
+ if (row.direction === 'downgrade') {
33
+ // Wrong-direction projection: do NOT auto-repair. Mark tasks done instead.
34
+ return ` ⚠ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus} (likely merged; mark tasks done — not auto-repaired)`;
35
+ }
25
36
  return ` ✗ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus}`;
26
37
  }
27
38
  return ` ✓ ${row.name}${progress} — ${row.registryStatus} (in sync)`;
@@ -37,6 +48,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
37
48
  promptGuidelines: [
38
49
  'Use reconcile_plans to audit .plans/ when registry status looks stale (e.g. a fully-done plan still in-progress).',
39
50
  'Run it read-only first; pass apply:true once you have reviewed the reported drift.',
51
+ 'apply:true only records completion (in-progress→done). It never regresses a done plan back to in-progress — if a done plan shows incomplete tasks (work merged but tasks not marked), mark those tasks done instead.',
40
52
  ],
41
53
  parameters: Type.Object({
42
54
  apply: Type.Optional(
@@ -48,38 +60,64 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
48
60
 
49
61
  async execute(_toolCallId, params) {
50
62
  const rows = await runPlanIO(collectPlanDrift());
63
+ const initiativeRows = await runPlanIO(collectInitiativeDrift());
51
64
  const drifted = rows.filter((row) => row.drift);
65
+ const initiativeDrifted = initiativeRows.filter((row) => row.drift);
52
66
 
53
67
  let repaired: PlanDriftRow[] = [];
68
+ let initiativeRepaired: InitiativeDriftRow[] = [];
54
69
  if (params.apply) {
55
70
  repaired = await runPlanIO(applyReconcile(rows));
71
+ // Re-collect after plan repairs so initiative projection sees fresh state.
72
+ initiativeRepaired = await runPlanIO(
73
+ applyInitiativeReconcile(await runPlanIO(collectInitiativeDrift())),
74
+ );
56
75
  }
57
76
 
58
77
  const lines = rows.map(describeRow);
59
- const statusDrift = drifted.filter((r) => r.drift === 'status').length;
78
+ const initiativeLines = initiativeRows.map(
79
+ (row) =>
80
+ row.drift === 'status'
81
+ ? ` ✗ ${row.name} (initiative, ${row.members} plans) — registry ${row.registryStatus}, plans say ${row.derivedStatus}`
82
+ : ` ✓ ${row.name} (initiative, ${row.members} plans) — ${row.registryStatus} (in sync)`,
83
+ );
84
+ const statusDrift = drifted.filter(
85
+ (r) => r.drift === 'status' && r.direction === 'upgrade',
86
+ ).length;
87
+ const downgrades = drifted.filter(
88
+ (r) => r.drift === 'status' && r.direction === 'downgrade',
89
+ ).length;
60
90
  const orphans = drifted.filter((r) => r.drift === 'orphan').length;
61
91
  const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
62
92
 
93
+ const totalDrift = drifted.length + initiativeDrifted.length;
63
94
  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).`;
95
+ ? `Reconciled ${repaired.length} plan(s) + ${initiativeRepaired.length} initiative(s).`
96
+ : totalDrift === 0
97
+ ? 'All plans and initiatives in sync.'
98
+ : `${totalDrift} drift issue(s) found (run with apply:true to repair status drift).`;
68
99
 
69
100
  const summary = [
70
101
  `status-drift ${statusDrift}`,
102
+ `needs-tasks-done ${downgrades}`,
71
103
  `orphan ${orphans}`,
72
104
  `registry-only ${registryOnly}`,
73
105
  ].join(', ');
74
106
 
75
- const text = `${header}\n${summary}\n${lines.join('\n')}`;
107
+ const initiativeBlock = initiativeLines.length
108
+ ? `\nInitiatives:\n${initiativeLines.join('\n')}`
109
+ : '';
110
+ const text = `${header}\n${summary}\n${lines.join('\n')}${initiativeBlock}`;
76
111
  return {
77
112
  content: [{ type: 'text' as const, text }],
78
113
  details: {
79
114
  applied: Boolean(params.apply),
80
115
  repaired: repaired.map((r) => r.name),
81
- drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
116
+ drift: drifted.map((r) => ({ name: r.name, kind: r.drift, direction: r.direction })),
82
117
  total: rows.length,
118
+ initiative_repaired: initiativeRepaired.map((r) => r.name),
119
+ initiative_drift: initiativeDrifted.map((r) => ({ name: r.name, kind: r.drift })),
120
+ initiative_total: initiativeRows.length,
83
121
  },
84
122
  };
85
123
  },