@agent-plan/core 0.1.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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/export-service.d.ts +8 -0
- package/dist/export-service.d.ts.map +1 -0
- package/dist/export-service.js +126 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +44 -0
- package/dist/plan-store.d.ts +140 -0
- package/dist/plan-store.d.ts.map +1 -0
- package/dist/plan-store.js +1029 -0
- package/dist/render-utils.d.ts +6 -0
- package/dist/render-utils.d.ts.map +1 -0
- package/dist/render-utils.js +49 -0
- package/dist/renderer.d.ts +12 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +374 -0
- package/dist/schema.d.ts +2826 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +244 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CodebaseProfileSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, PlanWorkspaceSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
|
|
4
|
+
import { createFeatureId, createPhaseId, createRequirementId, createTaskId, isLegacyPhaseId } from "./naming.js";
|
|
5
|
+
function nowISO() {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
export class PlanStoreError extends Error {
|
|
9
|
+
cause;
|
|
10
|
+
constructor(message, cause) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.cause = cause;
|
|
13
|
+
this.name = "PlanStoreError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// ── Atomic file helpers ────────────────────────────────────────────────
|
|
17
|
+
// Per-path write mutex: serializes concurrent writes to the SAME file so that
|
|
18
|
+
// parallel tool calls (feature_create/phase_create/...) don't truncate JSON.
|
|
19
|
+
const writeLocks = new Map();
|
|
20
|
+
// Per-feature mutex: serializes concurrent phase_create calls that target the
|
|
21
|
+
// same feature, so auto-numbering can read/assign the next phase number safely.
|
|
22
|
+
const featureLocks = new Map();
|
|
23
|
+
// Optional global hook fired around every atomic write so adapters can mark the
|
|
24
|
+
// plan as "busy" (e.g. to make the web server return 503 during mutations).
|
|
25
|
+
let writeBusyHook;
|
|
26
|
+
export function setWriteBusyHook(hook) {
|
|
27
|
+
writeBusyHook = hook;
|
|
28
|
+
}
|
|
29
|
+
// Optional global hook fired AFTER every successful atomic write, so adapters
|
|
30
|
+
// can broadcast a live-update event (e.g. WebSocket plan-rendered) to the web UI.
|
|
31
|
+
let writeNotifyHook;
|
|
32
|
+
export function setWriteNotifyHook(hook) {
|
|
33
|
+
writeNotifyHook = hook;
|
|
34
|
+
}
|
|
35
|
+
function withWriteLock(path, fn) {
|
|
36
|
+
const prev = writeLocks.get(path) ?? Promise.resolve();
|
|
37
|
+
let release;
|
|
38
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
39
|
+
writeLocks.set(path, prev.then(() => next));
|
|
40
|
+
return prev.then(fn).finally(() => {
|
|
41
|
+
release();
|
|
42
|
+
if (writeLocks.get(path) === prev.then(() => next))
|
|
43
|
+
writeLocks.delete(path);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function withFeatureLock(featureId, fn) {
|
|
47
|
+
const prev = featureLocks.get(featureId) ?? Promise.resolve();
|
|
48
|
+
let release;
|
|
49
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
50
|
+
featureLocks.set(featureId, prev.then(() => next));
|
|
51
|
+
return prev.then(fn).finally(() => {
|
|
52
|
+
release();
|
|
53
|
+
if (featureLocks.get(featureId) === prev.then(() => next))
|
|
54
|
+
featureLocks.delete(featureId);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function atomicWriteText(path, raw) {
|
|
58
|
+
return withWriteLock(path, async () => {
|
|
59
|
+
writeBusyHook?.(true);
|
|
60
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
61
|
+
try {
|
|
62
|
+
await writeFile(tmp, raw, "utf-8");
|
|
63
|
+
try {
|
|
64
|
+
await copyFile(path, `${path}.bak`);
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
await rename(tmp, path);
|
|
68
|
+
writeNotifyHook?.();
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
await unlink(tmp).catch(() => { });
|
|
72
|
+
throw new PlanStoreError(`atomic write failed: ${path}`, cause);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
writeBusyHook?.(false);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function atomicWriteJson(path, data) {
|
|
80
|
+
return atomicWriteText(path, JSON.stringify(data, null, 2));
|
|
81
|
+
}
|
|
82
|
+
async function atomicUpdateJson(path, schema, updater) {
|
|
83
|
+
// NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
|
|
84
|
+
// because those re-acquire withWriteLock(path) — and we already hold it (below).
|
|
85
|
+
// Re-entrant locking is not supported, so calling them would deadlock.
|
|
86
|
+
return withWriteLock(path, async () => {
|
|
87
|
+
const current = await readJson(path, schema);
|
|
88
|
+
const updated = updater(current);
|
|
89
|
+
const parsed = schema.parse(updated);
|
|
90
|
+
writeBusyHook?.(true);
|
|
91
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(tmp, JSON.stringify(parsed, null, 2), "utf-8");
|
|
94
|
+
try {
|
|
95
|
+
await copyFile(path, `${path}.bak`);
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
98
|
+
await rename(tmp, path);
|
|
99
|
+
writeNotifyHook?.();
|
|
100
|
+
}
|
|
101
|
+
catch (cause) {
|
|
102
|
+
await unlink(tmp).catch(() => { });
|
|
103
|
+
throw new PlanStoreError(`atomic write failed: ${path}`, cause);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
writeBusyHook?.(false);
|
|
107
|
+
}
|
|
108
|
+
return parsed;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function migrateToUuids(store) {
|
|
112
|
+
// Run as a batch so internal saveFeatures/savePhase calls do not
|
|
113
|
+
// re-trigger syncStatuses (O(N^2) on large planners). Idempotent: if there
|
|
114
|
+
// is nothing to migrate, no writes happen at all.
|
|
115
|
+
await store.runBatchForMigration(async () => {
|
|
116
|
+
const workspace = await store.loadAll();
|
|
117
|
+
const { features, requirements, phases } = workspace;
|
|
118
|
+
const featureIdMap = new Map();
|
|
119
|
+
const phaseIdMap = new Map();
|
|
120
|
+
const taskIdMap = new Map();
|
|
121
|
+
const reqIdMap = new Map();
|
|
122
|
+
const isUuid = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
123
|
+
// 1. Map Features
|
|
124
|
+
const updatedFeatures = features.features.map((f) => {
|
|
125
|
+
const newId = isUuid(f.id) ? f.id : createFeatureId();
|
|
126
|
+
featureIdMap.set(f.id, newId);
|
|
127
|
+
return { ...f, id: newId };
|
|
128
|
+
});
|
|
129
|
+
// 2. Map Requirements
|
|
130
|
+
const updatedRequirements = requirements.requirements.map((r) => {
|
|
131
|
+
const newId = isUuid(r.id) ? r.id : createRequirementId();
|
|
132
|
+
reqIdMap.set(r.id, newId);
|
|
133
|
+
return { ...r, id: newId };
|
|
134
|
+
});
|
|
135
|
+
// 3. Map Phases
|
|
136
|
+
const updatedPhases = phases.map((p) => {
|
|
137
|
+
const newId = isUuid(p.id) ? p.id : createPhaseId();
|
|
138
|
+
phaseIdMap.set(p.id, newId);
|
|
139
|
+
return {
|
|
140
|
+
...p,
|
|
141
|
+
id: newId,
|
|
142
|
+
featureId: p.featureId ? (featureIdMap.get(p.featureId) ?? p.featureId) : undefined,
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
// 4. Map Tasks
|
|
146
|
+
for (const phase of updatedPhases) {
|
|
147
|
+
phase.tasks = phase.tasks.map((t) => {
|
|
148
|
+
const newId = isUuid(t.id) ? t.id : createTaskId();
|
|
149
|
+
taskIdMap.set(t.id, newId);
|
|
150
|
+
return { ...t, id: newId, phaseId: phase.id };
|
|
151
|
+
});
|
|
152
|
+
// Update taskIds array to match new task IDs
|
|
153
|
+
phase.taskIds = phase.tasks.map(t => t.id);
|
|
154
|
+
}
|
|
155
|
+
// 5. Update Feature -> Phase links
|
|
156
|
+
for (const feature of updatedFeatures) {
|
|
157
|
+
feature.phaseIds = feature.phaseIds.map(id => phaseIdMap.get(id) ?? id);
|
|
158
|
+
}
|
|
159
|
+
// 6. Update Requirement -> Phase links
|
|
160
|
+
const finalRequirements = updatedRequirements.map((r) => ({
|
|
161
|
+
...r,
|
|
162
|
+
linkedPhaseIds: r.linkedPhaseIds.map((id) => phaseIdMap.get(id) ?? id),
|
|
163
|
+
}));
|
|
164
|
+
// Save everything
|
|
165
|
+
await store.saveFeatures({ features: updatedFeatures });
|
|
166
|
+
await store.saveRequirements({ requirements: finalRequirements });
|
|
167
|
+
for (const p of updatedPhases) {
|
|
168
|
+
await store.savePhase(p);
|
|
169
|
+
}
|
|
170
|
+
await store.writeGenerated();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function readJson(path, schema) {
|
|
174
|
+
try {
|
|
175
|
+
const raw = await readFile(path, "utf-8");
|
|
176
|
+
return schema.parse(JSON.parse(raw));
|
|
177
|
+
}
|
|
178
|
+
catch (cause) {
|
|
179
|
+
// Try the .bak backup before giving up (recover from external-write corruption).
|
|
180
|
+
try {
|
|
181
|
+
const bak = await readFile(`${path}.bak`, "utf-8");
|
|
182
|
+
return schema.parse(JSON.parse(bak));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// fall through to original error
|
|
186
|
+
}
|
|
187
|
+
throw new PlanStoreError(`read failed: ${path}`, cause);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ─── PlanStore ──────────────────────────────────────────────────────────
|
|
191
|
+
export class PlanStore {
|
|
192
|
+
root;
|
|
193
|
+
autoSync = false;
|
|
194
|
+
syncGuard = false;
|
|
195
|
+
// While true, maybeAutoSync() is a no-op. Used by batch operations
|
|
196
|
+
// (migrateToUuids, ensureStructureOrdering, syncStatuses, repair) so that
|
|
197
|
+
// their internal savePhase/saveFeatures calls do NOT re-trigger a full
|
|
198
|
+
// syncStatuses on every write. Without this, a batch over N phases becomes
|
|
199
|
+
// O(N^2) atomic writes (each save -> syncStatuses -> N saves), which hangs
|
|
200
|
+
// Pi on planners with hundreds of phases.
|
|
201
|
+
batchInProgress = false;
|
|
202
|
+
constructor(root) {
|
|
203
|
+
this.root = root;
|
|
204
|
+
}
|
|
205
|
+
/** When enabled, status rollup (syncStatuses) runs automatically after every
|
|
206
|
+
* phase/feature/project save. Used by the pi-adapter so the agent's tool
|
|
207
|
+
* mutations keep phase/feature statuses derived from task statuses. */
|
|
208
|
+
enableAutoSync(value) { this.autoSync = value; }
|
|
209
|
+
/** Run a batch operation with autoSync suspended. Internal saves inside the
|
|
210
|
+
* batch will NOT re-trigger syncStatuses (which would be O(N^2) on large
|
|
211
|
+
* planners). The caller is responsible for triggering any needed final
|
|
212
|
+
* sync explicitly. */
|
|
213
|
+
async runAsBatch(fn) {
|
|
214
|
+
const prev = this.batchInProgress;
|
|
215
|
+
this.batchInProgress = true;
|
|
216
|
+
try {
|
|
217
|
+
return await fn();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
this.batchInProgress = prev;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Public batch wrapper used by the module-level migrateToUuids helper. */
|
|
224
|
+
async runBatchForMigration(fn) {
|
|
225
|
+
return this.runAsBatch(fn);
|
|
226
|
+
}
|
|
227
|
+
async maybeAutoSync() {
|
|
228
|
+
if (!this.autoSync || this.syncGuard || this.batchInProgress)
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
this.syncGuard = true;
|
|
232
|
+
await this.syncStatuses();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
this.syncGuard = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
normalizeTasks(tasks) {
|
|
239
|
+
let changed = false;
|
|
240
|
+
const normalized = tasks.map((task, index) => {
|
|
241
|
+
const nextNumber = index + 1;
|
|
242
|
+
if (task.number !== nextNumber)
|
|
243
|
+
changed = true;
|
|
244
|
+
return { ...task, number: nextNumber };
|
|
245
|
+
});
|
|
246
|
+
return { tasks: normalized, changed };
|
|
247
|
+
}
|
|
248
|
+
normalizeFeaturesDocument(doc) {
|
|
249
|
+
let changed = false;
|
|
250
|
+
const normalized = doc.features.map((feature, index) => {
|
|
251
|
+
const nextNumber = index + 1;
|
|
252
|
+
if (feature.number !== nextNumber)
|
|
253
|
+
changed = true;
|
|
254
|
+
return { ...feature, number: nextNumber };
|
|
255
|
+
});
|
|
256
|
+
return { doc: { features: normalized }, changed };
|
|
257
|
+
}
|
|
258
|
+
normalizePhaseDocument(phase) {
|
|
259
|
+
const { tasks, changed } = this.normalizeTasks(phase.tasks);
|
|
260
|
+
const nextTaskIds = tasks.map((task) => task.id);
|
|
261
|
+
const taskIdsChanged = nextTaskIds.length !== phase.taskIds.length || nextTaskIds.some((id, index) => id !== phase.taskIds[index]);
|
|
262
|
+
return {
|
|
263
|
+
phase: {
|
|
264
|
+
...phase,
|
|
265
|
+
tasks,
|
|
266
|
+
taskIds: nextTaskIds,
|
|
267
|
+
},
|
|
268
|
+
changed: changed || taskIdsChanged,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
normalizeStructureSnapshot(featuresDoc, phases) {
|
|
272
|
+
let changed = false;
|
|
273
|
+
const phaseById = new Map(phases.map((phase) => [phase.id, phase]));
|
|
274
|
+
const phasesByFeature = new Map();
|
|
275
|
+
const orphanPhases = [];
|
|
276
|
+
for (const phase of phases) {
|
|
277
|
+
if (phase.featureId) {
|
|
278
|
+
const bucket = phasesByFeature.get(phase.featureId) ?? [];
|
|
279
|
+
bucket.push(phase);
|
|
280
|
+
phasesByFeature.set(phase.featureId, bucket);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
orphanPhases.push(phase);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const normalizedFeatures = featuresDoc.features.map((feature, featureIndex) => {
|
|
287
|
+
const nextFeatureNumber = featureIndex + 1;
|
|
288
|
+
if (feature.number !== nextFeatureNumber)
|
|
289
|
+
changed = true;
|
|
290
|
+
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
291
|
+
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
292
|
+
const inferred = (phasesByFeature.get(feature.id) ?? []).filter((phase) => !linkedIds.has(phase.id));
|
|
293
|
+
const orderedPhases = [...linked, ...inferred];
|
|
294
|
+
const normalizedPhaseIds = orderedPhases.map((phase) => phase.id);
|
|
295
|
+
if (normalizedPhaseIds.length !== feature.phaseIds.length || normalizedPhaseIds.some((id, index) => id !== feature.phaseIds[index])) {
|
|
296
|
+
changed = true;
|
|
297
|
+
}
|
|
298
|
+
orderedPhases.forEach((phase, index) => {
|
|
299
|
+
const nextPhaseNumber = index + 1;
|
|
300
|
+
if (phase.number !== nextPhaseNumber) {
|
|
301
|
+
phase.number = nextPhaseNumber;
|
|
302
|
+
changed = true;
|
|
303
|
+
}
|
|
304
|
+
const normalizedPhase = this.normalizePhaseDocument(phase);
|
|
305
|
+
if (normalizedPhase.changed) {
|
|
306
|
+
phase.tasks = normalizedPhase.phase.tasks;
|
|
307
|
+
phase.taskIds = normalizedPhase.phase.taskIds;
|
|
308
|
+
changed = true;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
...feature,
|
|
313
|
+
number: nextFeatureNumber,
|
|
314
|
+
phaseIds: normalizedPhaseIds,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
orphanPhases.forEach((phase, index) => {
|
|
318
|
+
const nextPhaseNumber = index + 1;
|
|
319
|
+
if (phase.number !== nextPhaseNumber) {
|
|
320
|
+
phase.number = nextPhaseNumber;
|
|
321
|
+
changed = true;
|
|
322
|
+
}
|
|
323
|
+
const normalizedPhase = this.normalizePhaseDocument(phase);
|
|
324
|
+
if (normalizedPhase.changed) {
|
|
325
|
+
phase.tasks = normalizedPhase.phase.tasks;
|
|
326
|
+
phase.taskIds = normalizedPhase.phase.taskIds;
|
|
327
|
+
changed = true;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
features: { features: normalizedFeatures },
|
|
332
|
+
phases,
|
|
333
|
+
changed,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async ensureStructureOrdering() {
|
|
337
|
+
return this.runAsBatch(async () => {
|
|
338
|
+
const featuresDoc = await readJson(this.featuresPath(), FeaturesDocumentSchema).catch(() => ({ features: [] }));
|
|
339
|
+
const phases = await this.loadAllPhases();
|
|
340
|
+
const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
|
|
341
|
+
if (!normalized.changed)
|
|
342
|
+
return { changed: false };
|
|
343
|
+
await this.saveFeatures(normalized.features);
|
|
344
|
+
for (const phase of normalized.phases) {
|
|
345
|
+
await this.savePhase(phase);
|
|
346
|
+
}
|
|
347
|
+
return { changed: true };
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// ── Path helpers ─────────────────────────────────────────────────────
|
|
351
|
+
manifestPath() {
|
|
352
|
+
return join(this.root, "manifest.json");
|
|
353
|
+
}
|
|
354
|
+
projectPath() {
|
|
355
|
+
return join(this.root, "project.json");
|
|
356
|
+
}
|
|
357
|
+
requirementsPath() {
|
|
358
|
+
return join(this.root, "requirements.json");
|
|
359
|
+
}
|
|
360
|
+
featuresPath() {
|
|
361
|
+
return join(this.root, "features.json");
|
|
362
|
+
}
|
|
363
|
+
phasesDir() {
|
|
364
|
+
return join(this.root, "phases");
|
|
365
|
+
}
|
|
366
|
+
phasePath(phaseId) {
|
|
367
|
+
return join(this.phasesDir(), `${phaseId}.json`);
|
|
368
|
+
}
|
|
369
|
+
generatedDir() {
|
|
370
|
+
return join(this.root, "generated");
|
|
371
|
+
}
|
|
372
|
+
codebasePath() {
|
|
373
|
+
return join(this.root, "codebase.json");
|
|
374
|
+
}
|
|
375
|
+
resumePath() {
|
|
376
|
+
return join(this.root, "resume.json");
|
|
377
|
+
}
|
|
378
|
+
activityPath() {
|
|
379
|
+
return join(this.root, "activity.json");
|
|
380
|
+
}
|
|
381
|
+
handoffPath() {
|
|
382
|
+
return join(this.root, "HANDOFF.md");
|
|
383
|
+
}
|
|
384
|
+
// ── Init ─────────────────────────────────────────────────────────────
|
|
385
|
+
async init(projectName) {
|
|
386
|
+
if (await this.exists()) {
|
|
387
|
+
throw new PlanStoreError(".planner/ already exists");
|
|
388
|
+
}
|
|
389
|
+
await mkdir(this.root, { recursive: true });
|
|
390
|
+
await mkdir(this.phasesDir(), { recursive: true });
|
|
391
|
+
await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
|
|
392
|
+
await mkdir(join(this.root, "schema"), { recursive: true });
|
|
393
|
+
await mkdir(join(this.root, "adapters"), { recursive: true });
|
|
394
|
+
const manifest = {
|
|
395
|
+
schemaVersion: 1,
|
|
396
|
+
projectId: crypto.randomUUID(),
|
|
397
|
+
projectName,
|
|
398
|
+
createdAt: nowISO(),
|
|
399
|
+
updatedAt: nowISO(),
|
|
400
|
+
};
|
|
401
|
+
await atomicWriteJson(this.manifestPath(), manifest);
|
|
402
|
+
await this.saveProject({
|
|
403
|
+
name: projectName,
|
|
404
|
+
goal: "",
|
|
405
|
+
description: "",
|
|
406
|
+
webPort: 0,
|
|
407
|
+
scope: [],
|
|
408
|
+
outOfScope: [],
|
|
409
|
+
decisions: [],
|
|
410
|
+
globalRules: [],
|
|
411
|
+
technologies: [],
|
|
412
|
+
tools: [],
|
|
413
|
+
contentLanguage: "",
|
|
414
|
+
chatLanguage: "",
|
|
415
|
+
plannerAutoEnable: false,
|
|
416
|
+
plannerNeverAsk: false,
|
|
417
|
+
plannerAutoStartWeb: false,
|
|
418
|
+
plannerNeverStartWeb: false,
|
|
419
|
+
acceptedDecisions: [],
|
|
420
|
+
workflowRules: {
|
|
421
|
+
beforePhaseStart: [],
|
|
422
|
+
beforeTaskStart: [],
|
|
423
|
+
afterPhaseComplete: [],
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
await this.saveRequirements({ requirements: [] });
|
|
427
|
+
await this.saveFeatures({ features: [] });
|
|
428
|
+
await this.saveResume({
|
|
429
|
+
updatedAt: nowISO(),
|
|
430
|
+
currentPhaseId: "",
|
|
431
|
+
inProgressTaskIds: [],
|
|
432
|
+
nextSteps: ["Run /planner project discuss to bootstrap discovery"],
|
|
433
|
+
blockers: [],
|
|
434
|
+
notes: "Project initialized. Awaiting discovery.",
|
|
435
|
+
lastSessionSummary: "",
|
|
436
|
+
guardBypassUntil: "",
|
|
437
|
+
});
|
|
438
|
+
await this.writeGenerated();
|
|
439
|
+
// Write a README stub
|
|
440
|
+
const readme = [
|
|
441
|
+
"# Project Plan",
|
|
442
|
+
"",
|
|
443
|
+
`This is the project plan for **${projectName}** — managed by Agent Plan Platform.`,
|
|
444
|
+
"",
|
|
445
|
+
"## Structure",
|
|
446
|
+
"",
|
|
447
|
+
"- `manifest.json` — metadata",
|
|
448
|
+
"- `project.json` — scope, rules, stack, tools",
|
|
449
|
+
"- `requirements.json` — requirements and macro-tasks",
|
|
450
|
+
"- `phases/` — one JSON file per phase",
|
|
451
|
+
"- `generated/` — auto-generated markdown views",
|
|
452
|
+
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
453
|
+
].join("\n");
|
|
454
|
+
await writeFile(join(this.root, "README.md"), readme, "utf-8");
|
|
455
|
+
// Write a .gitignore inside .planner/ so transient backup/tmp files are
|
|
456
|
+
// not tracked by the host project's git. Git respects nested .gitignore.
|
|
457
|
+
await writeFile(join(this.root, ".gitignore"), [
|
|
458
|
+
"# Agent Plan transient files — do not track",
|
|
459
|
+
"*.bak",
|
|
460
|
+
"*.tmp.*",
|
|
461
|
+
"",
|
|
462
|
+
].join("\n"), "utf-8");
|
|
463
|
+
}
|
|
464
|
+
async exists() {
|
|
465
|
+
try {
|
|
466
|
+
await access(this.manifestPath());
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// ── Loaders ──────────────────────────────────────────────────────────
|
|
474
|
+
async loadManifest() {
|
|
475
|
+
return readJson(this.manifestPath(), ManifestSchema);
|
|
476
|
+
}
|
|
477
|
+
async loadProject() {
|
|
478
|
+
return readJson(this.projectPath(), ProjectSchema);
|
|
479
|
+
}
|
|
480
|
+
async loadPhase(phaseId) {
|
|
481
|
+
const phase = await readJson(this.phasePath(phaseId), PhaseSchema);
|
|
482
|
+
return this.normalizePhaseDocument(phase).phase;
|
|
483
|
+
}
|
|
484
|
+
async loadFeatures() {
|
|
485
|
+
try {
|
|
486
|
+
const features = await readJson(this.featuresPath(), FeaturesDocumentSchema);
|
|
487
|
+
return this.normalizeFeaturesDocument(features).doc;
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return { features: [] };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async loadCodebaseProfile() {
|
|
494
|
+
try {
|
|
495
|
+
return await readJson(this.codebasePath(), CodebaseProfileSchema);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async saveCodebaseProfile(profile) {
|
|
502
|
+
const parsed = CodebaseProfileSchema.parse(profile);
|
|
503
|
+
await atomicWriteJson(this.codebasePath(), parsed);
|
|
504
|
+
await this.touchManifest();
|
|
505
|
+
}
|
|
506
|
+
async loadResume() {
|
|
507
|
+
try {
|
|
508
|
+
return await readJson(this.resumePath(), ResumeFocusSchema);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async saveResume(resume) {
|
|
515
|
+
const parsed = ResumeFocusSchema.parse(resume);
|
|
516
|
+
await atomicWriteJson(this.resumePath(), parsed);
|
|
517
|
+
await this.touchManifest();
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Authorize a temporary guard bypass so edit/write tools may proceed even
|
|
521
|
+
* when no task is in-progress. Harness-agnostic: stored in resume.json so
|
|
522
|
+
* every adapter (Pi, Claude Code, Codex, ...) reads the same source.
|
|
523
|
+
* Time-scoped; auto-expires after `durationMinutes` (default 15).
|
|
524
|
+
*/
|
|
525
|
+
async authorizeGuardBypass(durationMinutes = 15) {
|
|
526
|
+
const resume = await this.loadResume() ?? {
|
|
527
|
+
updatedAt: nowISO(),
|
|
528
|
+
currentPhaseId: "",
|
|
529
|
+
inProgressTaskIds: [],
|
|
530
|
+
nextSteps: [],
|
|
531
|
+
blockers: [],
|
|
532
|
+
notes: "",
|
|
533
|
+
lastSessionSummary: "",
|
|
534
|
+
guardBypassUntil: "",
|
|
535
|
+
};
|
|
536
|
+
const until = new Date(Date.now() + durationMinutes * 60_000).toISOString();
|
|
537
|
+
resume.guardBypassUntil = until;
|
|
538
|
+
resume.updatedAt = nowISO();
|
|
539
|
+
await this.saveResume(resume);
|
|
540
|
+
return until;
|
|
541
|
+
}
|
|
542
|
+
/** Clear any active guard bypass. */
|
|
543
|
+
async clearGuardBypass() {
|
|
544
|
+
const resume = await this.loadResume();
|
|
545
|
+
if (!resume || !resume.guardBypassUntil)
|
|
546
|
+
return;
|
|
547
|
+
resume.guardBypassUntil = "";
|
|
548
|
+
resume.updatedAt = nowISO();
|
|
549
|
+
await this.saveResume(resume);
|
|
550
|
+
}
|
|
551
|
+
/** True when a guard bypass is currently active (not expired). */
|
|
552
|
+
async isGuardBypassed() {
|
|
553
|
+
const resume = await this.loadResume();
|
|
554
|
+
if (!resume?.guardBypassUntil)
|
|
555
|
+
return false;
|
|
556
|
+
const until = Date.parse(resume.guardBypassUntil);
|
|
557
|
+
if (!Number.isFinite(until))
|
|
558
|
+
return false;
|
|
559
|
+
return until > Date.now();
|
|
560
|
+
}
|
|
561
|
+
async loadActivityLog() {
|
|
562
|
+
try {
|
|
563
|
+
return await readJson(this.activityPath(), ActivityLogSchema);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return { entries: [] };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async handoffExists() {
|
|
570
|
+
try {
|
|
571
|
+
await access(this.handoffPath());
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async loadHandoff() {
|
|
579
|
+
try {
|
|
580
|
+
const [content, info] = await Promise.all([
|
|
581
|
+
readFile(this.handoffPath(), "utf-8"),
|
|
582
|
+
stat(this.handoffPath()),
|
|
583
|
+
]);
|
|
584
|
+
const createdAt = content.match(/^Created at:\s*(.+)$/m)?.[1]?.trim() ?? info.birthtime.toISOString();
|
|
585
|
+
const updatedAt = content.match(/^Updated at:\s*(.+)$/m)?.[1]?.trim() ?? info.mtime.toISOString();
|
|
586
|
+
return {
|
|
587
|
+
content,
|
|
588
|
+
createdAt,
|
|
589
|
+
updatedAt,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async saveHandoff(content) {
|
|
597
|
+
await atomicWriteText(this.handoffPath(), content);
|
|
598
|
+
await this.touchManifest();
|
|
599
|
+
}
|
|
600
|
+
async deleteHandoff() {
|
|
601
|
+
try {
|
|
602
|
+
await unlink(this.handoffPath());
|
|
603
|
+
}
|
|
604
|
+
catch { }
|
|
605
|
+
await this.touchManifest();
|
|
606
|
+
}
|
|
607
|
+
async appendActivity(type, ref, summary) {
|
|
608
|
+
const log = await this.loadActivityLog();
|
|
609
|
+
const id = `act-${log.entries.length + 1}-${type}`;
|
|
610
|
+
const entry = { id, at: nowISO(), type, ref, summary };
|
|
611
|
+
log.entries.push(entry);
|
|
612
|
+
// Cap to last 200 entries
|
|
613
|
+
if (log.entries.length > 200)
|
|
614
|
+
log.entries = log.entries.slice(-200);
|
|
615
|
+
await atomicWriteJson(this.activityPath(), { entries: log.entries });
|
|
616
|
+
await this.touchManifest();
|
|
617
|
+
return entry;
|
|
618
|
+
}
|
|
619
|
+
/** Derive an up-to-date resume focus from the current workspace state. */
|
|
620
|
+
async refreshResume(notes, lastSessionSummary) {
|
|
621
|
+
const workspace = await this.loadAll();
|
|
622
|
+
const inProgressPhases = workspace.phases.filter((p) => p.status === "in-progress");
|
|
623
|
+
const inProgressTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "in-progress"));
|
|
624
|
+
const blockedTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "blocked"));
|
|
625
|
+
const existing = await this.loadResume();
|
|
626
|
+
const resume = {
|
|
627
|
+
updatedAt: nowISO(),
|
|
628
|
+
currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
|
|
629
|
+
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
630
|
+
nextSteps: existing?.nextSteps ?? [],
|
|
631
|
+
blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
|
|
632
|
+
notes: notes ?? existing?.notes ?? "",
|
|
633
|
+
lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
|
|
634
|
+
guardBypassUntil: existing?.guardBypassUntil ?? "",
|
|
635
|
+
};
|
|
636
|
+
await this.saveResume(resume);
|
|
637
|
+
return resume;
|
|
638
|
+
}
|
|
639
|
+
async loadRequirements() {
|
|
640
|
+
try {
|
|
641
|
+
return await readJson(this.requirementsPath(), RequirementsDocumentSchema);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return { requirements: [] };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async loadAllPhases() {
|
|
648
|
+
const { readdir } = await import("node:fs/promises");
|
|
649
|
+
let files;
|
|
650
|
+
try {
|
|
651
|
+
files = await readdir(this.phasesDir());
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
const results = [];
|
|
657
|
+
for (const f of files.sort()) {
|
|
658
|
+
if (!f.endsWith(".json"))
|
|
659
|
+
continue;
|
|
660
|
+
try {
|
|
661
|
+
results.push(await this.loadPhase(f.replace(/\.json$/, "")));
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// skip corrupted files
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return results.sort((left, right) => {
|
|
668
|
+
const leftFeature = left.featureId ?? "~orphan";
|
|
669
|
+
const rightFeature = right.featureId ?? "~orphan";
|
|
670
|
+
if (leftFeature !== rightFeature)
|
|
671
|
+
return leftFeature.localeCompare(rightFeature);
|
|
672
|
+
if (left.number !== right.number)
|
|
673
|
+
return left.number - right.number;
|
|
674
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
async loadAll() {
|
|
678
|
+
const [manifest, project, features, requirements, phases] = await Promise.all([
|
|
679
|
+
this.loadManifest(),
|
|
680
|
+
this.loadProject(),
|
|
681
|
+
this.loadFeatures(),
|
|
682
|
+
this.loadRequirements(),
|
|
683
|
+
this.loadAllPhases(),
|
|
684
|
+
]);
|
|
685
|
+
return PlanWorkspaceSchema.parse({ manifest, project, features, requirements, phases });
|
|
686
|
+
}
|
|
687
|
+
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
688
|
+
* dangling feature.phaseIds references. Idempotent. */
|
|
689
|
+
async migratePhaseIds() {
|
|
690
|
+
const { readdir, unlink } = await import("node:fs/promises");
|
|
691
|
+
const phases = await this.loadAllPhases();
|
|
692
|
+
const features = await this.loadFeatures();
|
|
693
|
+
// Infer missing featureId from feature.phaseIds references (legacy back-link).
|
|
694
|
+
const legacyIdToFeatureId = new Map();
|
|
695
|
+
for (const feature of features.features) {
|
|
696
|
+
for (const ref of feature.phaseIds) {
|
|
697
|
+
if (isLegacyPhaseId(ref) && !legacyIdToFeatureId.has(ref)) {
|
|
698
|
+
legacyIdToFeatureId.set(ref, feature.id);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const phaseIdByLegacy = new Map();
|
|
703
|
+
let renamed = 0;
|
|
704
|
+
let inferred = 0;
|
|
705
|
+
for (const phase of phases) {
|
|
706
|
+
if (!isLegacyPhaseId(phase.id))
|
|
707
|
+
continue;
|
|
708
|
+
let featureId = phase.featureId ?? legacyIdToFeatureId.get(phase.id);
|
|
709
|
+
if (!featureId)
|
|
710
|
+
continue;
|
|
711
|
+
if (!phase.featureId) {
|
|
712
|
+
phase.featureId = featureId;
|
|
713
|
+
inferred += 1;
|
|
714
|
+
}
|
|
715
|
+
const newId = createPhaseId();
|
|
716
|
+
if (newId === phase.id)
|
|
717
|
+
continue;
|
|
718
|
+
phaseIdByLegacy.set(phase.id, newId);
|
|
719
|
+
const oldId = phase.id;
|
|
720
|
+
phase.id = newId;
|
|
721
|
+
for (const task of phase.tasks) {
|
|
722
|
+
task.phaseId = newId;
|
|
723
|
+
}
|
|
724
|
+
await this.savePhase(phase);
|
|
725
|
+
try {
|
|
726
|
+
await unlink(this.phasePath(oldId));
|
|
727
|
+
}
|
|
728
|
+
catch { }
|
|
729
|
+
renamed += 1;
|
|
730
|
+
}
|
|
731
|
+
// Repair feature.phaseIds: replace legacy refs with new ids, drop dangling ones.
|
|
732
|
+
const knownPhaseIds = new Set(phases.map((p) => p.id));
|
|
733
|
+
let repaired = 0;
|
|
734
|
+
let dirty = false;
|
|
735
|
+
for (const feature of features.features) {
|
|
736
|
+
const next = [];
|
|
737
|
+
for (const ref of feature.phaseIds) {
|
|
738
|
+
const resolved = phaseIdByLegacy.get(ref) ?? ref;
|
|
739
|
+
if (knownPhaseIds.has(resolved)) {
|
|
740
|
+
next.push(resolved);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
repaired += 1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (next.length !== feature.phaseIds.length || next.some((id, i) => id !== feature.phaseIds[i])) {
|
|
747
|
+
feature.phaseIds = next;
|
|
748
|
+
feature.updatedAt = nowISO();
|
|
749
|
+
dirty = true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (dirty)
|
|
753
|
+
await this.saveFeatures(features);
|
|
754
|
+
return { renamed, repaired, inferred };
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Remove orphan backup/temp files from .planner/:
|
|
758
|
+
* - `*.json.bak` whose main `.json` no longer exists (e.g. deleted phases)
|
|
759
|
+
* - `*.tmp.*` leftover from interrupted atomic writes
|
|
760
|
+
* Harness-agnostic; safe to run in background at startup.
|
|
761
|
+
*/
|
|
762
|
+
async cleanupOrphanBackups() {
|
|
763
|
+
let removed = 0;
|
|
764
|
+
try {
|
|
765
|
+
const { readdir, unlink, stat } = await import("node:fs/promises");
|
|
766
|
+
const phasesDir = this.phasesDir();
|
|
767
|
+
const dirs = [this.root, phasesDir];
|
|
768
|
+
for (const dir of dirs) {
|
|
769
|
+
let entries = [];
|
|
770
|
+
try {
|
|
771
|
+
entries = await readdir(dir);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
for (const name of entries) {
|
|
777
|
+
const isBak = name.endsWith(".json.bak");
|
|
778
|
+
const isTmp = name.includes(".tmp.");
|
|
779
|
+
if (!isBak && !isTmp)
|
|
780
|
+
continue;
|
|
781
|
+
const full = join(dir, name);
|
|
782
|
+
if (isBak) {
|
|
783
|
+
// Orphan = the main json file no longer exists
|
|
784
|
+
const mainPath = full.slice(0, -".bak".length);
|
|
785
|
+
try {
|
|
786
|
+
await stat(mainPath);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
catch { /* main gone → orphan */ }
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await unlink(full);
|
|
793
|
+
removed += 1;
|
|
794
|
+
}
|
|
795
|
+
catch { /* ignore */ }
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch { /* best-effort */ }
|
|
800
|
+
return { removed };
|
|
801
|
+
}
|
|
802
|
+
/** Repair dangling references and report integrity. One-shot maintenance op. */
|
|
803
|
+
async repair() {
|
|
804
|
+
return this.runAsBatch(async () => {
|
|
805
|
+
const migrated = await this.migratePhaseIds();
|
|
806
|
+
const integrity = await this.validateIntegrity();
|
|
807
|
+
await this.writeGenerated();
|
|
808
|
+
return { migrated, integrity };
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
/** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
|
|
812
|
+
async validateIntegrity() {
|
|
813
|
+
const phases = await this.loadAllPhases();
|
|
814
|
+
const features = await this.loadFeatures();
|
|
815
|
+
const seen = new Map();
|
|
816
|
+
for (const phase of phases) {
|
|
817
|
+
seen.set(phase.id, (seen.get(phase.id) ?? 0) + 1);
|
|
818
|
+
}
|
|
819
|
+
const duplicatePhaseIds = [...seen.entries()].filter(([, count]) => count > 1).map(([id]) => id);
|
|
820
|
+
const knownPhaseIds = new Set(phases.map((p) => p.id));
|
|
821
|
+
const danglingPhaseIds = [];
|
|
822
|
+
for (const feature of features.features) {
|
|
823
|
+
for (const ref of feature.phaseIds) {
|
|
824
|
+
if (!knownPhaseIds.has(ref))
|
|
825
|
+
danglingPhaseIds.push(`${feature.id} -> ${ref}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { duplicatePhaseIds, danglingPhaseIds };
|
|
829
|
+
}
|
|
830
|
+
derivePhaseStatus(phase) {
|
|
831
|
+
if (phase.tasks.length === 0)
|
|
832
|
+
return phase.status;
|
|
833
|
+
const taskStatuses = phase.tasks.map((task) => task.status);
|
|
834
|
+
const allRejectedOrCanceled = taskStatuses.every((status) => status === "rejected" || status === "canceled");
|
|
835
|
+
const anyBlocked = taskStatuses.some((status) => status === "blocked");
|
|
836
|
+
const anyInProgress = taskStatuses.some((status) => status === "in-progress");
|
|
837
|
+
const anyWaiting = taskStatuses.some((status) => status === "waiting");
|
|
838
|
+
const anyDeferred = taskStatuses.some((status) => status === "deferred");
|
|
839
|
+
const anyPlanned = taskStatuses.some((status) => status === "planned");
|
|
840
|
+
const anyDone = taskStatuses.some((status) => status === "done");
|
|
841
|
+
if (allRejectedOrCanceled)
|
|
842
|
+
return "rejected";
|
|
843
|
+
if (anyBlocked)
|
|
844
|
+
return "blocked";
|
|
845
|
+
if (anyInProgress)
|
|
846
|
+
return "in-progress";
|
|
847
|
+
if (anyWaiting)
|
|
848
|
+
return "waiting";
|
|
849
|
+
if (anyDeferred)
|
|
850
|
+
return "deferred";
|
|
851
|
+
if (anyPlanned)
|
|
852
|
+
return "planned";
|
|
853
|
+
if (anyDone)
|
|
854
|
+
return "done";
|
|
855
|
+
return "planned";
|
|
856
|
+
}
|
|
857
|
+
deriveFeatureStatus(featureId, currentStatus, phases) {
|
|
858
|
+
const featurePhases = phases.filter((phase) => phase.featureId === featureId);
|
|
859
|
+
if (featurePhases.length === 0)
|
|
860
|
+
return currentStatus;
|
|
861
|
+
const phaseStatuses = featurePhases.map((phase) => phase.status);
|
|
862
|
+
const allRejectedOrCanceled = phaseStatuses.every((status) => status === "rejected" || status === "canceled");
|
|
863
|
+
const anyBlocked = phaseStatuses.some((status) => status === "blocked");
|
|
864
|
+
const anyActive = phaseStatuses.some((status) => status === "discovery" || status === "in-progress");
|
|
865
|
+
const anyWaiting = phaseStatuses.some((status) => status === "waiting");
|
|
866
|
+
const anyDeferred = phaseStatuses.some((status) => status === "deferred");
|
|
867
|
+
const anyPlannedLike = phaseStatuses.some((status) => status === "draft" || status === "planned");
|
|
868
|
+
const anyDone = phaseStatuses.every((status) => status === "done");
|
|
869
|
+
if (allRejectedOrCanceled)
|
|
870
|
+
return "rejected";
|
|
871
|
+
if (anyBlocked)
|
|
872
|
+
return "blocked";
|
|
873
|
+
if (anyActive)
|
|
874
|
+
return "in-progress";
|
|
875
|
+
if (anyWaiting)
|
|
876
|
+
return "waiting";
|
|
877
|
+
if (anyDeferred)
|
|
878
|
+
return "deferred";
|
|
879
|
+
if (anyPlannedLike)
|
|
880
|
+
return "planned";
|
|
881
|
+
if (anyDone)
|
|
882
|
+
return "done";
|
|
883
|
+
return "planned";
|
|
884
|
+
}
|
|
885
|
+
async syncStatuses() {
|
|
886
|
+
// Run as a batch so the internal saveFeatures + N savePhase calls do not
|
|
887
|
+
// re-trigger syncStatuses on every write (O(N^2) on large planners).
|
|
888
|
+
await this.runAsBatch(async () => {
|
|
889
|
+
await this.migratePhaseIds();
|
|
890
|
+
const workspace = await this.loadAll();
|
|
891
|
+
const { phases, features } = workspace;
|
|
892
|
+
// 1. Update Phase statuses based on tasks
|
|
893
|
+
for (const phase of phases) {
|
|
894
|
+
phase.status = this.derivePhaseStatus(phase);
|
|
895
|
+
}
|
|
896
|
+
// 2. Update Feature statuses based on phases
|
|
897
|
+
for (const feature of features.features) {
|
|
898
|
+
feature.status = this.deriveFeatureStatus(feature.id, feature.status, phases);
|
|
899
|
+
}
|
|
900
|
+
// 3. Save everything
|
|
901
|
+
await this.saveFeatures(features);
|
|
902
|
+
for (const phase of phases) {
|
|
903
|
+
await this.savePhase(phase);
|
|
904
|
+
}
|
|
905
|
+
// 4. Refresh resume focus so a subentrating agent sees current state
|
|
906
|
+
await this.refreshResume();
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/** Optimized rollup: syncs only the affected phase and its parent feature.
|
|
910
|
+
* Drastically reduces write operations and 'busy' window for task updates. */
|
|
911
|
+
async syncTaskStatusRollup(phaseId) {
|
|
912
|
+
const phase = await this.loadPhase(phaseId);
|
|
913
|
+
phase.status = this.derivePhaseStatus(phase);
|
|
914
|
+
await this.savePhase(phase);
|
|
915
|
+
if (phase.featureId) {
|
|
916
|
+
const featuresDoc = await this.loadFeatures();
|
|
917
|
+
const feature = featuresDoc.features.find((f) => f.id === phase.featureId);
|
|
918
|
+
if (feature) {
|
|
919
|
+
// To derive feature status, we still need the statuses of all its phases
|
|
920
|
+
const allPhases = await this.loadAllPhases();
|
|
921
|
+
feature.status = this.deriveFeatureStatus(feature.id, feature.status, allPhases);
|
|
922
|
+
await this.saveFeatures(featuresDoc);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
await this.refreshResume();
|
|
926
|
+
}
|
|
927
|
+
// ── Savers ───────────────────────────────────────────────────────────
|
|
928
|
+
async updateProject(updater) {
|
|
929
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
|
|
930
|
+
await this.maybeAutoSync();
|
|
931
|
+
return updated;
|
|
932
|
+
}
|
|
933
|
+
async updateFeatures(updater) {
|
|
934
|
+
const updated = await atomicUpdateJson(this.featuresPath(), FeaturesDocumentSchema, (current) => this.normalizeFeaturesDocument(updater(current)).doc);
|
|
935
|
+
await this.maybeAutoSync();
|
|
936
|
+
return updated;
|
|
937
|
+
}
|
|
938
|
+
async updateRequirements(updater) {
|
|
939
|
+
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
|
|
940
|
+
await this.maybeAutoSync();
|
|
941
|
+
return updated;
|
|
942
|
+
}
|
|
943
|
+
async saveProject(project) {
|
|
944
|
+
const parsed = ProjectSchema.parse(project);
|
|
945
|
+
await atomicWriteJson(this.projectPath(), parsed);
|
|
946
|
+
await this.touchManifest();
|
|
947
|
+
await this.maybeAutoSync();
|
|
948
|
+
}
|
|
949
|
+
async saveFeatures(features) {
|
|
950
|
+
const parsed = FeaturesDocumentSchema.parse(this.normalizeFeaturesDocument(features).doc);
|
|
951
|
+
await atomicWriteJson(this.featuresPath(), parsed);
|
|
952
|
+
await this.touchManifest();
|
|
953
|
+
await this.maybeAutoSync();
|
|
954
|
+
}
|
|
955
|
+
async saveRequirements(reqs) {
|
|
956
|
+
const parsed = RequirementsDocumentSchema.parse(reqs);
|
|
957
|
+
await atomicWriteJson(this.requirementsPath(), parsed);
|
|
958
|
+
await this.touchManifest();
|
|
959
|
+
}
|
|
960
|
+
async savePhase(phase) {
|
|
961
|
+
const parsed = PhaseSchema.parse(this.normalizePhaseDocument(phase).phase);
|
|
962
|
+
await mkdir(this.phasesDir(), { recursive: true });
|
|
963
|
+
await atomicWriteJson(this.phasePath(parsed.id), parsed);
|
|
964
|
+
await this.touchManifest();
|
|
965
|
+
await this.maybeAutoSync();
|
|
966
|
+
}
|
|
967
|
+
/** Atomic read-modify-write on a single phase file. Serializes concurrent
|
|
968
|
+
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
969
|
+
* don't lose tasks (last-write-wins race condition). */
|
|
970
|
+
async updatePhase(phaseId, updater) {
|
|
971
|
+
const updated = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (phase) => this.normalizePhaseDocument(updater(phase)).phase);
|
|
972
|
+
await this.maybeAutoSync();
|
|
973
|
+
return updated;
|
|
974
|
+
}
|
|
975
|
+
async deletePhase(phaseId) {
|
|
976
|
+
try {
|
|
977
|
+
await unlink(this.phasePath(phaseId));
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
// already gone
|
|
981
|
+
}
|
|
982
|
+
await this.touchManifest();
|
|
983
|
+
}
|
|
984
|
+
// ── Workspace-level operations ─────────────────────────────────────
|
|
985
|
+
/** Load the full workspace (manifest + phases + project + requirements + features) */
|
|
986
|
+
async loadWorkspace() {
|
|
987
|
+
const manifest = await this.loadManifest();
|
|
988
|
+
const phases = await this.loadAllPhases();
|
|
989
|
+
const project = await this.loadProject();
|
|
990
|
+
const features = await this.loadFeatures();
|
|
991
|
+
const requirements = await this.loadRequirements();
|
|
992
|
+
return { manifest, phases, project, features, requirements };
|
|
993
|
+
}
|
|
994
|
+
// ── Markdown generation ────────────────────────────────────────────
|
|
995
|
+
/** Load all data, render markdown, and write into generated/. */
|
|
996
|
+
async writeGenerated() {
|
|
997
|
+
const { PlanRenderer } = await import("./renderer.js");
|
|
998
|
+
const plan = await this.loadAll();
|
|
999
|
+
const renderer = new PlanRenderer();
|
|
1000
|
+
const files = renderer.render(plan);
|
|
1001
|
+
const written = [];
|
|
1002
|
+
const genDir = this.generatedDir();
|
|
1003
|
+
const phasesDir = join(genDir, "phases");
|
|
1004
|
+
await mkdir(phasesDir, { recursive: true });
|
|
1005
|
+
for (const [relPath, content] of files) {
|
|
1006
|
+
const fullPath = join(genDir, relPath);
|
|
1007
|
+
// Ensure subdirectory exists
|
|
1008
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
1009
|
+
if (dir !== genDir) {
|
|
1010
|
+
await mkdir(dir, { recursive: true });
|
|
1011
|
+
}
|
|
1012
|
+
await writeFile(fullPath, content, "utf-8");
|
|
1013
|
+
written.push(relPath);
|
|
1014
|
+
}
|
|
1015
|
+
return written;
|
|
1016
|
+
}
|
|
1017
|
+
// ── Touch ────────────────────────────────────────────────────────────
|
|
1018
|
+
/** Update manifest.updatedAt to reflect a change. */
|
|
1019
|
+
async touchManifest() {
|
|
1020
|
+
try {
|
|
1021
|
+
const m = await this.loadManifest();
|
|
1022
|
+
m.updatedAt = nowISO();
|
|
1023
|
+
await atomicWriteJson(this.manifestPath(), m);
|
|
1024
|
+
}
|
|
1025
|
+
catch {
|
|
1026
|
+
// if manifest doesn't exist yet, skip
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|