@dreki-gg/pi-plan-mode 0.23.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.
- package/CHANGELOG.md +13 -0
- package/README.md +48 -5
- package/bin/clean-plans.js +67 -49
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
- package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/index.ts +22 -0
- package/extensions/plan-mode/initiative.ts +168 -0
- package/extensions/plan-mode/prompts.ts +4 -0
- package/extensions/plan-mode/reconcile.ts +65 -0
- package/extensions/plan-mode/schema.ts +28 -0
- package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
- package/extensions/plan-mode/storage/plan-storage.ts +11 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
- package/extensions/plan-mode/tools/initiative-status.ts +191 -0
- package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
- package/extensions/plan-mode/tools/revise-plan.ts +22 -0
- package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
- package/extensions/plan-mode/tools/submit-plan.ts +37 -4
- package/extensions/plan-mode/tools/update-initiative.ts +120 -0
- package/extensions/plan-mode/tools/update-plan.ts +3 -0
- package/extensions/plan-mode/types.ts +3 -0
- package/package.json +1 -1
- package/skills/planning-context/SKILL.md +11 -0
|
@@ -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,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,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';
|
|
@@ -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: {
|
|
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;
|