@dreki-gg/pi-plan-mode 0.21.0 → 0.24.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +48 -5
  3. package/bin/clean-plans.js +67 -49
  4. package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
  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__/list-plans.test.ts +253 -0
  10. package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
  11. package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
  12. package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
  13. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
  14. package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
  15. package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
  16. package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
  17. package/extensions/plan-mode/commands/list-plans.ts +229 -0
  18. package/extensions/plan-mode/constants.ts +5 -0
  19. package/extensions/plan-mode/html/render.ts +41 -18
  20. package/extensions/plan-mode/index.ts +31 -0
  21. package/extensions/plan-mode/initiative.ts +168 -0
  22. package/extensions/plan-mode/prompts.ts +5 -1
  23. package/extensions/plan-mode/reconcile.ts +65 -0
  24. package/extensions/plan-mode/schema.ts +28 -0
  25. package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
  26. package/extensions/plan-mode/storage/plan-storage.ts +11 -0
  27. package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
  28. package/extensions/plan-mode/tools/initiative-status.ts +191 -0
  29. package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
  30. package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
  31. package/extensions/plan-mode/tools/revise-plan.ts +22 -0
  32. package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
  33. package/extensions/plan-mode/tools/submit-plan.ts +37 -4
  34. package/extensions/plan-mode/tools/update-initiative.ts +120 -0
  35. package/extensions/plan-mode/tools/update-plan.ts +3 -0
  36. package/extensions/plan-mode/types.ts +3 -0
  37. package/package.json +2 -3
  38. package/skills/planning-context/SKILL.md +11 -0
  39. package/skills/visual-prototype/SKILL.md +4 -2
  40. package/extensions/plan-mode/html/templates/prototype.pug +0 -25
  41. package/extensions/plan-mode/pug.d.ts +0 -5
@@ -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,8 +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';
58
+ import { handleListPlans } from './commands/list-plans.js';
59
+ import { handleListInitiatives } from './commands/list-initiatives.js';
54
60
 
55
61
  export default function planMode(pi: ExtensionAPI): void {
56
62
  const state = new PlanModeState();
@@ -82,6 +88,8 @@ export default function planMode(pi: ExtensionAPI): void {
82
88
  },
83
89
  });
84
90
 
91
+ registerSubmitInitiativeTool(pi, runPlanIO);
92
+
85
93
  registerPreviewPrototypeTool(pi, runPlanIO);
86
94
 
87
95
  // Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
@@ -119,6 +127,8 @@ export default function planMode(pi: ExtensionAPI): void {
119
127
  state.plan.title,
120
128
  ),
121
129
  );
130
+ // Project plan status up to its parent initiative (no-op when standalone).
131
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
122
132
  state.persist(pi);
123
133
  };
124
134
 
@@ -161,6 +171,7 @@ export default function planMode(pi: ExtensionAPI): void {
161
171
  state.plan.title,
162
172
  ),
163
173
  );
174
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
164
175
  state.persist(pi);
165
176
  },
166
177
  });
@@ -185,6 +196,8 @@ export default function planMode(pi: ExtensionAPI): void {
185
196
  });
186
197
 
187
198
  registerUpdatePlanTool(pi, runPlanIO);
199
+ registerUpdateInitiativeTool(pi, runPlanIO);
200
+ registerInitiativeStatusTool(pi, runPlanIO);
188
201
  registerReconcilePlansTool(pi, runPlanIO);
189
202
 
190
203
  registerAddTaskTool(pi, {
@@ -213,6 +226,7 @@ export default function planMode(pi: ExtensionAPI): void {
213
226
  state.plan.title,
214
227
  ),
215
228
  );
229
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
216
230
  state.persist(pi);
217
231
  },
218
232
  });
@@ -268,6 +282,22 @@ export default function planMode(pi: ExtensionAPI): void {
268
282
  },
269
283
  });
270
284
 
