@agent-plan/core 0.2.18 → 0.2.19-next.1

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,7 +1,7 @@
1
- import { access, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
1
+ import { access, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
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";
3
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
4
+ import { createFeatureId, createPhaseId, createRequirementId, createShortId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
5
5
  function nowISO() {
6
6
  return new Date().toISOString();
7
7
  }
@@ -108,6 +108,12 @@ async function atomicUpdateJson(path, schema, updater) {
108
108
  return parsed;
109
109
  });
110
110
  }
111
+ /** Extract the first meaningful line of a handoff: skip blank lines, strip a
112
+ * leading markdown header (#), trim, and truncate to ~80 chars. */
113
+ function handoffFirstLine(text) {
114
+ const line = text.trim().split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
115
+ return line.replace(/^#+\s*/, "").trim().slice(0, 80);
116
+ }
111
117
  export async function migrateToUuids(store) {
112
118
  // Run as a batch so internal saveFeatures/savePhase calls do not
113
119
  // re-trigger syncStatuses (O(N^2) on large planners). Idempotent: if there
@@ -187,7 +193,6 @@ async function readJson(path, schema) {
187
193
  throw new PlanStoreError(`read failed: ${path}`, cause);
188
194
  }
189
195
  }
190
- // ─── PlanStore ──────────────────────────────────────────────────────────
191
196
  export class PlanStore {
192
197
  root;
193
198
  autoSync = false;
@@ -224,16 +229,15 @@ export class PlanStore {
224
229
  async runBatchForMigration(fn) {
225
230
  return this.runAsBatch(fn);
226
231
  }
232
+ /** Public batch wrapper: suspend autoSync (status rollup) for a sequence of
233
+ * writes. Use for priority-only reorders so they don't recompute phase/feature
234
+ * status (a reorder must not flip a partially-done feature to in-progress). */
235
+ async runBatch(fn) {
236
+ return this.runAsBatch(fn);
237
+ }
227
238
  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
- }
239
+ // No-op: status is derived on read, so there is nothing to sync after a
240
+ // save. Kept so existing save* call sites compile unchanged.
237
241
  }
238
242
  normalizeTasks(tasks) {
239
243
  let changed = false;
@@ -335,7 +339,7 @@ export class PlanStore {
335
339
  }
336
340
  async ensureStructureOrdering() {
337
341
  return this.runAsBatch(async () => {
338
- const featuresDoc = await readJson(this.featuresPath(), FeaturesDocumentSchema).catch(() => ({ features: [] }));
342
+ const featuresDoc = await this.loadFeatures();
339
343
  const phases = await this.loadAllPhases();
340
344
  const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
341
345
  if (!normalized.changed)
@@ -360,6 +364,35 @@ export class PlanStore {
360
364
  featuresPath() {
361
365
  return join(this.root, "features.json");
362
366
  }
367
+ featuresDir() {
368
+ return join(this.root, "features");
369
+ }
370
+ featurePath(featureId) {
371
+ return join(this.featuresDir(), `${featureId}.json`);
372
+ }
373
+ withFeaturesLock(fn) {
374
+ // Sentinel-keyed mutex (the features dir path) serializing all feature
375
+ // mutations so read-modify-write via updateFeatures is race-free and the
376
+ // one-time legacy→per-file migration never interleaves with a writer.
377
+ return withWriteLock(this.featuresDir(), fn);
378
+ }
379
+ /** Idempotent one-time migration: if a legacy features.json exists, split it
380
+ * into features/<id>.json (one per feature) and remove the legacy file.
381
+ * Must be called under withFeaturesLock. Crash-safe: re-run overwrites. */
382
+ async migrateLegacy() {
383
+ let legacy;
384
+ try {
385
+ legacy = await readJson(this.featuresPath(), FeaturesDocumentSchema);
386
+ }
387
+ catch {
388
+ return; // no legacy features.json (already migrated or fresh project)
389
+ }
390
+ await mkdir(this.featuresDir(), { recursive: true });
391
+ for (const feat of legacy.features) {
392
+ await atomicWriteJson(this.featurePath(feat.id), feat);
393
+ }
394
+ await unlink(this.featuresPath()).catch(() => { });
395
+ }
363
396
  phasesDir() {
364
397
  return join(this.root, "phases");
365
398
  }
@@ -388,6 +421,7 @@ export class PlanStore {
388
421
  }
389
422
  await mkdir(this.root, { recursive: true });
390
423
  await mkdir(this.phasesDir(), { recursive: true });
424
+ await mkdir(this.featuresDir(), { recursive: true });
391
425
  await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
392
426
  await mkdir(join(this.root, "schema"), { recursive: true });
393
427
  await mkdir(join(this.root, "adapters"), { recursive: true });
@@ -474,18 +508,58 @@ export class PlanStore {
474
508
  return readJson(this.projectPath(), ProjectSchema);
475
509
  }
476
510
  async loadPhase(phaseId) {
477
- const phase = await readJson(this.phasePath(phaseId), PhaseSchema);
478
- return this.normalizePhaseDocument(phase).phase;
479
- }
480
- async loadFeatures() {
511
+ const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
512
+ const normalized = this.normalizePhaseDocument(raw).phase;
513
+ return { ...normalized, status: this.derivePhaseStatus(normalized.tasks) };
514
+ }
515
+ /** Read raw feature files WITHOUT the derived `status` field. Used
516
+ * internally so loadFeatures/loadAll can derive status from phases without
517
+ * double-loading. */
518
+ async loadRawFeatures() {
519
+ let jsonFiles = [];
481
520
  try {
482
- const features = await readJson(this.featuresPath(), FeaturesDocumentSchema);
483
- return this.normalizeFeaturesDocument(features).doc;
521
+ const all = await readdir(this.featuresDir());
522
+ jsonFiles = all.filter((f) => f.endsWith(".json"));
484
523
  }
485
524
  catch {
486
- return { features: [] };
525
+ // features/ absent → fall through to legacy single-file layout
526
+ }
527
+ if (jsonFiles.length > 0) {
528
+ const out = [];
529
+ for (const f of jsonFiles) {
530
+ const id = f.replace(/\.json$/, "");
531
+ try {
532
+ out.push(await readJson(this.featurePath(id), FeatureSchema));
533
+ }
534
+ catch (err) {
535
+ // Skip an invalid feature file rather than failing the whole load.
536
+ console.warn(`[plan-store] skipping invalid feature file ${f}:`, err);
537
+ }
538
+ }
539
+ // Deterministic order: sort by the persisted `number` (creation order)
540
+ // so callers that renumber by index (normalizeFeaturesDocument) and
541
+ // callers that keep persisted numbers (loadAll) agree, regardless of the
542
+ // filesystem readdir order. Tiebreak by id for full determinism.
543
+ out.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
544
+ return out;
545
+ }
546
+ // Legacy: single features.json (pre-migration read; migration writes on first write op).
547
+ try {
548
+ const legacy = await readJson(this.featuresPath(), FeaturesDocumentSchema);
549
+ const legacyFeatures = legacy.features;
550
+ legacyFeatures.sort((a, b) => (a.number - b.number) || a.id.localeCompare(b.id));
551
+ return legacyFeatures;
552
+ }
553
+ catch {
554
+ return [];
487
555
  }
488
556
  }
557
+ async loadFeatures() {
558
+ const raws = await this.loadRawFeatures();
559
+ const phases = await this.loadAllPhases();
560
+ const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
561
+ return this.normalizeFeaturesDocument({ features }).doc;
562
+ }
489
563
  async loadCodebaseProfile() {
490
564
  try {
491
565
  return await readJson(this.codebasePath(), CodebaseProfileSchema);
@@ -671,14 +745,15 @@ export class PlanStore {
671
745
  });
672
746
  }
673
747
  async loadAll() {
674
- const [manifest, project, features, requirements, phases] = await Promise.all([
748
+ const [manifest, project, requirements, phases] = await Promise.all([
675
749
  this.loadManifest(),
676
750
  this.loadProject(),
677
- this.loadFeatures(),
678
751
  this.loadRequirements(),
679
752
  this.loadAllPhases(),
680
753
  ]);
681
- return PlanWorkspaceSchema.parse({ manifest, project, features, requirements, phases });
754
+ const rawFeatures = await this.loadRawFeatures();
755
+ const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
756
+ return { manifest, project, requirements, phases, features: { features } };
682
757
  }
683
758
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
684
759
  * dangling feature.phaseIds references. Idempotent. */
@@ -795,13 +870,156 @@ export class PlanStore {
795
870
  catch { /* best-effort */ }
796
871
  return { removed };
797
872
  }
873
+ /** All non-empty shortIds currently assigned in the project (features + phases + tasks).
874
+ * Read-only; used by createShortId collision guard during entity creation. */
875
+ async assignedShortIds() {
876
+ const features = await this.loadFeatures();
877
+ const phases = await this.loadAllPhases();
878
+ const ids = new Set();
879
+ for (const f of features.features)
880
+ if (f.shortId)
881
+ ids.add(f.shortId);
882
+ for (const p of phases) {
883
+ if (p.shortId)
884
+ ids.add(p.shortId);
885
+ for (const t of p.tasks)
886
+ if (t.shortId)
887
+ ids.add(t.shortId);
888
+ }
889
+ return ids;
890
+ }
891
+ /** Next priority (>=1) for a new entity within its scope.
892
+ * - feature: max priority among features + 1
893
+ * - phase: max priority among phases of parentId (featureId) + 1
894
+ * - task: max priority among tasks of parentId (phaseId) + 1 */
895
+ async nextPriority(kind, parentId) {
896
+ if (kind === "feature") {
897
+ const features = await this.loadFeatures();
898
+ const max = features.features.reduce((m, f) => Math.max(m, f.priority ?? 0), 0);
899
+ return max + 1;
900
+ }
901
+ if (kind === "phase") {
902
+ const phases = await this.loadAllPhases();
903
+ const siblings = phases.filter((p) => p.featureId === parentId);
904
+ const max = siblings.reduce((m, p) => Math.max(m, p.priority ?? 0), 0);
905
+ return max + 1;
906
+ }
907
+ // task
908
+ const phase = parentId ? await this.loadPhase(parentId).catch(() => undefined) : undefined;
909
+ const tasks = phase?.tasks ?? [];
910
+ const max = tasks.reduce((m, t) => Math.max(m, t.priority ?? 0), 0);
911
+ return max + 1;
912
+ }
913
+ /** Idempotent backfill of shortId (globally-unique 5-char Crockford) and priority
914
+ * (per-scope display order). Assigns missing shortIds and priorities; never overwrites
915
+ * existing non-empty shortIds or non-zero priorities. Safe to run at startup. */
916
+ async ensureShortIdsAndPriority() {
917
+ return this.runAsBatch(async () => {
918
+ const featuresDoc = await this.loadFeatures();
919
+ const phases = await this.loadAllPhases();
920
+ const existing = new Set();
921
+ for (const f of featuresDoc.features)
922
+ if (f.shortId)
923
+ existing.add(f.shortId);
924
+ for (const p of phases) {
925
+ if (p.shortId)
926
+ existing.add(p.shortId);
927
+ for (const t of p.tasks)
928
+ if (t.shortId)
929
+ existing.add(t.shortId);
930
+ }
931
+ let shortIdsAssigned = 0;
932
+ let prioritiesAssigned = 0;
933
+ let featuresDirty = false;
934
+ const assignPriority = (current, index) => {
935
+ if (current === 0) {
936
+ prioritiesAssigned += 1;
937
+ return index + 1;
938
+ }
939
+ return current;
940
+ };
941
+ // Features: shortId + priority (project scope)
942
+ const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
943
+ sortedFeatures.forEach((f, index) => {
944
+ if (!f.shortId) {
945
+ f.shortId = createShortId(existing);
946
+ existing.add(f.shortId);
947
+ shortIdsAssigned += 1;
948
+ featuresDirty = true;
949
+ }
950
+ const nextP = assignPriority(f.priority ?? 0, index);
951
+ if (nextP !== f.priority) {
952
+ f.priority = nextP;
953
+ featuresDirty = true;
954
+ }
955
+ });
956
+ if (featuresDirty) {
957
+ featuresDoc.features.sort((a, b) => a.priority - b.priority || a.number - b.number);
958
+ await this.saveFeatures(featuresDoc);
959
+ }
960
+ // Phases + tasks
961
+ for (const phase of phases) {
962
+ let phaseDirty = false;
963
+ if (!phase.shortId) {
964
+ phase.shortId = createShortId(existing);
965
+ existing.add(phase.shortId);
966
+ shortIdsAssigned += 1;
967
+ phaseDirty = true;
968
+ }
969
+ const phaseIndex = phase.number - 1; // stable pre-migration order
970
+ const nextPP = assignPriority(phase.priority ?? 0, phaseIndex < 0 ? 0 : phaseIndex);
971
+ if (nextPP !== phase.priority) {
972
+ phase.priority = nextPP;
973
+ phaseDirty = true;
974
+ }
975
+ const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
976
+ sortedTasks.forEach((t, index) => {
977
+ if (!t.shortId) {
978
+ t.shortId = createShortId(existing);
979
+ existing.add(t.shortId);
980
+ shortIdsAssigned += 1;
981
+ phaseDirty = true;
982
+ }
983
+ const nextTP = assignPriority(t.priority ?? 0, index);
984
+ if (nextTP !== t.priority) {
985
+ t.priority = nextTP;
986
+ phaseDirty = true;
987
+ }
988
+ });
989
+ if (phaseDirty) {
990
+ phase.tasks.sort((a, b) => a.priority - b.priority || a.number - b.number);
991
+ phase.taskIds = phase.tasks.map((t) => t.id);
992
+ phase.updatedAt = nowISO();
993
+ await this.savePhase(phase);
994
+ }
995
+ }
996
+ // Duplicate shortId report (across all entities)
997
+ const allShortIds = [];
998
+ for (const f of featuresDoc.features)
999
+ if (f.shortId)
1000
+ allShortIds.push(f.shortId);
1001
+ for (const ph of phases) {
1002
+ if (ph.shortId)
1003
+ allShortIds.push(ph.shortId);
1004
+ for (const t of ph.tasks)
1005
+ if (t.shortId)
1006
+ allShortIds.push(t.shortId);
1007
+ }
1008
+ const counts = new Map();
1009
+ for (const id of allShortIds)
1010
+ counts.set(id, (counts.get(id) ?? 0) + 1);
1011
+ const duplicateShortIds = [...counts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1012
+ return { shortIdsAssigned, prioritiesAssigned, duplicateShortIds };
1013
+ });
1014
+ }
798
1015
  /** Repair dangling references and report integrity. One-shot maintenance op. */
799
1016
  async repair() {
800
1017
  return this.runAsBatch(async () => {
801
1018
  const migrated = await this.migratePhaseIds();
1019
+ const backfill = await this.ensureShortIdsAndPriority();
802
1020
  const integrity = await this.validateIntegrity();
803
1021
  await this.writeGenerated();
804
- return { migrated, integrity };
1022
+ return { migrated, backfill, integrity };
805
1023
  });
806
1024
  }
807
1025
  /** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
@@ -821,104 +1039,95 @@ export class PlanStore {
821
1039
  danglingPhaseIds.push(`${feature.id} -> ${ref}`);
822
1040
  }
823
1041
  }
824
- return { duplicatePhaseIds, danglingPhaseIds };
825
- }
826
- derivePhaseStatus(phase) {
827
- if (phase.tasks.length === 0)
828
- return phase.status;
829
- const taskStatuses = phase.tasks.map((task) => task.status);
830
- const allRejectedOrCanceled = taskStatuses.every((status) => status === "rejected" || status === "canceled");
831
- const anyBlocked = taskStatuses.some((status) => status === "blocked");
832
- const anyInProgress = taskStatuses.some((status) => status === "in-progress");
833
- const anyWaiting = taskStatuses.some((status) => status === "waiting");
834
- const anyDeferred = taskStatuses.some((status) => status === "deferred");
835
- const anyPlanned = taskStatuses.some((status) => status === "planned");
836
- const anyDone = taskStatuses.some((status) => status === "done");
837
- if (allRejectedOrCanceled)
1042
+ // Duplicate shortIds across all entities
1043
+ const allShortIds = [];
1044
+ for (const f of features.features)
1045
+ if (f.shortId)
1046
+ allShortIds.push(f.shortId);
1047
+ for (const ph of phases) {
1048
+ if (ph.shortId)
1049
+ allShortIds.push(ph.shortId);
1050
+ for (const t of ph.tasks)
1051
+ if (t.shortId)
1052
+ allShortIds.push(t.shortId);
1053
+ }
1054
+ const sidCounts = new Map();
1055
+ for (const id of allShortIds)
1056
+ sidCounts.set(id, (sidCounts.get(id) ?? 0) + 1);
1057
+ const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1058
+ return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
1059
+ }
1060
+ derivePhaseStatus(tasks) {
1061
+ if (tasks.length === 0)
1062
+ return "draft";
1063
+ const taskStatuses = tasks.map((task) => task.status);
1064
+ // Ignore rejected/canceled tasks (void) when deriving progress.
1065
+ const meaningful = taskStatuses.filter((s) => s !== "rejected" && s !== "canceled");
1066
+ if (meaningful.length === 0)
838
1067
  return "rejected";
839
- if (anyBlocked)
840
- return "blocked";
841
- if (anyInProgress)
1068
+ if (meaningful.every((s) => s === "done"))
1069
+ return "done";
1070
+ // Lifecycle truth: any progress (active work OR partial completion)
1071
+ // in-progress, until fully done. This is what prevents a single
1072
+ // blocked/waiting/deferred task from poisoning the parent when there is
1073
+ // substantial done or in-progress work (the long-standing rollup bug).
1074
+ if (meaningful.some((s) => s === "in-progress") || meaningful.some((s) => s === "done"))
842
1075
  return "in-progress";
843
- if (anyWaiting)
1076
+ // No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
1077
+ if (meaningful.some((s) => s === "blocked"))
1078
+ return "blocked";
1079
+ if (meaningful.some((s) => s === "waiting"))
844
1080
  return "waiting";
845
- if (anyDeferred)
1081
+ if (meaningful.some((s) => s === "deferred"))
846
1082
  return "deferred";
847
- if (anyPlanned)
848
- return "planned";
849
- if (anyDone)
850
- return "done";
851
1083
  return "planned";
852
1084
  }
853
- deriveFeatureStatus(featureId, currentStatus, phases) {
1085
+ deriveFeatureStatus(featureId, phases) {
854
1086
  const featurePhases = phases.filter((phase) => phase.featureId === featureId);
855
1087
  if (featurePhases.length === 0)
856
- return currentStatus;
1088
+ return "planned";
857
1089
  const phaseStatuses = featurePhases.map((phase) => phase.status);
858
- const allRejectedOrCanceled = phaseStatuses.every((status) => status === "rejected" || status === "canceled");
859
- const anyBlocked = phaseStatuses.some((status) => status === "blocked");
860
- const anyActive = phaseStatuses.some((status) => status === "discovery" || status === "in-progress");
861
- const anyWaiting = phaseStatuses.some((status) => status === "waiting");
862
- const anyDeferred = phaseStatuses.some((status) => status === "deferred");
863
- const anyPlannedLike = phaseStatuses.some((status) => status === "draft" || status === "planned");
864
- const anyDone = phaseStatuses.every((status) => status === "done");
865
- if (allRejectedOrCanceled)
1090
+ // Ignore rejected/canceled phases when deriving progress.
1091
+ const meaningful = phaseStatuses.filter((s) => s !== "rejected" && s !== "canceled");
1092
+ if (meaningful.length === 0)
866
1093
  return "rejected";
867
- if (anyBlocked)
868
- return "blocked";
869
- if (anyActive)
1094
+ if (meaningful.every((s) => s === "done"))
1095
+ return "done";
1096
+ // Any progress (an active phase, or a partially-complete done phase)
1097
+ // in-progress. Prevents a single stalled phase from poisoning the feature
1098
+ // when other phases have done/in-progress work.
1099
+ if (meaningful.some((s) => s === "discovery" || s === "in-progress") || meaningful.some((s) => s === "done"))
870
1100
  return "in-progress";
871
- if (anyWaiting)
1101
+ // No progress at all ⇒ surface the stall / not-started state.
1102
+ if (meaningful.some((s) => s === "blocked"))
1103
+ return "blocked";
1104
+ if (meaningful.some((s) => s === "waiting"))
872
1105
  return "waiting";
873
- if (anyDeferred)
1106
+ if (meaningful.some((s) => s === "deferred"))
874
1107
  return "deferred";
875
- if (anyPlannedLike)
876
- return "planned";
877
- if (anyDone)
878
- return "done";
879
1108
  return "planned";
880
1109
  }
881
1110
  async syncStatuses() {
882
- // Run as a batch so the internal saveFeatures + N savePhase calls do not
883
- // re-trigger syncStatuses on every write (O(N^2) on large planners).
884
- await this.runAsBatch(async () => {
885
- await this.migratePhaseIds();
886
- const workspace = await this.loadAll();
887
- const { phases, features } = workspace;
888
- // 1. Update Phase statuses based on tasks
889
- for (const phase of phases) {
890
- phase.status = this.derivePhaseStatus(phase);
891
- }
892
- // 2. Update Feature statuses based on phases
893
- for (const feature of features.features) {
894
- feature.status = this.deriveFeatureStatus(feature.id, feature.status, phases);
895
- }
896
- // 3. Save everything
897
- await this.saveFeatures(features);
898
- for (const phase of phases) {
899
- await this.savePhase(phase);
900
- }
901
- // 4. Refresh resume focus so a subentrating agent sees current state
902
- await this.refreshResume();
903
- });
904
- }
905
- /** Optimized rollup: syncs only the affected phase and its parent feature.
906
- * Drastically reduces write operations and 'busy' window for task updates. */
1111
+ // No-op: phase/feature status is now DERIVED at read time (never persisted),
1112
+ // so there is nothing to sync. Kept for backward compatibility with callers
1113
+ // (serve.ts, adapters) that invoke it after mutations.
1114
+ return [];
1115
+ }
1116
+ /** Auto-clear a phase's handoff when its DERIVED status is done. Status itself
1117
+ * is no longer persisted (derived on read), so the only remaining side effect
1118
+ * of a task→done transition is clearing a stale handoff on a completed phase.
1119
+ * Returns the composite ref of the phase if its handoff was cleared, else null. */
907
1120
  async syncTaskStatusRollup(phaseId) {
908
1121
  const phase = await this.loadPhase(phaseId);
909
- phase.status = this.derivePhaseStatus(phase);
910
- await this.savePhase(phase);
911
- if (phase.featureId) {
912
- const featuresDoc = await this.loadFeatures();
913
- const feature = featuresDoc.features.find((f) => f.id === phase.featureId);
914
- if (feature) {
915
- // To derive feature status, we still need the statuses of all its phases
916
- const allPhases = await this.loadAllPhases();
917
- feature.status = this.deriveFeatureStatus(feature.id, feature.status, allPhases);
918
- await this.saveFeatures(featuresDoc);
919
- }
1122
+ let cleared = null;
1123
+ if (phase.status === "done" && phase.handoff !== "") {
1124
+ await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffUpdatedAt: nowISO() }));
1125
+ const features = await this.loadFeatures();
1126
+ const feature = features.features.find((f) => f.id === phase.featureId);
1127
+ cleared = formatPhaseRef(phase.number, feature?.number);
920
1128
  }
921
1129
  await this.refreshResume();
1130
+ return cleared;
922
1131
  }
923
1132
  // ── Savers ───────────────────────────────────────────────────────────
924
1133
  async updateProject(updater) {
@@ -927,7 +1136,13 @@ export class PlanStore {
927
1136
  return updated;
928
1137
  }
929
1138
  async updateFeatures(updater) {
930
- const updated = await atomicUpdateJson(this.featuresPath(), FeaturesDocumentSchema, (current) => this.normalizeFeaturesDocument(updater(current)).doc);
1139
+ const updated = await this.withFeaturesLock(async () => {
1140
+ await this.migrateLegacy();
1141
+ const current = await this.loadFeatures();
1142
+ const upd = this.normalizeFeaturesDocument(updater(current)).doc;
1143
+ await this.saveFeaturesRaw(upd);
1144
+ return upd;
1145
+ });
931
1146
  await this.maybeAutoSync();
932
1147
  return updated;
933
1148
  }
@@ -943,8 +1158,45 @@ export class PlanStore {
943
1158
  await this.maybeAutoSync();
944
1159
  }
945
1160
  async saveFeatures(features) {
1161
+ await this.withFeaturesLock(async () => {
1162
+ await this.migrateLegacy();
1163
+ await this.saveFeaturesRaw(features);
1164
+ });
1165
+ await this.touchManifest();
1166
+ await this.maybeAutoSync();
1167
+ }
1168
+ /** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
1169
+ async saveFeaturesRaw(features) {
946
1170
  const parsed = FeaturesDocumentSchema.parse(this.normalizeFeaturesDocument(features).doc);
947
- await atomicWriteJson(this.featuresPath(), parsed);
1171
+ await mkdir(this.featuresDir(), { recursive: true });
1172
+ const wantIds = new Set(parsed.features.map((f) => f.id));
1173
+ for (const feat of parsed.features) {
1174
+ await atomicWriteJson(this.featurePath(feat.id), feat);
1175
+ }
1176
+ // Orphan reconcile: remove feature files no longer in the document.
1177
+ try {
1178
+ const files = await readdir(this.featuresDir());
1179
+ for (const f of files) {
1180
+ if (!f.endsWith(".json"))
1181
+ continue;
1182
+ const id = f.replace(/\.json$/, "");
1183
+ if (!wantIds.has(id)) {
1184
+ await unlink(this.featurePath(id)).catch(() => { });
1185
+ }
1186
+ }
1187
+ }
1188
+ catch {
1189
+ // dir absent — nothing to reconcile
1190
+ }
1191
+ }
1192
+ /** Granular single-feature write (per-file lock; parallel-safe across features). */
1193
+ async saveFeature(feature) {
1194
+ await this.withFeaturesLock(async () => {
1195
+ await this.migrateLegacy();
1196
+ await mkdir(this.featuresDir(), { recursive: true });
1197
+ const parsed = FeatureSchema.parse(feature);
1198
+ await atomicWriteJson(this.featurePath(parsed.id), parsed);
1199
+ });
948
1200
  await this.touchManifest();
949
1201
  await this.maybeAutoSync();
950
1202
  }
@@ -964,9 +1216,56 @@ export class PlanStore {
964
1216
  * task_create / phase_update calls on the SAME phaseId so batch operations
965
1217
  * don't lose tasks (last-write-wins race condition). */
966
1218
  async updatePhase(phaseId, updater) {
967
- const updated = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (phase) => this.normalizePhaseDocument(updater(phase)).phase);
1219
+ // Augment the raw (on-disk) phase with its DERIVED status before handing it
1220
+ // to the updater, so updaters that read 'phase.status' see the truth. The
1221
+ // returned object's 'status' is stripped by PhaseSchema.parse (status is
1222
+ // not persisted); the return value is re-derived for the caller.
1223
+ const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
1224
+ const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
1225
+ const next = updater(current);
1226
+ return this.normalizePhaseDocument(next).phase;
1227
+ });
968
1228
  await this.maybeAutoSync();
969
- return updated;
1229
+ return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
1230
+ }
1231
+ // ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
1232
+ /** Get the handoff text for a phase ("" if none). Throws if phase missing. */
1233
+ async getPhaseHandoff(phaseId) {
1234
+ return (await this.loadPhase(phaseId)).handoff;
1235
+ }
1236
+ /** Set the handoff text for a phase + stamp handoffUpdatedAt. Atomic per-file
1237
+ * update via updatePhase (so the web UI refreshes via maybeAutoSync). */
1238
+ async setPhaseHandoff(phaseId, text) {
1239
+ const now = new Date().toISOString();
1240
+ await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
1241
+ }
1242
+ /** Clear the handoff text for a phase (handoff=""). handoffUpdatedAt is left
1243
+ * unchanged as an audit trail (when a handoff last existed). */
1244
+ async clearPhaseHandoff(phaseId) {
1245
+ await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
1246
+ }
1247
+ /** List all phases that have a non-empty handoff, newest first, with a
1248
+ * human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
1249
+ async listHandoffs() {
1250
+ const phases = await this.loadAllPhases();
1251
+ const features = await this.loadFeatures();
1252
+ const featureNumber = new Map();
1253
+ for (const f of features.features)
1254
+ featureNumber.set(f.id, f.number);
1255
+ const out = [];
1256
+ for (const p of phases) {
1257
+ if (!p.handoff)
1258
+ continue;
1259
+ const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
1260
+ out.push({
1261
+ phaseId: p.id,
1262
+ compositeRef: formatPhaseRef(p.number, fnum),
1263
+ updatedAt: p.handoffUpdatedAt || p.updatedAt,
1264
+ firstLine: handoffFirstLine(p.handoff),
1265
+ });
1266
+ }
1267
+ out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1268
+ return out;
970
1269
  }
971
1270
  async deletePhase(phaseId) {
972
1271
  try {