@agent-plan/core 0.2.19-next.0 → 0.2.19-next.10
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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/naming.d.ts +9 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +8 -0
- package/dist/plan-store.d.ts +92 -16
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +376 -214
- package/dist/recap.d.ts +32 -0
- package/dist/recap.d.ts.map +1 -0
- package/dist/recap.js +119 -0
- package/dist/refs.d.ts +23 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +72 -1
- package/dist/schema.d.ts +152 -27
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +33 -2
- package/package.json +1 -1
package/dist/plan-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { access, copyFile, mkdir, readdir, readFile, rename,
|
|
1
|
+
import { access, copyFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema,
|
|
3
|
+
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
|
|
4
4
|
import { createFeatureId, createPhaseId, createRequirementId, createShortId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
|
|
5
5
|
function nowISO() {
|
|
6
6
|
return new Date().toISOString();
|
|
@@ -176,6 +176,100 @@ export async function migrateToUuids(store) {
|
|
|
176
176
|
await store.writeGenerated();
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* One-time idempotent migration to GLOBAL F/P/T numbering.
|
|
181
|
+
*
|
|
182
|
+
* Legacy plans assign Phase.number per-feature and Task.number per-phase, so
|
|
183
|
+
* every feature has a P001 and every phase has a T001 (ambiguous in chat/handoffs).
|
|
184
|
+
* This renumbers ALL features/phases/tasks by `createdAt` asc (stable tiebreak by
|
|
185
|
+
* id) into a single project-wide 1..N sequence and sets the monotonic project
|
|
186
|
+
* counters (nextFeatureNumber/nextPhaseNumber/nextTaskNumber).
|
|
187
|
+
*
|
|
188
|
+
* Idempotent: if no duplicate phase/task/feature numbers exist across the
|
|
189
|
+
* project, the plan is already global → no renumber writes happen (only the
|
|
190
|
+
* counters are ensured, in case project.json predates them). MUST run before
|
|
191
|
+
* ensureStructureOrdering (which no longer renumbers — numbers are stable).
|
|
192
|
+
*/
|
|
193
|
+
export async function migrateToGlobalSequence(store) {
|
|
194
|
+
return store.runBatchForMigration(async () => {
|
|
195
|
+
const ws = await store.loadAll();
|
|
196
|
+
const phases = ws.phases;
|
|
197
|
+
const features = ws.features.features;
|
|
198
|
+
const project = ws.project;
|
|
199
|
+
const allTasks = [];
|
|
200
|
+
for (const phase of phases)
|
|
201
|
+
for (const task of phase.tasks)
|
|
202
|
+
allTasks.push({ phase, task });
|
|
203
|
+
const hasDupes = (nums) => new Set(nums).size !== nums.length;
|
|
204
|
+
const phaseDupes = hasDupes(phases.map((p) => p.number));
|
|
205
|
+
const taskDupes = hasDupes(allTasks.map((x) => x.task.number));
|
|
206
|
+
const featureDupes = hasDupes(features.map((f) => f.number));
|
|
207
|
+
const maxP = phases.reduce((m, p) => Math.max(m, p.number), 0);
|
|
208
|
+
const maxT = allTasks.reduce((m, x) => Math.max(m, x.task.number), 0);
|
|
209
|
+
const maxF = features.reduce((m, f) => Math.max(m, f.number), 0);
|
|
210
|
+
if (!phaseDupes && !taskDupes && !featureDupes) {
|
|
211
|
+
// Already global. Ensure counters are set (project.json may predate them).
|
|
212
|
+
let changed = false;
|
|
213
|
+
if (project.nextPhaseNumber <= maxP) {
|
|
214
|
+
project.nextPhaseNumber = maxP + 1;
|
|
215
|
+
changed = true;
|
|
216
|
+
}
|
|
217
|
+
if (project.nextTaskNumber <= maxT) {
|
|
218
|
+
project.nextTaskNumber = maxT + 1;
|
|
219
|
+
changed = true;
|
|
220
|
+
}
|
|
221
|
+
if (project.nextFeatureNumber <= maxF) {
|
|
222
|
+
project.nextFeatureNumber = maxF + 1;
|
|
223
|
+
changed = true;
|
|
224
|
+
}
|
|
225
|
+
if (changed)
|
|
226
|
+
await store.saveProject(project);
|
|
227
|
+
return { migrated: false, phases: phases.length, tasks: allTasks.length, features: features.length };
|
|
228
|
+
}
|
|
229
|
+
const renumber = (arr) => arr
|
|
230
|
+
.slice()
|
|
231
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
232
|
+
.map((x, i) => ({ ...x, number: i + 1 }));
|
|
233
|
+
const newFeatures = renumber(features);
|
|
234
|
+
const newPhases = renumber(phases);
|
|
235
|
+
const numberedTasks = renumber(allTasks.map((x) => x.task));
|
|
236
|
+
// Reassemble renumbered tasks into their phases, keyed by the task's OWN
|
|
237
|
+
// phaseId (source of truth). NOTE: do NOT pair `numberedTasks[i]` with
|
|
238
|
+
// `allTasks[i]!.phase.id` — the two arrays are in DIFFERENT orders
|
|
239
|
+
// (numberedTasks is sorted by createdAt, allTasks is in phase-iteration
|
|
240
|
+
// order), so a positional index would file each task under the wrong phase.
|
|
241
|
+
const phaseIdByTaskId = new Map(allTasks.map((x) => [x.task.id, x.phase.id]));
|
|
242
|
+
const newPhaseIds = new Set(newPhases.map((p) => p.id));
|
|
243
|
+
const tasksByPhase = new Map();
|
|
244
|
+
for (const t of numberedTasks) {
|
|
245
|
+
// Prefer the task's own phaseId when it points to a real phase; otherwise
|
|
246
|
+
// fall back to the phase the task was loaded from (handles legacy tasks
|
|
247
|
+
// with empty/stale phaseId without losing them).
|
|
248
|
+
const pid = (t.phaseId && newPhaseIds.has(t.phaseId)) ? t.phaseId : (phaseIdByTaskId.get(t.id) ?? "");
|
|
249
|
+
const bucket = tasksByPhase.get(pid) ?? [];
|
|
250
|
+
bucket.push(t);
|
|
251
|
+
tasksByPhase.set(pid, bucket);
|
|
252
|
+
}
|
|
253
|
+
const finalPhases = newPhases.map((p) => {
|
|
254
|
+
const tasks = tasksByPhase.get(p.id) ?? [];
|
|
255
|
+
const order = new Map(tasks.map((t) => [t.id, t]));
|
|
256
|
+
const ordered = p.taskIds.map((id) => order.get(id)).filter((t) => Boolean(t));
|
|
257
|
+
for (const t of tasks.sort((a, b) => a.number - b.number))
|
|
258
|
+
if (!ordered.includes(t))
|
|
259
|
+
ordered.push(t);
|
|
260
|
+
return { ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) };
|
|
261
|
+
});
|
|
262
|
+
await store.saveFeatures({ features: newFeatures });
|
|
263
|
+
for (const p of finalPhases)
|
|
264
|
+
await store.savePhase(p);
|
|
265
|
+
project.nextFeatureNumber = newFeatures.length + 1;
|
|
266
|
+
project.nextPhaseNumber = newPhases.length + 1;
|
|
267
|
+
project.nextTaskNumber = numberedTasks.length + 1;
|
|
268
|
+
await store.saveProject(project);
|
|
269
|
+
await store.writeGenerated();
|
|
270
|
+
return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
|
|
271
|
+
});
|
|
272
|
+
}
|
|
179
273
|
async function readJson(path, schema) {
|
|
180
274
|
try {
|
|
181
275
|
const raw = await readFile(path, "utf-8");
|
|
@@ -193,7 +287,6 @@ async function readJson(path, schema) {
|
|
|
193
287
|
throw new PlanStoreError(`read failed: ${path}`, cause);
|
|
194
288
|
}
|
|
195
289
|
}
|
|
196
|
-
// ─── PlanStore ──────────────────────────────────────────────────────────
|
|
197
290
|
export class PlanStore {
|
|
198
291
|
root;
|
|
199
292
|
autoSync = false;
|
|
@@ -230,36 +323,24 @@ export class PlanStore {
|
|
|
230
323
|
async runBatchForMigration(fn) {
|
|
231
324
|
return this.runAsBatch(fn);
|
|
232
325
|
}
|
|
326
|
+
/** Public batch wrapper: suspend autoSync (status rollup) for a sequence of
|
|
327
|
+
* writes. Use for priority-only reorders so they don't recompute phase/feature
|
|
328
|
+
* status (a reorder must not flip a partially-done feature to in-progress). */
|
|
329
|
+
async runBatch(fn) {
|
|
330
|
+
return this.runAsBatch(fn);
|
|
331
|
+
}
|
|
233
332
|
async maybeAutoSync() {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
this.syncGuard = true;
|
|
238
|
-
await this.syncStatuses();
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
this.syncGuard = false;
|
|
242
|
-
}
|
|
333
|
+
// No-op: status is derived on read, so there is nothing to sync after a
|
|
334
|
+
// save. Kept so existing save* call sites compile unchanged.
|
|
243
335
|
}
|
|
244
336
|
normalizeTasks(tasks) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (task.number !== nextNumber)
|
|
249
|
-
changed = true;
|
|
250
|
-
return { ...task, number: nextNumber };
|
|
251
|
-
});
|
|
252
|
-
return { tasks: normalized, changed };
|
|
337
|
+
// Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
|
|
338
|
+
// Do NOT renumber here — renumbering would break references after deletions.
|
|
339
|
+
return { tasks, changed: false };
|
|
253
340
|
}
|
|
254
341
|
normalizeFeaturesDocument(doc) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const nextNumber = index + 1;
|
|
258
|
-
if (feature.number !== nextNumber)
|
|
259
|
-
changed = true;
|
|
260
|
-
return { ...feature, number: nextNumber };
|
|
261
|
-
});
|
|
262
|
-
return { doc: { features: normalized }, changed };
|
|
342
|
+
// Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
|
|
343
|
+
return { doc, changed: false };
|
|
263
344
|
}
|
|
264
345
|
normalizePhaseDocument(phase) {
|
|
265
346
|
const { tasks, changed } = this.normalizeTasks(phase.tasks);
|
|
@@ -289,10 +370,7 @@ export class PlanStore {
|
|
|
289
370
|
orphanPhases.push(phase);
|
|
290
371
|
}
|
|
291
372
|
}
|
|
292
|
-
const normalizedFeatures = featuresDoc.features.map((feature
|
|
293
|
-
const nextFeatureNumber = featureIndex + 1;
|
|
294
|
-
if (feature.number !== nextFeatureNumber)
|
|
295
|
-
changed = true;
|
|
373
|
+
const normalizedFeatures = featuresDoc.features.map((feature) => {
|
|
296
374
|
const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
|
|
297
375
|
const linkedIds = new Set(linked.map((phase) => phase.id));
|
|
298
376
|
const inferred = (phasesByFeature.get(feature.id) ?? []).filter((phase) => !linkedIds.has(phase.id));
|
|
@@ -301,12 +379,7 @@ export class PlanStore {
|
|
|
301
379
|
if (normalizedPhaseIds.length !== feature.phaseIds.length || normalizedPhaseIds.some((id, index) => id !== feature.phaseIds[index])) {
|
|
302
380
|
changed = true;
|
|
303
381
|
}
|
|
304
|
-
orderedPhases.forEach((phase
|
|
305
|
-
const nextPhaseNumber = index + 1;
|
|
306
|
-
if (phase.number !== nextPhaseNumber) {
|
|
307
|
-
phase.number = nextPhaseNumber;
|
|
308
|
-
changed = true;
|
|
309
|
-
}
|
|
382
|
+
orderedPhases.forEach((phase) => {
|
|
310
383
|
const normalizedPhase = this.normalizePhaseDocument(phase);
|
|
311
384
|
if (normalizedPhase.changed) {
|
|
312
385
|
phase.tasks = normalizedPhase.phase.tasks;
|
|
@@ -316,16 +389,10 @@ export class PlanStore {
|
|
|
316
389
|
});
|
|
317
390
|
return {
|
|
318
391
|
...feature,
|
|
319
|
-
number: nextFeatureNumber,
|
|
320
392
|
phaseIds: normalizedPhaseIds,
|
|
321
393
|
};
|
|
322
394
|
});
|
|
323
|
-
orphanPhases.forEach((phase
|
|
324
|
-
const nextPhaseNumber = index + 1;
|
|
325
|
-
if (phase.number !== nextPhaseNumber) {
|
|
326
|
-
phase.number = nextPhaseNumber;
|
|
327
|
-
changed = true;
|
|
328
|
-
}
|
|
395
|
+
orphanPhases.forEach((phase) => {
|
|
329
396
|
const normalizedPhase = this.normalizePhaseDocument(phase);
|
|
330
397
|
if (normalizedPhase.changed) {
|
|
331
398
|
phase.tasks = normalizedPhase.phase.tasks;
|
|
@@ -340,7 +407,7 @@ export class PlanStore {
|
|
|
340
407
|
};
|
|
341
408
|
}
|
|
342
409
|
async ensureStructureOrdering() {
|
|
343
|
-
|
|
410
|
+
const result = await this.runAsBatch(async () => {
|
|
344
411
|
const featuresDoc = await this.loadFeatures();
|
|
345
412
|
const phases = await this.loadAllPhases();
|
|
346
413
|
const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
|
|
@@ -352,6 +419,52 @@ export class PlanStore {
|
|
|
352
419
|
}
|
|
353
420
|
return { changed: true };
|
|
354
421
|
});
|
|
422
|
+
// One-time import of a legacy file-based HANDOFF.md (pre-F004) into the
|
|
423
|
+
// entity-scoped phase.handoff. Idempotent — renames the file to .bak.
|
|
424
|
+
await this.importLegacyHandoffFile().catch(() => { });
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
/** Rebuild each phase's `tasks` + `taskIds` from the task's OWN `phaseId`
|
|
428
|
+
* (source of truth). Heals plans where tasks got filed into the wrong phase
|
|
429
|
+
* file (e.g. the migrateToGlobalSequence index-mismatch bug, @agent-plan/core
|
|
430
|
+
* <0.2.19-next.7). Deterministic, lossless, idempotent: groups every task by
|
|
431
|
+
* its phaseId, preserves each phase's existing taskIds order, appends orphan
|
|
432
|
+
* tasks (whose phaseId dangles or is empty) by number. Writes a phase file
|
|
433
|
+
* only when its task set actually changed. */
|
|
434
|
+
async rebuildContainment() {
|
|
435
|
+
return this.runAsBatch(async () => {
|
|
436
|
+
const phases = await this.loadAllPhases();
|
|
437
|
+
const phaseById = new Map(phases.map((p) => [p.id, p]));
|
|
438
|
+
const allTasks = [];
|
|
439
|
+
for (const p of phases)
|
|
440
|
+
for (const t of p.tasks)
|
|
441
|
+
allTasks.push({ task: t, fromPhaseId: p.id });
|
|
442
|
+
const grouped = new Map();
|
|
443
|
+
let orphan = 0;
|
|
444
|
+
for (const { task, fromPhaseId } of allTasks) {
|
|
445
|
+
const pid = (task.phaseId && phaseById.has(task.phaseId)) ? task.phaseId : fromPhaseId;
|
|
446
|
+
if (!phaseById.has(pid))
|
|
447
|
+
orphan++;
|
|
448
|
+
const bucket = grouped.get(pid) ?? [];
|
|
449
|
+
bucket.push(task);
|
|
450
|
+
grouped.set(pid, bucket);
|
|
451
|
+
}
|
|
452
|
+
let changed = 0;
|
|
453
|
+
for (const p of phases) {
|
|
454
|
+
const tasks = grouped.get(p.id) ?? [];
|
|
455
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
456
|
+
const ordered = p.taskIds.map((id) => byId.get(id)).filter((t) => Boolean(t));
|
|
457
|
+
for (const t of tasks.slice().sort((a, b) => a.number - b.number))
|
|
458
|
+
if (!ordered.some((o) => o.id === t.id))
|
|
459
|
+
ordered.push(t);
|
|
460
|
+
const same = ordered.length === p.tasks.length && ordered.every((t, i) => t.id === p.tasks[i]?.id);
|
|
461
|
+
if (same)
|
|
462
|
+
continue;
|
|
463
|
+
await this.savePhase({ ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) });
|
|
464
|
+
changed++;
|
|
465
|
+
}
|
|
466
|
+
return { changed, tasks: allTasks.length, orphan };
|
|
467
|
+
});
|
|
355
468
|
}
|
|
356
469
|
// ── Path helpers ─────────────────────────────────────────────────────
|
|
357
470
|
manifestPath() {
|
|
@@ -413,9 +526,6 @@ export class PlanStore {
|
|
|
413
526
|
activityPath() {
|
|
414
527
|
return join(this.root, "activity.json");
|
|
415
528
|
}
|
|
416
|
-
handoffPath() {
|
|
417
|
-
return join(this.root, "HANDOFF.md");
|
|
418
|
-
}
|
|
419
529
|
// ── Init ─────────────────────────────────────────────────────────────
|
|
420
530
|
async init(projectName) {
|
|
421
531
|
if (await this.exists()) {
|
|
@@ -454,6 +564,9 @@ export class PlanStore {
|
|
|
454
564
|
beforeTaskStart: [],
|
|
455
565
|
afterPhaseComplete: [],
|
|
456
566
|
},
|
|
567
|
+
nextFeatureNumber: 1,
|
|
568
|
+
nextPhaseNumber: 1,
|
|
569
|
+
nextTaskNumber: 1,
|
|
457
570
|
});
|
|
458
571
|
await this.saveRequirements({ requirements: [] });
|
|
459
572
|
await this.saveFeatures({ features: [] });
|
|
@@ -462,6 +575,7 @@ export class PlanStore {
|
|
|
462
575
|
currentPhaseId: "",
|
|
463
576
|
inProgressTaskIds: [],
|
|
464
577
|
nextSteps: ["Run /planner project discuss to bootstrap discovery"],
|
|
578
|
+
nextStepsUpdatedAt: nowISO(),
|
|
465
579
|
blockers: [],
|
|
466
580
|
notes: "Project initialized. Awaiting discovery.",
|
|
467
581
|
lastSessionSummary: "",
|
|
@@ -484,12 +598,20 @@ export class PlanStore {
|
|
|
484
598
|
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
485
599
|
].join("\n");
|
|
486
600
|
await writeFile(join(this.root, "README.md"), readme, "utf-8");
|
|
487
|
-
// Write a .gitignore inside .planner/ so transient
|
|
601
|
+
// Write a .gitignore inside .planner/ so transient/derived files are
|
|
488
602
|
// not tracked by the host project's git. Git respects nested .gitignore.
|
|
603
|
+
// - *.bak/*.tmp.*: crash backups from atomic writes
|
|
604
|
+
// - resume.json: per-session resume focus + the machine-local guard-bypass
|
|
605
|
+
// timestamp (guardBypassUntil must NOT leak into git/other clones)
|
|
606
|
+
// - generated/: auto-regenerated markdown views (derived from JSON; churn)
|
|
489
607
|
await writeFile(join(this.root, ".gitignore"), [
|
|
490
|
-
"# Agent Plan transient files — do not track",
|
|
608
|
+
"# Agent Plan transient/derived files — do not track",
|
|
491
609
|
"*.bak",
|
|
492
610
|
"*.tmp.*",
|
|
611
|
+
"resume.json",
|
|
612
|
+
"resume.*.json",
|
|
613
|
+
"generated/",
|
|
614
|
+
"handoff-archive/",
|
|
493
615
|
"",
|
|
494
616
|
].join("\n"), "utf-8");
|
|
495
617
|
}
|
|
@@ -509,12 +631,33 @@ export class PlanStore {
|
|
|
509
631
|
async loadProject() {
|
|
510
632
|
return readJson(this.projectPath(), ProjectSchema);
|
|
511
633
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
634
|
+
/**
|
|
635
|
+
* Allocate the next global sequence number for a feature/phase/task.
|
|
636
|
+
* Reads the monotonic counter from project.json, increments it, persists,
|
|
637
|
+
* and returns the allocated number. MUST be called within withFeatureLock
|
|
638
|
+
* (adapters create entities inside a lock) so the counter is race-free.
|
|
639
|
+
* The counter never reuses a number — deletions leave gaps (by design:
|
|
640
|
+
* stable references survive deletion).
|
|
641
|
+
*/
|
|
642
|
+
async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber"); }
|
|
643
|
+
async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber"); }
|
|
644
|
+
async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber"); }
|
|
645
|
+
async allocSeqNumber(key) {
|
|
646
|
+
const project = await this.loadProject();
|
|
647
|
+
const n = project[key];
|
|
648
|
+
project[key] = n + 1;
|
|
649
|
+
await this.saveProject(project);
|
|
650
|
+
return n;
|
|
515
651
|
}
|
|
516
|
-
async
|
|
517
|
-
|
|
652
|
+
async loadPhase(phaseId) {
|
|
653
|
+
const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
|
|
654
|
+
const normalized = this.normalizePhaseDocument(raw).phase;
|
|
655
|
+
return { ...normalized, status: this.derivePhaseStatus(normalized.tasks) };
|
|
656
|
+
}
|
|
657
|
+
/** Read raw feature files WITHOUT the derived `status` field. Used
|
|
658
|
+
* internally so loadFeatures/loadAll can derive status from phases without
|
|
659
|
+
* double-loading. */
|
|
660
|
+
async loadRawFeatures() {
|
|
518
661
|
let jsonFiles = [];
|
|
519
662
|
try {
|
|
520
663
|
const all = await readdir(this.featuresDir());
|
|
@@ -524,28 +667,41 @@ export class PlanStore {
|
|
|
524
667
|
// features/ absent → fall through to legacy single-file layout
|
|
525
668
|
}
|
|
526
669
|
if (jsonFiles.length > 0) {
|
|
527
|
-
const
|
|
670
|
+
const out = [];
|
|
528
671
|
for (const f of jsonFiles) {
|
|
529
672
|
const id = f.replace(/\.json$/, "");
|
|
530
673
|
try {
|
|
531
|
-
|
|
674
|
+
out.push(await readJson(this.featurePath(id), FeatureSchema));
|
|
532
675
|
}
|
|
533
676
|
catch (err) {
|
|
534
677
|
// Skip an invalid feature file rather than failing the whole load.
|
|
535
678
|
console.warn(`[plan-store] skipping invalid feature file ${f}:`, err);
|
|
536
679
|
}
|
|
537
680
|
}
|
|
538
|
-
|
|
681
|
+
// Deterministic order: sort by the persisted `number` (creation order)
|
|
682
|
+
// so callers that renumber by index (normalizeFeaturesDocument) and
|
|
683
|
+
// callers that keep persisted numbers (loadAll) agree, regardless of the
|
|
684
|
+
// filesystem readdir order. Tiebreak by id for full determinism.
|
|
685
|
+
out.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
|
|
686
|
+
return out;
|
|
539
687
|
}
|
|
540
688
|
// Legacy: single features.json (pre-migration read; migration writes on first write op).
|
|
541
689
|
try {
|
|
542
690
|
const legacy = await readJson(this.featuresPath(), FeaturesDocumentSchema);
|
|
543
|
-
|
|
691
|
+
const legacyFeatures = legacy.features;
|
|
692
|
+
legacyFeatures.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
|
|
693
|
+
return legacyFeatures;
|
|
544
694
|
}
|
|
545
695
|
catch {
|
|
546
|
-
return
|
|
696
|
+
return [];
|
|
547
697
|
}
|
|
548
698
|
}
|
|
699
|
+
async loadFeatures() {
|
|
700
|
+
const raws = await this.loadRawFeatures();
|
|
701
|
+
const phases = await this.loadAllPhases();
|
|
702
|
+
const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
703
|
+
return this.normalizeFeaturesDocument({ features }).doc;
|
|
704
|
+
}
|
|
549
705
|
async loadCodebaseProfile() {
|
|
550
706
|
try {
|
|
551
707
|
return await readJson(this.codebasePath(), CodebaseProfileSchema);
|
|
@@ -568,7 +724,18 @@ export class PlanStore {
|
|
|
568
724
|
}
|
|
569
725
|
}
|
|
570
726
|
async saveResume(resume) {
|
|
571
|
-
|
|
727
|
+
// Track when `nextSteps` actually change (free-text can go stale; the recap
|
|
728
|
+
// surfaces nextStepsUpdatedAt so staleness is visible). Preserved when
|
|
729
|
+
// refreshResume keeps existing nextSteps; bumped only on a real change.
|
|
730
|
+
const existing = await this.loadResume().catch(() => null);
|
|
731
|
+
const nextStepsChanged = JSON.stringify(existing?.nextSteps ?? []) !== JSON.stringify(resume.nextSteps ?? []);
|
|
732
|
+
const withTs = {
|
|
733
|
+
...resume,
|
|
734
|
+
nextStepsUpdatedAt: nextStepsChanged
|
|
735
|
+
? nowISO()
|
|
736
|
+
: (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
|
|
737
|
+
};
|
|
738
|
+
const parsed = ResumeFocusSchema.parse(withTs);
|
|
572
739
|
await atomicWriteJson(this.resumePath(), parsed);
|
|
573
740
|
await this.touchManifest();
|
|
574
741
|
}
|
|
@@ -584,6 +751,7 @@ export class PlanStore {
|
|
|
584
751
|
currentPhaseId: "",
|
|
585
752
|
inProgressTaskIds: [],
|
|
586
753
|
nextSteps: [],
|
|
754
|
+
nextStepsUpdatedAt: "",
|
|
587
755
|
blockers: [],
|
|
588
756
|
notes: "",
|
|
589
757
|
lastSessionSummary: "",
|
|
@@ -622,44 +790,6 @@ export class PlanStore {
|
|
|
622
790
|
return { entries: [] };
|
|
623
791
|
}
|
|
624
792
|
}
|
|
625
|
-
async handoffExists() {
|
|
626
|
-
try {
|
|
627
|
-
await access(this.handoffPath());
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
catch {
|
|
631
|
-
return false;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
async loadHandoff() {
|
|
635
|
-
try {
|
|
636
|
-
const [content, info] = await Promise.all([
|
|
637
|
-
readFile(this.handoffPath(), "utf-8"),
|
|
638
|
-
stat(this.handoffPath()),
|
|
639
|
-
]);
|
|
640
|
-
const createdAt = content.match(/^Created at:\s*(.+)$/m)?.[1]?.trim() ?? info.birthtime.toISOString();
|
|
641
|
-
const updatedAt = content.match(/^Updated at:\s*(.+)$/m)?.[1]?.trim() ?? info.mtime.toISOString();
|
|
642
|
-
return {
|
|
643
|
-
content,
|
|
644
|
-
createdAt,
|
|
645
|
-
updatedAt,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
catch {
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
async saveHandoff(content) {
|
|
653
|
-
await atomicWriteText(this.handoffPath(), content);
|
|
654
|
-
await this.touchManifest();
|
|
655
|
-
}
|
|
656
|
-
async deleteHandoff() {
|
|
657
|
-
try {
|
|
658
|
-
await unlink(this.handoffPath());
|
|
659
|
-
}
|
|
660
|
-
catch { }
|
|
661
|
-
await this.touchManifest();
|
|
662
|
-
}
|
|
663
793
|
async appendActivity(type, ref, summary) {
|
|
664
794
|
const log = await this.loadActivityLog();
|
|
665
795
|
const id = `act-${log.entries.length + 1}-${type}`;
|
|
@@ -684,6 +814,7 @@ export class PlanStore {
|
|
|
684
814
|
currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
|
|
685
815
|
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
686
816
|
nextSteps: existing?.nextSteps ?? [],
|
|
817
|
+
nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
|
|
687
818
|
blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
|
|
688
819
|
notes: notes ?? existing?.notes ?? "",
|
|
689
820
|
lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
|
|
@@ -731,14 +862,15 @@ export class PlanStore {
|
|
|
731
862
|
});
|
|
732
863
|
}
|
|
733
864
|
async loadAll() {
|
|
734
|
-
const [manifest, project,
|
|
865
|
+
const [manifest, project, requirements, phases] = await Promise.all([
|
|
735
866
|
this.loadManifest(),
|
|
736
867
|
this.loadProject(),
|
|
737
|
-
this.loadFeatures(),
|
|
738
868
|
this.loadRequirements(),
|
|
739
869
|
this.loadAllPhases(),
|
|
740
870
|
]);
|
|
741
|
-
|
|
871
|
+
const rawFeatures = await this.loadRawFeatures();
|
|
872
|
+
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
873
|
+
return { manifest, project, requirements, phases, features: { features } };
|
|
742
874
|
}
|
|
743
875
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
744
876
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -916,13 +1048,9 @@ export class PlanStore {
|
|
|
916
1048
|
let shortIdsAssigned = 0;
|
|
917
1049
|
let prioritiesAssigned = 0;
|
|
918
1050
|
let featuresDirty = false;
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
return index + 1;
|
|
923
|
-
}
|
|
924
|
-
return current;
|
|
925
|
-
};
|
|
1051
|
+
// Priority is left to reorder (midpoint-insert); ensureShortIds only
|
|
1052
|
+
// backfills shortIds. New items keep priority 0 until first drag reindex.
|
|
1053
|
+
const assignPriority = (current, _index) => current;
|
|
926
1054
|
// Features: shortId + priority (project scope)
|
|
927
1055
|
const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
928
1056
|
sortedFeatures.forEach((f, index) => {
|
|
@@ -1002,9 +1130,13 @@ export class PlanStore {
|
|
|
1002
1130
|
return this.runAsBatch(async () => {
|
|
1003
1131
|
const migrated = await this.migratePhaseIds();
|
|
1004
1132
|
const backfill = await this.ensureShortIdsAndPriority();
|
|
1133
|
+
// Rebuild phase containment from each task's own phaseId. Heals plans
|
|
1134
|
+
// corrupted by the migrateToGlobalSequence index-mismatch bug (core
|
|
1135
|
+
// <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
|
|
1136
|
+
const containment = await this.rebuildContainment();
|
|
1005
1137
|
const integrity = await this.validateIntegrity();
|
|
1006
1138
|
await this.writeGenerated();
|
|
1007
|
-
return { migrated, backfill, integrity };
|
|
1139
|
+
return { migrated, backfill, containment, integrity };
|
|
1008
1140
|
});
|
|
1009
1141
|
}
|
|
1010
1142
|
/** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
|
|
@@ -1042,123 +1174,77 @@ export class PlanStore {
|
|
|
1042
1174
|
const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
1043
1175
|
return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
|
|
1044
1176
|
}
|
|
1045
|
-
derivePhaseStatus(
|
|
1046
|
-
if (
|
|
1047
|
-
return
|
|
1048
|
-
const taskStatuses =
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
const anyWaiting = taskStatuses.some((status) => status === "waiting");
|
|
1053
|
-
const anyDeferred = taskStatuses.some((status) => status === "deferred");
|
|
1054
|
-
const anyPlanned = taskStatuses.some((status) => status === "planned");
|
|
1055
|
-
const anyDone = taskStatuses.some((status) => status === "done");
|
|
1056
|
-
if (allRejectedOrCanceled)
|
|
1177
|
+
derivePhaseStatus(tasks) {
|
|
1178
|
+
if (tasks.length === 0)
|
|
1179
|
+
return "draft";
|
|
1180
|
+
const taskStatuses = tasks.map((task) => task.status);
|
|
1181
|
+
// Ignore rejected/canceled tasks (void) when deriving progress.
|
|
1182
|
+
const meaningful = taskStatuses.filter((s) => s !== "rejected" && s !== "canceled");
|
|
1183
|
+
if (meaningful.length === 0)
|
|
1057
1184
|
return "rejected";
|
|
1058
|
-
if (
|
|
1059
|
-
return "
|
|
1060
|
-
|
|
1185
|
+
if (meaningful.every((s) => s === "done"))
|
|
1186
|
+
return "done";
|
|
1187
|
+
// Lifecycle truth: any progress (active work OR partial completion) ⇒
|
|
1188
|
+
// in-progress, until fully done. This is what prevents a single
|
|
1189
|
+
// blocked/waiting/deferred task from poisoning the parent when there is
|
|
1190
|
+
// substantial done or in-progress work (the long-standing rollup bug).
|
|
1191
|
+
if (meaningful.some((s) => s === "in-progress") || meaningful.some((s) => s === "done"))
|
|
1061
1192
|
return "in-progress";
|
|
1062
|
-
|
|
1193
|
+
// No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
|
|
1194
|
+
if (meaningful.some((s) => s === "blocked"))
|
|
1195
|
+
return "blocked";
|
|
1196
|
+
if (meaningful.some((s) => s === "waiting"))
|
|
1063
1197
|
return "waiting";
|
|
1064
|
-
if (
|
|
1198
|
+
if (meaningful.some((s) => s === "deferred"))
|
|
1065
1199
|
return "deferred";
|
|
1066
|
-
if (anyPlanned)
|
|
1067
|
-
return "planned";
|
|
1068
|
-
if (anyDone)
|
|
1069
|
-
return "done";
|
|
1070
1200
|
return "planned";
|
|
1071
1201
|
}
|
|
1072
|
-
deriveFeatureStatus(featureId,
|
|
1202
|
+
deriveFeatureStatus(featureId, phases) {
|
|
1073
1203
|
const featurePhases = phases.filter((phase) => phase.featureId === featureId);
|
|
1074
1204
|
if (featurePhases.length === 0)
|
|
1075
|
-
return
|
|
1205
|
+
return "planned";
|
|
1076
1206
|
const phaseStatuses = featurePhases.map((phase) => phase.status);
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
const anyWaiting = phaseStatuses.some((status) => status === "waiting");
|
|
1081
|
-
const anyDeferred = phaseStatuses.some((status) => status === "deferred");
|
|
1082
|
-
const anyPlannedLike = phaseStatuses.some((status) => status === "draft" || status === "planned");
|
|
1083
|
-
const anyDone = phaseStatuses.every((status) => status === "done");
|
|
1084
|
-
if (allRejectedOrCanceled)
|
|
1207
|
+
// Ignore rejected/canceled phases when deriving progress.
|
|
1208
|
+
const meaningful = phaseStatuses.filter((s) => s !== "rejected" && s !== "canceled");
|
|
1209
|
+
if (meaningful.length === 0)
|
|
1085
1210
|
return "rejected";
|
|
1086
|
-
if (
|
|
1087
|
-
return "
|
|
1088
|
-
|
|
1211
|
+
if (meaningful.every((s) => s === "done"))
|
|
1212
|
+
return "done";
|
|
1213
|
+
// Any progress (an active phase, or a partially-complete done phase) ⇒
|
|
1214
|
+
// in-progress. Prevents a single stalled phase from poisoning the feature
|
|
1215
|
+
// when other phases have done/in-progress work.
|
|
1216
|
+
if (meaningful.some((s) => s === "discovery" || s === "in-progress") || meaningful.some((s) => s === "done"))
|
|
1089
1217
|
return "in-progress";
|
|
1090
|
-
|
|
1218
|
+
// No progress at all ⇒ surface the stall / not-started state.
|
|
1219
|
+
if (meaningful.some((s) => s === "blocked"))
|
|
1220
|
+
return "blocked";
|
|
1221
|
+
if (meaningful.some((s) => s === "waiting"))
|
|
1091
1222
|
return "waiting";
|
|
1092
|
-
if (
|
|
1223
|
+
if (meaningful.some((s) => s === "deferred"))
|
|
1093
1224
|
return "deferred";
|
|
1094
|
-
if (anyPlannedLike)
|
|
1095
|
-
return "planned";
|
|
1096
|
-
if (anyDone)
|
|
1097
|
-
return "done";
|
|
1098
1225
|
return "planned";
|
|
1099
1226
|
}
|
|
1100
1227
|
async syncStatuses() {
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const featNum = new Map(features.features.map((f) => [f.id, f.number]));
|
|
1111
|
-
// 1. Update Phase statuses based on tasks; auto-clear handoff when a
|
|
1112
|
-
// phase transitions to done (completed phases don't keep stale
|
|
1113
|
-
// handoffs). handoffUpdatedAt is kept as an audit trail.
|
|
1114
|
-
for (const phase of phases) {
|
|
1115
|
-
const was = phase.status;
|
|
1116
|
-
phase.status = this.derivePhaseStatus(phase);
|
|
1117
|
-
if (phase.status === "done" && was !== "done" && phase.handoff) {
|
|
1118
|
-
phase.handoff = "";
|
|
1119
|
-
cleared.push(formatPhaseRef(phase.number, featNum.get(phase.featureId ?? "")));
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
// 2. Update Feature statuses based on phases
|
|
1123
|
-
for (const feature of features.features) {
|
|
1124
|
-
feature.status = this.deriveFeatureStatus(feature.id, feature.status, phases);
|
|
1125
|
-
}
|
|
1126
|
-
// 3. Save everything
|
|
1127
|
-
await this.saveFeatures(features);
|
|
1128
|
-
for (const phase of phases) {
|
|
1129
|
-
await this.savePhase(phase);
|
|
1130
|
-
}
|
|
1131
|
-
// 4. Refresh resume focus so a subentrating agent sees current state
|
|
1132
|
-
await this.refreshResume();
|
|
1133
|
-
});
|
|
1134
|
-
return cleared;
|
|
1135
|
-
}
|
|
1136
|
-
/** Optimized rollup: syncs only the affected phase and its parent feature.
|
|
1137
|
-
* Drastically reduces write operations and 'busy' window for task updates.
|
|
1138
|
-
* Returns the composite ref of the phase if its handoff was auto-cleared
|
|
1139
|
-
* (phase transitioned to done), else null. */
|
|
1228
|
+
// No-op: phase/feature status is now DERIVED at read time (never persisted),
|
|
1229
|
+
// so there is nothing to sync. Kept for backward compatibility with callers
|
|
1230
|
+
// (serve.ts, adapters) that invoke it after mutations.
|
|
1231
|
+
return [];
|
|
1232
|
+
}
|
|
1233
|
+
/** Auto-clear a phase's handoff when its DERIVED status is done. Status itself
|
|
1234
|
+
* is no longer persisted (derived on read), so the only remaining side effect
|
|
1235
|
+
* of a task→done transition is clearing a stale handoff on a completed phase.
|
|
1236
|
+
* Returns the composite ref of the phase if its handoff was cleared, else null. */
|
|
1140
1237
|
async syncTaskStatusRollup(phaseId) {
|
|
1141
1238
|
const phase = await this.loadPhase(phaseId);
|
|
1142
|
-
|
|
1143
|
-
phase.status
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
phase.
|
|
1148
|
-
await this.savePhase(phase);
|
|
1149
|
-
let feature;
|
|
1150
|
-
if (phase.featureId) {
|
|
1151
|
-
const featuresDoc = await this.loadFeatures();
|
|
1152
|
-
feature = featuresDoc.features.find((f) => f.id === phase.featureId);
|
|
1153
|
-
if (feature) {
|
|
1154
|
-
// To derive feature status, we still need the statuses of all its phases
|
|
1155
|
-
const allPhases = await this.loadAllPhases();
|
|
1156
|
-
feature.status = this.deriveFeatureStatus(feature.id, feature.status, allPhases);
|
|
1157
|
-
await this.saveFeatures(featuresDoc);
|
|
1158
|
-
}
|
|
1239
|
+
let cleared = null;
|
|
1240
|
+
if (phase.status === "done" && phase.handoff !== "") {
|
|
1241
|
+
await this.clearPhaseHandoff(phaseId, "phase-done");
|
|
1242
|
+
const features = await this.loadFeatures();
|
|
1243
|
+
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1244
|
+
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
1159
1245
|
}
|
|
1160
1246
|
await this.refreshResume();
|
|
1161
|
-
return cleared
|
|
1247
|
+
return cleared;
|
|
1162
1248
|
}
|
|
1163
1249
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
1164
1250
|
async updateProject(updater) {
|
|
@@ -1247,9 +1333,17 @@ export class PlanStore {
|
|
|
1247
1333
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
1248
1334
|
* don't lose tasks (last-write-wins race condition). */
|
|
1249
1335
|
async updatePhase(phaseId, updater) {
|
|
1250
|
-
|
|
1336
|
+
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
1337
|
+
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
1338
|
+
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
1339
|
+
// not persisted); the return value is re-derived for the caller.
|
|
1340
|
+
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
1341
|
+
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
1342
|
+
const next = updater(current);
|
|
1343
|
+
return this.normalizePhaseDocument(next).phase;
|
|
1344
|
+
});
|
|
1251
1345
|
await this.maybeAutoSync();
|
|
1252
|
-
return
|
|
1346
|
+
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
1253
1347
|
}
|
|
1254
1348
|
// ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
|
|
1255
1349
|
/** Get the handoff text for a phase ("" if none). Throws if phase missing. */
|
|
@@ -1262,10 +1356,76 @@ export class PlanStore {
|
|
|
1262
1356
|
const now = new Date().toISOString();
|
|
1263
1357
|
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
|
|
1264
1358
|
}
|
|
1265
|
-
/**
|
|
1266
|
-
*
|
|
1267
|
-
|
|
1268
|
-
|
|
1359
|
+
/** Directory where cleared handoff content is archived as .md files
|
|
1360
|
+
* (gitignored). Keeps the phase JSON lean while making past handoffs
|
|
1361
|
+
* recoverable + human-readable. */
|
|
1362
|
+
handoffArchiveDir() {
|
|
1363
|
+
return join(this.root, "handoff-archive");
|
|
1364
|
+
}
|
|
1365
|
+
/** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
|
|
1366
|
+
* Does NOT clear the handoff — content is kept until a task starts or the
|
|
1367
|
+
* phase completes, so a restart between read and resume does not lose it. */
|
|
1368
|
+
async markHandoffRead(phaseId) {
|
|
1369
|
+
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
|
|
1370
|
+
}
|
|
1371
|
+
/** One-time import of a legacy .planner/HANDOFF.md file (file-based handoff
|
|
1372
|
+
* from before F004) into the entity-scoped phase.handoff. Idempotent: if the
|
|
1373
|
+
* file is absent or empty, no-op. If it exists + non-empty + the target phase
|
|
1374
|
+
* has no handoff, writes the content onto the current in-progress phase (or
|
|
1375
|
+
* the first phase if none in-progress) with an "imported" handoffHistory entry,
|
|
1376
|
+
* then renames the file to HANDOFF.md.bak so it won't re-import. If the target
|
|
1377
|
+
* already has a handoff, the entity-scoped one wins and the file is just .bak'd. */
|
|
1378
|
+
async importLegacyHandoffFile() {
|
|
1379
|
+
const filePath = join(this.root, "HANDOFF.md");
|
|
1380
|
+
const content = await readFile(filePath, "utf-8").catch(() => null);
|
|
1381
|
+
if (content === null)
|
|
1382
|
+
return { imported: false };
|
|
1383
|
+
if (content.trim() === "") {
|
|
1384
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1385
|
+
return { imported: false };
|
|
1386
|
+
}
|
|
1387
|
+
const phases = await this.loadAllPhases();
|
|
1388
|
+
const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
|
|
1389
|
+
if (!target)
|
|
1390
|
+
return { imported: false }; // no phases yet — leave file for a later run
|
|
1391
|
+
if ((target.handoff ?? "") === "") {
|
|
1392
|
+
await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
|
|
1393
|
+
await this.updatePhase(target.id, (p) => ({
|
|
1394
|
+
...p,
|
|
1395
|
+
handoffHistory: [{ file: "(legacy HANDOFF.md)", clearedAt: nowISO(), reason: "imported" }, ...(p.handoffHistory ?? [])].slice(0, 5),
|
|
1396
|
+
}));
|
|
1397
|
+
}
|
|
1398
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1399
|
+
const features = await this.loadFeatures();
|
|
1400
|
+
const feat = features.features.find((f) => f.id === target.featureId);
|
|
1401
|
+
return { imported: true, phaseRef: formatPhaseRef(target.number, feat?.number) };
|
|
1402
|
+
}
|
|
1403
|
+
/** Clear the handoff for a phase, archiving its content first. The handoff
|
|
1404
|
+
* markdown is written to .planner/handoff-archive/<phaseId>-<ISO>.md and a
|
|
1405
|
+
* metadata entry { file, clearedAt, reason } is prepended to handoffHistory
|
|
1406
|
+
* (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
|
|
1407
|
+
* left unchanged as an audit trail. If the handoff is empty, this is a no-op.
|
|
1408
|
+
* reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
|
|
1409
|
+
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
1410
|
+
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
1411
|
+
if (!phase || phase.handoff === "")
|
|
1412
|
+
return; // nothing to archive
|
|
1413
|
+
const clearedAt = nowISO();
|
|
1414
|
+
const safeTs = clearedAt.replace(/[:.]/g, "-");
|
|
1415
|
+
const archiveDir = this.handoffArchiveDir();
|
|
1416
|
+
await mkdir(archiveDir, { recursive: true }).catch(() => { });
|
|
1417
|
+
const fileName = `${phaseId}-${safeTs}.md`;
|
|
1418
|
+
const filePath = join(archiveDir, fileName);
|
|
1419
|
+
await atomicWriteText(filePath, phase.handoff);
|
|
1420
|
+
const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
|
|
1421
|
+
// Cap history at 5: prepend new entry, drop oldest (and delete its file).
|
|
1422
|
+
const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
|
|
1423
|
+
const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
|
|
1424
|
+
for (const d of dropped) {
|
|
1425
|
+
if (d?.file)
|
|
1426
|
+
await unlink(join(this.root, d.file)).catch(() => { });
|
|
1427
|
+
}
|
|
1428
|
+
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
|
|
1269
1429
|
}
|
|
1270
1430
|
/** List all phases that have a non-empty handoff, newest first, with a
|
|
1271
1431
|
* human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
|
|
@@ -1282,9 +1442,11 @@ export class PlanStore {
|
|
|
1282
1442
|
const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
|
|
1283
1443
|
out.push({
|
|
1284
1444
|
phaseId: p.id,
|
|
1445
|
+
featureId: p.featureId,
|
|
1285
1446
|
compositeRef: formatPhaseRef(p.number, fnum),
|
|
1286
1447
|
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
1287
1448
|
firstLine: handoffFirstLine(p.handoff),
|
|
1449
|
+
content: p.handoff,
|
|
1288
1450
|
});
|
|
1289
1451
|
}
|
|
1290
1452
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|