285
+ pi.registerCommand('plans', {
286
+ description:
287
+ 'List all plans with filtering and sorting. Usage: /plans [filter] [sort]. Filters: all, in-progress, done, superseded, abandoned. Sorts: newest, oldest, tasks, name.',
288
+ handler: async (args, ctx) => {
289
+ await handleListPlans(ctx, runPlanIO, args);
290
+ },
291
+ });
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
+
271
301
  pi.registerCommand('todos', {
272
302
  description: 'Show current plan progress',
273
303
  handler: async (_args, ctx) => {
@@ -504,6 +534,7 @@ export default function planMode(pi: ExtensionAPI): void {
504
534
  await runPlanIO(
505
535
  upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title }),
506
536
  );
537
+ await runPlanIO(reconcileInitiativeForPlan(state.plan.planName));
507
538
  await runPlanIO(
508
539
  writeTasksJsonl(
509
540
  state.planDir,
@@ -0,0 +1,168 @@
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
+ readInitiativesManifest,
22
+ upsertInitiativeEntry,
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
+ return Effect.gen(function* () {
143
+ const initiatives = yield* readInitiativesManifest();
144
+ const existing = initiatives.find((entry) => entry.name === name);
145
+ if (!existing) return;
146
+ if (existing.status === 'superseded' || existing.status === 'abandoned') return;
147
+ const plans = yield* readPlansManifest();
148
+ const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
149
+ if (existing.status === status) return;
150
+ yield* upsertInitiativeEntry(name, { status, title: existing.title });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Reconcile the initiative that a given plan belongs to (no-op when the plan is
156
+ * standalone). Call this after any plan-status write so the initiative level
157
+ * stays in sync without callers needing to know the parent name.
158
+ */
159
+ export function reconcileInitiativeForPlan(
160
+ planName: string,
161
+ ): Effect.Effect<void, ReconcileError, FileSystem> {
162
+ return Effect.gen(function* () {
163
+ const plans = yield* readPlansManifest();
164
+ const plan = plans.find((entry) => entry.name === planName);
165
+ if (!plan?.initiative) return;
166
+ yield* reconcileInitiativeStatus(plan.initiative);
167
+ });
168
+ }
@@ -33,9 +33,13 @@ 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
- 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. The visual-prototype skill covers when and how.
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.
39
43
 
40
44
  When facing a significant technical decision with multiple viable approaches (architecture, API design, implementation strategy), use the technical-options skill: you generate the competing proposals yourself, then use the subagent tool to fan out voting agents for evaluation. Do not delegate the entire workflow to a subagent — you are the planner, you drive the process.`;
41
45
  }
@@ -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';
@@ -121,6 +128,62 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
121
128
  });
122
129
  }
123
130
 
131
+ // ── Initiative-level drift ───────────────────────────────────────────────────
132
+
133
+ export interface InitiativeDriftRow {
134
+ name: string;
135
+ registryStatus: PlanStatus;
136
+ title: string;
137
+ /** Projected from member plans: `done` when finalizable, else `in-progress`. */
138
+ derivedStatus: 'in-progress' | 'done';
139
+ members: number;
140
+ /** 'status' when the registry status disagrees with the projection. */
141
+ drift?: 'status';
142
+ }
143
+
144
+ /** Compare each initiative's registry status against its member-plan projection. */
145
+ export function collectInitiativeDrift(): Effect.Effect<
146
+ InitiativeDriftRow[],
147
+ CollectError,
148
+ FileSystem
149
+ > {
150
+ return Effect.gen(function* () {
151
+ const initiatives = yield* readInitiativesManifest();
152
+ const plans = yield* readPlansManifest();
153
+ return initiatives.map((entry) => {
154
+ const derivedStatus: 'in-progress' | 'done' = isInitiativeFinalizable(entry.name, plans)
155
+ ? 'done'
156
+ : 'in-progress';
157
+ // Terminal statuses (superseded/abandoned) are intentional — never drift.
158
+ const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
159
+ const drift = !isTerminalManual && entry.status !== derivedStatus ? ('status' as const) : undefined;
160
+ return {
161
+ name: entry.name,
162
+ registryStatus: entry.status,
163
+ title: entry.title,
164
+ derivedStatus,
165
+ members: membersOf(entry.name, plans).length,
166
+ drift,
167
+ };
168
+ });
169
+ });
170
+ }
171
+
172
+ /** Repair `status`-class initiative drift by re-projecting from member plans. */
173
+ export function applyInitiativeReconcile(
174
+ rows: InitiativeDriftRow[],
175
+ ): Effect.Effect<InitiativeDriftRow[], CollectError | PlanWriteError, FileSystem> {
176
+ return Effect.gen(function* () {
177
+ const repaired: InitiativeDriftRow[] = [];
178
+ for (const row of rows) {
179
+ if (row.drift !== 'status') continue;
180
+ yield* reconcileInitiativeStatus(row.name);
181
+ repaired.push(row);
182
+ }
183
+ return repaired;
184
+ });
185
+ }
186
+
124
187
  /**
125
188
  * Repair `status`-class drift by projecting derived status into the registry.
126
189
  * Orphans and registry-only rows are reported but not auto-fixed (they need a
@@ -134,6 +197,8 @@ export function applyReconcile(
134
197
  for (const row of rows) {
135
198
  if (row.drift !== 'status' || !row.derivedStatus) continue;
136
199
  yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
200
+ // Repairing a plan's status can flip its parent initiative's projection.
201
+ yield* reconcileInitiativeForPlan(row.name);
137
202
  repaired.push(row);
138
203
  }
139
204
  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,112 @@
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
+
22
+ const MANIFEST_DIR = '.plans';
23
+ const MANIFEST_PATH = '.plans/initiatives.jsonl';
24
+
25
+ export interface InitiativeManifestEntry {
26
+ _type: 'initiative';
27
+ name: string;
28
+ status: InitiativeStatus;
29
+ title: string;
30
+ created_at: string;
31
+ completed_at: string | null;
32
+ reason?: string;
33
+ }
34
+
35
+ /** A status is terminal (closed) when it is anything other than in-progress. */
36
+ export function isTerminalStatus(status: InitiativeStatus): boolean {
37
+ return status !== 'in-progress';
38
+ }
39
+
40
+ type ReadError = JsonlParseError | JsonlValidationError;
41
+
42
+ export function readInitiativesManifest(): Effect.Effect<
43
+ InitiativeManifestEntry[],
44
+ ReadError,
45
+ FileSystem
46
+ > {
47
+ return Effect.gen(function* () {
48
+ const fs = yield* FileSystem;
49
+ // A missing or unreadable manifest is treated as "no initiatives".
50
+ const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
51
+ if (Option.isNone(maybeText)) return [];
52
+
53
+ const entries: InitiativeManifestEntry[] = [];
54
+ for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
55
+ if (!raw.trim()) continue;
56
+ const line = index + 1;
57
+
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch (cause) {
62
+ return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
63
+ }
64
+
65
+ const decoded = decodeInitiativeManifestEntry(parsed);
66
+ if (Either.isLeft(decoded)) {
67
+ return yield* Effect.fail(
68
+ new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
69
+ );
70
+ }
71
+ entries.push(decoded.right);
72
+ }
73
+ return entries;
74
+ });
75
+ }
76
+
77
+ export function writeInitiativesManifest(
78
+ entries: InitiativeManifestEntry[],
79
+ ): Effect.Effect<void, PlanWriteError, FileSystem> {
80
+ return Effect.gen(function* () {
81
+ const fs = yield* FileSystem;
82
+ yield* fs.makeDir(MANIFEST_DIR);
83
+ const content =
84
+ entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
85
+ yield* fs.writeFileAtomic(MANIFEST_PATH, content);
86
+ });
87
+ }
88
+
89
+ export function upsertInitiativeEntry(
90
+ name: string,
91
+ updates: { status: InitiativeStatus; title?: string; reason?: string },
92
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
93
+ return Effect.gen(function* () {
94
+ const entries = yield* readInitiativesManifest();
95
+ const now = new Date().toISOString();
96
+ const index = entries.findIndex((entry) => entry.name === name);
97
+ const existing = index === -1 ? undefined : entries[index];
98
+ const entry: InitiativeManifestEntry = {
99
+ _type: 'initiative',
100
+ name,
101
+ status: updates.status,
102
+ title: updates.title ?? existing?.title ?? 'Untitled initiative',
103
+ created_at: existing?.created_at ?? now,
104
+ // Terminal statuses record a completion timestamp; reopening clears it.
105
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
106
+ reason: updates.reason ?? existing?.reason,
107
+ };
108
+ if (index === -1) entries.push(entry);
109
+ else entries[index] = entry;
110
+ yield* writeInitiativesManifest(entries);
111
+ });
112
+ }
@@ -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
+ }
@@ -15,6 +15,10 @@ export interface PlanManifestEntry {
15
15
  created_at: string;
16
16
  completed_at: string | null;
17
17
  reason?: string;
18
+ /** Parent initiative name (kebab). Absent = standalone flat plan. */
19
+ initiative?: string;
20
+ /** Plan-level dependencies (plan names). Cross-initiative allowed. */
21
+ depends_on?: string[];
18
22
  }
19
23
 
20
24
  /** A status is terminal (closed) when it is anything other than in-progress. */
@@ -69,7 +73,15 @@ export function writePlansManifest(
69
73
 
70
74
  export function upsertPlanEntry(
71
75
  name: string,
72
- updates: { status: PlanStatus; title?: string; reason?: string },
76
+ updates: {
77
+ status: PlanStatus;
78
+ title?: string;
79
+ reason?: string;
80
+ /** Parent initiative name; preserved when omitted. */
81
+ initiative?: string;
82
+ /** Plan-level dependencies (plan names); preserved when omitted. */
83
+ depends_on?: string[];
84
+ },
73
85
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
74
86
  return Effect.gen(function* () {
75
87
  const entries = yield* readPlansManifest();
@@ -85,6 +97,9 @@ export function upsertPlanEntry(
85
97
  // Terminal statuses record a completion timestamp; reopening clears it.
86
98
  completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
87
99
  reason: updates.reason ?? existing?.reason,
100
+ // Membership + plan-level deps are preserved across status-only upserts.
101
+ initiative: updates.initiative ?? existing?.initiative,
102
+ depends_on: updates.depends_on ?? existing?.depends_on,
88
103
  };
89
104
  if (index === -1) entries.push(entry);
90
105
  else entries[index] = entry;