@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,148 @@
1
+ /**
2
+ * /initiatives command β€” list, filter, and sort initiatives interactively.
3
+ *
4
+ * The initiative sibling of /plans. Reads the initiatives registry plus the
5
+ * plans manifest to roll up member-plan progress (done/total, ready/blocked).
6
+ */
7
+
8
+ import { Effect } from 'effect';
9
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
10
+ import type { RunPlanIO } from '../effects/runtime.js';
11
+ import { FileSystem } from '../effects/filesystem.js';
12
+ import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
13
+ import {
14
+ readInitiativesManifest,
15
+ type InitiativeManifestEntry,
16
+ } from '../storage/initiatives-manifest.js';
17
+ import { initiativeRollup } from '../initiative.js';
18
+ import type { InitiativeStatus } from '../types.js';
19
+
20
+ export type StatusFilter = 'all' | InitiativeStatus;
21
+
22
+ export interface InitiativeListItem {
23
+ name: string;
24
+ title: string;
25
+ status: InitiativeStatus;
26
+ created_at: string;
27
+ totalPlans: number;
28
+ donePlans: number;
29
+ ready: number;
30
+ blocked: number;
31
+ }
32
+
33
+ export function filterInitiatives(
34
+ items: InitiativeListItem[],
35
+ filter: StatusFilter,
36
+ ): InitiativeListItem[] {
37
+ if (filter === 'all') return items;
38
+ return items.filter((i) => i.status === filter);
39
+ }
40
+
41
+ const STATUS_ICON: Record<InitiativeStatus, string> = {
42
+ 'in-progress': 'πŸ”΅',
43
+ done: 'βœ…',
44
+ superseded: 'πŸ”„',
45
+ abandoned: '❌',
46
+ };
47
+
48
+ export function formatInitiativeList(items: InitiativeListItem[], filter: StatusFilter): string {
49
+ if (items.length === 0) {
50
+ return filter === 'all'
51
+ ? 'No initiatives found in .plans/initiatives.jsonl'
52
+ : `No initiatives with status "${filter}"`;
53
+ }
54
+ const header =
55
+ filter === 'all'
56
+ ? `All initiatives (${items.length})`
57
+ : `Initiatives: ${filter} (${items.length})`;
58
+ const lines = items.map((i) => {
59
+ const icon = STATUS_ICON[i.status];
60
+ const progress =
61
+ i.totalPlans > 0
62
+ ? ` [${i.donePlans}/${i.totalPlans} plans, ready ${i.ready}, blocked ${i.blocked}]`
63
+ : ' [no plans]';
64
+ const date = i.created_at.slice(0, 10);
65
+ return ` ${icon} ${i.name} β€” ${i.title}${progress} (${date})`;
66
+ });
67
+ return `${header}\n${lines.join('\n')}`;
68
+ }
69
+
70
+ export function loadInitiativeListItems(): Effect.Effect<InitiativeListItem[], never, FileSystem> {
71
+ return Effect.gen(function* () {
72
+ const initiatives = yield* Effect.orElseSucceed(
73
+ readInitiativesManifest(),
74
+ () => [] as InitiativeManifestEntry[],
75
+ );
76
+ const plans = yield* Effect.orElseSucceed(
77
+ readPlansManifest(),
78
+ () => [] as PlanManifestEntry[],
79
+ );
80
+ return initiatives.map((entry): InitiativeListItem => {
81
+ const r = initiativeRollup(entry.name, plans);
82
+ return {
83
+ name: entry.name,
84
+ title: entry.title,
85
+ status: entry.status,
86
+ created_at: entry.created_at,
87
+ totalPlans: r.total,
88
+ donePlans: r.done,
89
+ ready: r.ready,
90
+ blocked: r.blocked,
91
+ };
92
+ });
93
+ });
94
+ }
95
+
96
+ const FILTER_ALIASES: Record<string, StatusFilter> = {
97
+ all: 'all',
98
+ 'in-progress': 'in-progress',
99
+ active: 'in-progress',
100
+ done: 'done',
101
+ completed: 'done',
102
+ superseded: 'superseded',
103
+ abandoned: 'abandoned',
104
+ };
105
+
106
+ export function parseFilter(raw: string): StatusFilter {
107
+ for (const token of raw.toLowerCase().split(/\s+/)) {
108
+ if (FILTER_ALIASES[token]) return FILTER_ALIASES[token];
109
+ }
110
+ return 'all';
111
+ }
112
+
113
+ export async function handleListInitiatives(
114
+ ctx: ExtensionCommandContext,
115
+ runPlanIO: RunPlanIO,
116
+ args?: string,
117
+ ): Promise<void> {
118
+ const items = await runPlanIO(loadInitiativeListItems());
119
+ if (items.length === 0) {
120
+ ctx.ui.notify('No initiatives found in .plans/initiatives.jsonl', 'info');
121
+ return;
122
+ }
123
+
124
+ let filter: StatusFilter = 'all';
125
+ if (args?.trim()) {
126
+ filter = parseFilter(args.trim());
127
+ } else {
128
+ const choice = await ctx.ui.select('Filter initiatives by status:', [
129
+ 'All',
130
+ 'In-progress',
131
+ 'Done',
132
+ 'Superseded',
133
+ 'Abandoned',
134
+ ]);
135
+ if (!choice) return;
136
+ const map: Record<string, StatusFilter> = {
137
+ All: 'all',
138
+ 'In-progress': 'in-progress',
139
+ Done: 'done',
140
+ Superseded: 'superseded',
141
+ Abandoned: 'abandoned',
142
+ };
143
+ filter = map[choice] ?? 'all';
144
+ }
145
+
146
+ const filtered = filterInitiatives(items, filter);
147
+ ctx.ui.notify(formatInitiativeList(filtered, filter), 'info');
148
+ }
@@ -10,6 +10,7 @@ export const PLAN_TOOLS = [
10
10
  'find',
11
11
  'ls',
12
12
  'submit_plan',
13
+ 'submit_initiative',
13
14
  'revise_plan',
14
15
  'preview_prototype',
15
16
  'write',
@@ -19,6 +20,8 @@ export const PLAN_TOOLS = [
19
20
  'plan_status',
20
21
  'set_active_plan',
21
22
  'update_plan',
23
+ 'update_initiative',
24
+ 'initiative_status',
22
25
  'reconcile_plans',
23
26
  ];
24
27
 
@@ -33,6 +36,8 @@ export const EXEC_TOOLS = [
33
36
  'plan_status',
34
37
  'set_active_plan',
35
38
  'update_plan',
39
+ 'update_initiative',
40
+ 'initiative_status',
36
41
  'reconcile_plans',
37
42
  ];
38
43
 
@@ -39,8 +39,10 @@ import { activeTasksResolved, deferredTasks, isPlanFinalizable } from './task-st
39
39
  import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
40
40
  import { resumePlan, executeInNewSession } from './resume.js';
41
41
  import { resolveActivePlan, focusActivePlan } from './resolve-plan.js';
42
+ import { reconcileInitiativeForPlan } from './initiative.js';
42
43
  import { collectPlanDrift } from './reconcile.js';
43
44
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
45
+ import { registerSubmitInitiativeTool } from './tools/submit-initiative.js';
44
46
  import { registerRevisePlanTool } from './tools/revise-plan.js';
45
47
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
46
48
  import { registerUpdateTaskTool } from './tools/update-task.js';
@@ -49,9 +51,12 @@ import { registerAddTaskTool } from './tools/add-task.js';
49
51
  import { registerPlanStatusTool } from './tools/plan-status.js';
50
52
  import { registerSetActivePlanTool } from './tools/set-active-plan.js';
51
53
  import { registerUpdatePlanTool } from './tools/update-plan.js';
54
+ import { registerUpdateInitiativeTool } from './tools/update-initiative.js';
55
+ import { registerInitiativeStatusTool } from './tools/initiative-status.js';
52
56
  import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
53
57
  import { isSafeCommand, isPlanPath } from './utils.js';
54
58
  import { handleListPlans } from './commands/list-plans.js';
59
+ import { handleListInitiatives } from './commands/list-initiatives.js';
55
60
 
56
61
  export default function planMode(pi: ExtensionAPI): void {
57
62
  const state = new PlanModeState();
@@ -83,6 +88,8 @@ export default function planMode(pi: ExtensionAPI): void {
83
88
  },
84
89
  });
