@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.
@@ -1,6 +1,6 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
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, PlanWorkspaceSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
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
- if (!this.autoSync || this.syncGuard || this.batchInProgress)
235
- return;
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
- let changed = false;
246
- const normalized = tasks.map((task, index) => {
247
- const nextNumber = index + 1;
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
- let changed = false;
256
- const normalized = doc.features.map((feature, index) => {
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, featureIndex) => {
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, index) => {
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, index) => {
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
- return this.runAsBatch(async () => {
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 backup/tmp files are
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
- async loadPhase(phaseId) {
513
- const phase = await readJson(this.phasePath(phaseId), PhaseSchema);
514
- return this.normalizePhaseDocument(phase).phase;
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 loadFeatures() {
517
- // Per-file layout: read features/<id>.json (one file per feature).
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 features = [];
670
+ const out = [];
528
671
  for (const f of jsonFiles) {
529
672
  const id = f.replace(/\.json$/, "");
530
673
  try {
531
- features.push(await readJson(this.featurePath(id), FeatureSchema));
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
- return this.normalizeFeaturesDocument({ features }).doc;
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
- return this.normalizeFeaturesDocument(legacy).doc;
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 { features: [] };
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
- const parsed = ResumeFocusSchema.parse(resume);
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, features, requirements, phases] = await Promise.all([
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
- return PlanWorkspaceSchema.parse({ manifest, project, features, requirements, phases });
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
- const assignPriority = (current, index) => {
920
- if (current === 0) {
921
- prioritiesAssigned += 1;
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(phase) {
1046
- if (phase.tasks.length === 0)
1047
- return phase.status;
1048
- const taskStatuses = phase.tasks.map((task) => task.status);
1049
- const allRejectedOrCanceled = taskStatuses.every((status) => status === "rejected" || status === "canceled");
1050
- const anyBlocked = taskStatuses.some((status) => status === "blocked");
1051
- const anyInProgress = taskStatuses.some((status) => status === "in-progress");
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 (anyBlocked)
1059
- return "blocked";
1060
- if (anyInProgress)
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
- if (anyWaiting)
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 (anyDeferred)
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, currentStatus, phases) {
1202
+ deriveFeatureStatus(featureId, phases) {
1073
1203
  const featurePhases = phases.filter((phase) => phase.featureId === featureId);
1074
1204
  if (featurePhases.length === 0)
1075
- return currentStatus;
1205
+ return "planned";
1076
1206
  const phaseStatuses = featurePhases.map((phase) => phase.status);
1077
- const allRejectedOrCanceled = phaseStatuses.every((status) => status === "rejected" || status === "canceled");
1078
- const anyBlocked = phaseStatuses.some((status) => status === "blocked");
1079
- const anyActive = phaseStatuses.some((status) => status === "discovery" || status === "in-progress");
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 (anyBlocked)
1087
- return "blocked";
1088
- if (anyActive)
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
- if (anyWaiting)
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 (anyDeferred)
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
- // Run as a batch so the internal saveFeatures + N savePhase calls do not
1102
- // re-trigger syncStatuses on every write (O(N^2) on large planners).
1103
- // Returns the composite refs of phases whose handoff was auto-cleared
1104
- // (phase transitioned to done).
1105
- const cleared = [];
1106
- await this.runAsBatch(async () => {
1107
- await this.migratePhaseIds();
1108
- const workspace = await this.loadAll();
1109
- const { phases, features } = workspace;
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
- const was = phase.status;
1143
- phase.status = this.derivePhaseStatus(phase);
1144
- // Auto-clear handoff when the phase transitions to done.
1145
- const cleared = phase.status === "done" && was !== "done" && phase.handoff !== "";
1146
- if (cleared)
1147
- phase.handoff = "";
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 ? formatPhaseRef(phase.number, feature?.number) : null;
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
- const updated = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (phase) => this.normalizePhaseDocument(updater(phase)).phase);
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 updated;
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
- /** Clear the handoff text for a phase (handoff=""). handoffUpdatedAt is left
1266
- * unchanged as an audit trail (when a handoff last existed). */
1267
- async clearPhaseHandoff(phaseId) {
1268
- await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
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));