85
90
 
91
+ registerSubmitInitiativeTool(pi, runPlanIO);
92
+
86
93
  registerPreviewPrototypeTool(pi, runPlanIO);
87
94
 
88
95
  // Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
@@ -120,6 +127,8 @@ export default function planMode(pi: ExtensionAPI): void {
120
127
  state.plan.title,
121
128
  ),
122
129
  );
130
+ // Project plan status up to its parent initiative (no-op when standalone).
131
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
123
132
  state.persist(pi);
124
133
  };
125
134
 
@@ -162,6 +171,7 @@ export default function planMode(pi: ExtensionAPI): void {
162
171
  state.plan.title,
163
172
  ),
164
173
  );
174
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
165
175
  state.persist(pi);
166
176
  },
167
177
  });
@@ -186,6 +196,8 @@ export default function planMode(pi: ExtensionAPI): void {
186
196
  });
187
197
 
188
198
  registerUpdatePlanTool(pi, runPlanIO);
199
+ registerUpdateInitiativeTool(pi, runPlanIO);
200
+ registerInitiativeStatusTool(pi, runPlanIO);
189
201
  registerReconcilePlansTool(pi, runPlanIO);
190
202
 
191
203
  registerAddTaskTool(pi, {
@@ -214,6 +226,7 @@ export default function planMode(pi: ExtensionAPI): void {
214
226
  state.plan.title,
215
227
  ),
216
228
  );
229
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
217
230
  state.persist(pi);
218
231
  },
219
232
  });
@@ -277,6 +290,14 @@ export default function planMode(pi: ExtensionAPI): void {
277
290
  },
278
291
  });
279
292
 
293
+ pi.registerCommand('initiatives', {
294
+ description:
295
+ 'List all initiatives with member-plan rollup. Usage: /initiatives [filter]. Filters: all, in-progress, done, superseded, abandoned.',
296
+ handler: async (args, ctx) => {
297
+ await handleListInitiatives(ctx, runPlanIO, args);
298
+ },
299
+ });
300
+
280
301
  pi.registerCommand('todos', {
281
302
  description: 'Show current plan progress',
282
303
  handler: async (_args, ctx) => {
@@ -513,6 +534,7 @@ export default function planMode(pi: ExtensionAPI): void {
513
534
  await runPlanIO(
514
535
  upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title }),
515
536
  );
537
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
516
538
  await runPlanIO(
517
539
  writeTasksJsonl(
518
540
  state.planDir,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Initiative logic — ready-work computation and the initiative→plan projection.
3
+ *
4
+ * Two layers live here:
5
+ * - PURE: `computePlanReadiness`, `isInitiativeFinalizable`, `initiativeRollup`
6
+ * reason over a plans-manifest snapshot with no IO. They are the basis for
7
+ * "what work is unblocked right now" β€” the foundation for phase-2 subagent
8
+ * fan-out.
9
+ * - IO: `reconcileInitiativeStatus` / `reconcileInitiativeForPlan` keep an
10
+ * initiative's registry status a PROJECTION of its member plans, mirroring
11
+ * `reconcilePlanStatus` one level up. They read the PLANS manifest, so they
12
+ * live here (not in `initiatives-manifest.ts`) to keep the dependency
13
+ * direction one-way: initiative.ts β†’ {plans-manifest, initiatives-manifest}.
14
+ */
15
+
16
+ import { Effect } from 'effect';
17
+ import { FileSystem } from './effects/filesystem.js';
18
+ import type { JsonlParseError, JsonlValidationError, PlanWriteError } from './errors.js';
19
+ import { readPlansManifest, type PlanManifestEntry } from './storage/plans-manifest.js';
20
+ import {
21
+ applyInitiativeUpsert,
22
+ mutateInitiativesManifest,
23
+ } from './storage/initiatives-manifest.js';
24
+ import type { PlanStatus } from './types.js';
25
+
26
+ // ── Pure: readiness + projection rules ───────────────────────────────────────
27
+
28
+ export interface PlanReadiness {
29
+ name: string;
30
+ /** True when every plan in `depends_on` is `done`. */
31
+ ready: boolean;
32
+ /** Dependency plan names that are not yet `done` (unknown deps count too). */
33
+ blockedBy: string[];
34
+ }
35
+
36
+ /**
37
+ * For each `in-progress` plan, whether all of its plan-level dependencies are
38
+ * `done`. Only a `done` dependency unblocks β€” a missing, in-progress, or
39
+ * terminally-closed (superseded/abandoned) dependency keeps a plan blocked.
40
+ */
41
+ export function computePlanReadiness(plans: readonly PlanManifestEntry[]): PlanReadiness[] {
42
+ const statusByName = new Map(plans.map((plan) => [plan.name, plan.status]));
43
+ return plans
44
+ .filter((plan) => plan.status === 'in-progress')
45
+ .map((plan) => {
46
+ const deps = plan.depends_on ?? [];
47
+ const blockedBy = deps.filter((dep) => statusByName.get(dep) !== 'done');
48
+ return { name: plan.name, ready: blockedBy.length === 0, blockedBy };
49
+ });
50
+ }
51
+
52
+ /** Member plans of an initiative (linked by name in the plans manifest). */
53
+ export function membersOf(
54
+ initiative: string,
55
+ plans: readonly PlanManifestEntry[],
56
+ ): PlanManifestEntry[] {
57
+ return plans.filter((plan) => plan.initiative === initiative);
58
+ }
59
+
60
+ /**
61
+ * An initiative is finalizable (`done`) when it has β‰₯1 member plan AND every
62
+ * member is terminal (no member is `in-progress`). Mirrors the plan-level rule
63
+ * one level up.
64
+ */
65
+ export function isInitiativeFinalizable(
66
+ initiative: string,
67
+ plans: readonly PlanManifestEntry[],
68
+ ): boolean {
69
+ const members = membersOf(initiative, plans);
70
+ if (members.length === 0) return false;
71
+ return members.every((plan) => plan.status !== 'in-progress');
72
+ }
73
+
74
+ export interface InitiativeMemberRow {
75
+ name: string;
76
+ title: string;
77
+ status: PlanStatus;
78
+ /** Present for in-progress members. */
79
+ ready?: boolean;
80
+ blockedBy?: string[];
81
+ }
82
+
83
+ export interface InitiativeRollup {
84
+ name: string;
85
+ total: number;
86
+ done: number;
87
+ /** Terminal but not done (superseded / abandoned). */
88
+ closed: number;
89
+ inProgress: number;
90
+ ready: number;
91
+ blocked: number;
92
+ members: InitiativeMemberRow[];
93
+ }
94
+
95
+ /** Aggregate an initiative's member plans into counts + per-member readiness. */
96
+ export function initiativeRollup(
97
+ initiative: string,
98
+ plans: readonly PlanManifestEntry[],
99
+ ): InitiativeRollup {
100
+ const members = membersOf(initiative, plans);
101
+ const readiness = new Map(computePlanReadiness(plans).map((row) => [row.name, row]));
102
+
103
+ let done = 0;
104
+ let closed = 0;
105
+ let inProgress = 0;
106
+ let ready = 0;
107
+ let blocked = 0;
108
+
109
+ const rows: InitiativeMemberRow[] = members.map((plan) => {
110
+ if (plan.status === 'done') done += 1;
111
+ else if (plan.status === 'in-progress') inProgress += 1;
112
+ else closed += 1; // superseded / abandoned
113
+
114
+ const row: InitiativeMemberRow = { name: plan.name, title: plan.title, status: plan.status };
115
+ if (plan.status === 'in-progress') {
116
+ const r = readiness.get(plan.name);
117
+ row.ready = r?.ready ?? true;
118
+ row.blockedBy = r?.blockedBy ?? [];
119
+ if (row.ready) ready += 1;
120
+ else blocked += 1;
121
+ }
122
+ return row;
123
+ });
124
+
125
+ return { name: initiative, total: members.length, done, closed, inProgress, ready, blocked, members: rows };
126
+ }
127
+
128
+ // ── IO: keep initiative registry status a projection of member plans ─────────
129
+
130
+ type ReconcileError = JsonlParseError | JsonlValidationError | PlanWriteError;
131
+
132
+ /**
133
+ * Re-derive an initiative's registry status from its member plans.
134
+ *
135
+ * Like `reconcilePlanStatus`: only reflects state for a KNOWN initiative (never
136
+ * conjures an entry), and never clobbers a manually-set terminal status
137
+ * (`superseded` / `abandoned`). Only `in-progress` ⇄ `done` is derived.
138
+ */
139
+ export function reconcileInitiativeStatus(
140
+ name: string,
141
+ ): Effect.Effect<void, ReconcileError, FileSystem> {
142
+ // The whole read-decide-write runs under the initiatives lock so a concurrent
143
+ // writer cannot slip a status change between our read and our write.
144
+ return mutateInitiativesManifest((initiatives) =>
145
+ Effect.gen(function* () {
146
+ const existing = initiatives.find((entry) => entry.name === name);
147
+ if (!existing) return false;
148
+ if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
149
+ const plans = yield* readPlansManifest();
150
+ const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
151
+ if (existing.status === status) return false;
152
+ applyInitiativeUpsert(initiatives, name, { status, title: existing.title });
153
+ return true;
154
+ }),
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Reconcile the initiative that a given plan belongs to (no-op when the plan is
160
+ * standalone). Call this after any plan-status write so the initiative level
161
+ * stays in sync without callers needing to know the parent name.
162
+ */
163
+ export function reconcileInitiativeForPlan(
164
+ planName: string,
165
+ ): Effect.Effect<void, ReconcileError, FileSystem> {
166
+ return Effect.gen(function* () {
167
+ const plans = yield* readPlansManifest();
168
+ const plan = plans.find((entry) => entry.name === planName);
169
+ if (!plan?.initiative) return;
170
+ yield* reconcileInitiativeStatus(plan.initiative);
171
+ });
172
+ }
@@ -33,6 +33,10 @@ Plan weight:
33
33
 
34
34
  submit_plan is finalization, not the starting point. It records tasks and the handoff β€” it does not generate HTML.
35
35
 
36
+ Sizing the work β€” flat plan vs initiative:
37
+ - For a bounded change that fits one coherent execution session, submit a single flat plan.
38
+ - For LARGE work that does not fit one session, or spans multiple subsystems with dependencies between chunks, create an **initiative** first with submit_initiative, then submit each executable chunk as its own plan with \`initiative\` set and \`depends_on_plans\` capturing ordering. An initiative's status is a projection of its member plans; \`initiative_status\` shows which member plans are READY (all dependencies done) so the work can be split across sessions or subagents. Use \`/initiatives\` and \`update_initiative\` to track and close initiatives.
39
+
36
40
  If a plan with the same name already exists and the user asks for follow-up changes (e.g. you submitted prematurely), call revise_plan instead of submit_plan. It rewrites the existing plan in place β€” pass only the fields that change (title, handoff, and/or tasks); status and notes are preserved for tasks whose id is unchanged.
37
41
 
38
42
  For visual/UI/layout/style work, build a prototype with preview_prototype DURING planning, before submit_plan, so the user can react to the visual before the plan hardens. You author the HTML freely β€” no template engine, no imposed theme β€” and can delegate the markup to the ux-designer subagent for real design taste. The visual-prototype skill covers when and how.
@@ -22,6 +22,13 @@ import type {
22
22
  PlanWriteError,
23
23
  } from './errors.js';
24
24
  import { readPlansManifest, reconcilePlanStatus } from './storage/plans-manifest.js';
25
+ import { readInitiativesManifest } from './storage/initiatives-manifest.js';
26
+ import {
27
+ isInitiativeFinalizable,
28
+ membersOf,
29
+ reconcileInitiativeForPlan,
30
+ reconcileInitiativeStatus,
31
+ } from './initiative.js';
25
32
  import { readTasksJsonl } from './storage/task-storage.js';
26
33
  import { isPlanFinalizable } from './task-status.js';
27
34
  import type { PlanStatus } from './types.js';
@@ -48,6 +55,18 @@ export interface PlanDriftRow {
48
55
  * - undefined : in sync
49
56
  */
50
57
  drift?: 'status' | 'registry-only' | 'orphan';
58
+ /**
59
+ * For `status` drift, the direction the registry would move if projected from
60
+ * tasks:
61
+ * - 'upgrade' : registry `in-progress` β†’ tasks `done` (safe; auto-repaired)
62
+ * - 'downgrade' : registry `done` β†’ tasks `in-progress` (NOT auto-repaired)
63
+ *
64
+ * A downgrade almost always means "work merged but tasks were never marked
65
+ * done" — auto-projecting tasks→registry there would REGRESS a finished plan
66
+ * back to in-progress (the wrong direction). We surface it for a human to
67
+ * resolve by marking the tasks done instead.
68
+ */
69
+ direction?: 'upgrade' | 'downgrade';
51
70
  }
52
71
 
53
72
  type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
@@ -85,6 +104,12 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
85
104
  // Terminal statuses (superseded/abandoned) are intentional β€” never drift.
86
105
  const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
87
106
  const drift = !isTerminalManual && entry.status !== derivedStatus ? 'status' : undefined;
107
+ const direction =
108
+ drift === 'status'
109
+ ? derivedStatus === 'done'
110
+ ? ('upgrade' as const)
111
+ : ('downgrade' as const)
112
+ : undefined;
88
113
  rows.push({
89
114
  name: entry.name,
90
115
  registryStatus: entry.status,
@@ -94,6 +119,7 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
94
119
  total,
95
120
  hasTasks: true,
96
121
  drift,
122
+ direction,
97
123
  });
98
124
  }
99
125
 
@@ -121,10 +147,73 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
121
147
  });
122
148
  }
123
149
 
150
+ // ── Initiative-level drift ───────────────────────────────────────────────────
151
+
152
+ export interface InitiativeDriftRow {
153
+ name: string;
154
+ registryStatus: PlanStatus;
155
+ title: string;
156
+ /** Projected from member plans: `done` when finalizable, else `in-progress`. */
157
+ derivedStatus: 'in-progress' | 'done';
158
+ members: number;
159
+ /** 'status' when the registry status disagrees with the projection. */
160
+ drift?: 'status';
161
+ }
162
+
163
+ /** Compare each initiative's registry status against its member-plan projection. */
164
+ export function collectInitiativeDrift(): Effect.Effect<
165
+ InitiativeDriftRow[],
166
+ CollectError,
167
+ FileSystem
168
+ > {
169
+ return Effect.gen(function* () {
170
+ const initiatives = yield* readInitiativesManifest();
171
+ const plans = yield* readPlansManifest();
172
+ return initiatives.map((entry) => {
173
+ const derivedStatus: 'in-progress' | 'done' = isInitiativeFinalizable(entry.name, plans)
174
+ ? 'done'
175
+ : 'in-progress';
176
+ // Terminal statuses (superseded/abandoned) are intentional β€” never drift.
177
+ const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
178
+ const drift = !isTerminalManual && entry.status !== derivedStatus ? ('status' as const) : undefined;
179
+ return {
180
+ name: entry.name,
181
+ registryStatus: entry.status,
182
+ title: entry.title,
183
+ derivedStatus,
184
+ members: membersOf(entry.name, plans).length,
185
+ drift,
186
+ };
187
+ });
188
+ });
189
+ }
190
+
191
+ /** Repair `status`-class initiative drift by re-projecting from member plans. */
192
+ export function applyInitiativeReconcile(
193
+ rows: InitiativeDriftRow[],
194
+ ): Effect.Effect<InitiativeDriftRow[], CollectError | PlanWriteError, FileSystem> {
195
+ return Effect.gen(function* () {
196
+ const repaired: InitiativeDriftRow[] = [];
197
+ for (const row of rows) {
198
+ if (row.drift !== 'status') continue;
199
+ yield* reconcileInitiativeStatus(row.name);
200
+ repaired.push(row);
201
+ }
202
+ return repaired;
203
+ });
204
+ }
205
+
124
206
  /**
125
207
  * 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.
208
+ *
209
+ * Safety: only `upgrade` drift (registry `in-progress` β†’ tasks `done`) is
210
+ * auto-repaired. A `downgrade` (registry `done` β†’ tasks `in-progress`) is
211
+ * reported but NEVER auto-applied β€” it almost always means work merged without
212
+ * marking tasks done, and projecting tasks→registry there would regress a
213
+ * finished plan. The human resolves it by marking the tasks done instead.
214
+ *
215
+ * Orphans and registry-only rows are likewise reported but not auto-fixed.
216
+ * Returns the rows that were repaired.
128
217
  */
129
218
  export function applyReconcile(
130
219
  rows: PlanDriftRow[],
@@ -133,7 +222,12 @@ export function applyReconcile(
133
222
  const repaired: PlanDriftRow[] = [];
134
223
  for (const row of rows) {
135
224
  if (row.drift !== 'status' || !row.derivedStatus) continue;
225
+ // Guard against the wrong-direction projection: never auto-regress a
226
+ // `done` plan back to `in-progress`.
227
+ if (row.direction === 'downgrade') continue;
136
228
  yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
229
+ // Repairing a plan's status can flip its parent initiative's projection.
230
+ yield* reconcileInitiativeForPlan(row.name);
137
231
  repaired.push(row);
138
232
  }
139
233
  return repaired;
@@ -55,6 +55,31 @@ export const PlanManifestEntrySchema = Schema.Struct({
55
55
  completed_at: Schema.NullOr(Schema.String),
56
56
  /** Optional human-readable reason, used for terminal statuses. */
57
57
  reason: Schema.optional(Schema.String),
58
+ /** Parent initiative name (kebab). Absent = standalone flat plan. */
59
+ initiative: Schema.optional(Schema.String),
60
+ /**
61
+ * Plan-level dependencies: names of plans this plan depends on. Distinct from
62
+ * the task-level `depends_on` above. Cross-initiative references are allowed.
63
+ */
64
+ depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
65
+ });
66
+
67
+ /**
68
+ * Initiative lifecycle statuses reuse the plan lifecycle literals. An
69
+ * initiative's status is a projection of its member plans' statuses, with the
70
+ * same terminal-guard semantics as plans.
71
+ */
72
+ export const InitiativeStatusSchema = PlanStatusSchema;
73
+
74
+ export const InitiativeManifestEntrySchema = Schema.Struct({
75
+ _type: Schema.Literal('initiative'),
76
+ name: Schema.String,
77
+ status: InitiativeStatusSchema,
78
+ title: Schema.String,
79
+ created_at: Schema.String,
80
+ completed_at: Schema.NullOr(Schema.String),
81
+ /** Optional human-readable reason, used for terminal statuses. */
82
+ reason: Schema.optional(Schema.String),
58
83
  });
59
84
 
60
85
  export const ExecPendingConfigSchema = Schema.Struct({
@@ -66,4 +91,7 @@ export const decodeTaskRecord = Schema.decodeUnknownEither(TaskRecordSchema);
66
91
  export const decodeTaskMeta = Schema.decodeUnknownEither(TaskMetaSchema);
67
92
  export const decodeTasksLine = Schema.decodeUnknownEither(TasksLineSchema);
68
93
  export const decodePlanManifestEntry = Schema.decodeUnknownEither(PlanManifestEntrySchema);
94
+ export const decodeInitiativeManifestEntry = Schema.decodeUnknownEither(
95
+ InitiativeManifestEntrySchema,
96
+ );
69
97
  export const decodeExecPendingConfig = Schema.decodeUnknownEither(ExecPendingConfigSchema);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Process-wide keyed mutex for serializing read-modify-write on shared files.
3
+ *
4
+ * Pi runs every tool call in a single Node process. When several tool calls run
5
+ * in one block (e.g. three `submit_initiative` calls, or concurrent
6
+ * `submit_plan` / `revise_plan`), each does an independent
7
+ * read β†’ modify β†’ write against the same registry file. Without serialization
8
+ * their reads all observe the same starting state and the last write clobbers
9
+ * the rest β€” a classic lost-update race.
10
+ *
11
+ * `withFileLock` wraps a read-modify-write critical section so only one runs at
12
+ * a time per `key` (the registry path). The semaphore is created eagerly with
13
+ * `unsafeMakeSemaphore` and cached per key, so its permit count lives in plain
14
+ * shared memory and serializes correctly even across independent
15
+ * `Effect.runPromise` invocations (separate tool executes).
16
+ *
17
+ * NOTE: this guards against in-process concurrency only. Atomic writes
18
+ * (`writeFileAtomic`) still protect against torn files from other processes,
19
+ * but cross-process registry coordination is out of scope.
20
+ */
21
+
22
+ import { Effect } from 'effect';
23
+
24
+ const locks = new Map<string, Effect.Semaphore>();
25
+
26
+ function lockFor(key: string): Effect.Semaphore {
27
+ let lock = locks.get(key);
28
+ if (!lock) {
29
+ lock = Effect.unsafeMakeSemaphore(1);
30
+ locks.set(key, lock);
31
+ }
32
+ return lock;
33
+ }
34
+
35
+ /**
36
+ * Run `effect` while holding the single permit for `key`. Concurrent callers
37
+ * with the same key queue and run one at a time; the permit is always released,
38
+ * even on failure or interruption.
39
+ *
40
+ * Do NOT nest `withFileLock` for the same key inside another β€” the permit is
41
+ * not reentrant and would deadlock. Express composite read-modify-write as one
42
+ * locked section instead.
43
+ */
44
+ export function withFileLock<A, E, R>(
45
+ key: string,
46
+ effect: Effect.Effect<A, E, R>,
47
+ ): Effect.Effect<A, E, R> {
48
+ return Effect.suspend(() => lockFor(key).withPermits(1)(effect));
49
+ }