@agent-plan/core 0.2.26 → 0.2.28

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.
Files changed (47) hide show
  1. package/dist/description-freshness.d.ts +37 -0
  2. package/dist/description-freshness.d.ts.map +1 -0
  3. package/dist/description-freshness.js +84 -0
  4. package/dist/handoff-context.d.ts +135 -3
  5. package/dist/handoff-context.d.ts.map +1 -1
  6. package/dist/handoff-context.js +336 -25
  7. package/dist/index.d.ts +4 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -1
  10. package/dist/package-version.d.ts +2 -0
  11. package/dist/package-version.d.ts.map +1 -1
  12. package/dist/package-version.js +1 -1
  13. package/dist/payload-fallback.d.ts +1 -1
  14. package/dist/payload-fallback.d.ts.map +1 -1
  15. package/dist/payload-fallback.js +6 -0
  16. package/dist/plan-store.d.ts +129 -30
  17. package/dist/plan-store.d.ts.map +1 -1
  18. package/dist/plan-store.js +690 -115
  19. package/dist/planner-rules.d.ts.map +1 -1
  20. package/dist/planner-rules.js +6 -2
  21. package/dist/read-tracking.d.ts +27 -3
  22. package/dist/read-tracking.d.ts.map +1 -1
  23. package/dist/read-tracking.js +53 -4
  24. package/dist/recap.d.ts.map +1 -1
  25. package/dist/recap.js +33 -9
  26. package/dist/renderer.d.ts.map +1 -1
  27. package/dist/renderer.js +0 -1
  28. package/dist/requirement-macro-tasks.d.ts +2 -2
  29. package/dist/requirement-macro-tasks.d.ts.map +1 -1
  30. package/dist/requirement-macro-tasks.js +2 -2
  31. package/dist/runtime-diagnostics.d.ts +34 -0
  32. package/dist/runtime-diagnostics.d.ts.map +1 -0
  33. package/dist/runtime-diagnostics.js +39 -0
  34. package/dist/schema.d.ts +487 -17
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +24 -4
  37. package/dist/task-context.d.ts +41 -2
  38. package/dist/task-context.d.ts.map +1 -1
  39. package/dist/task-context.js +102 -4
  40. package/dist/task-selection.d.ts +44 -1
  41. package/dist/task-selection.d.ts.map +1 -1
  42. package/dist/task-selection.js +158 -7
  43. package/dist/write-coordination.d.ts +27 -0
  44. package/dist/write-coordination.d.ts.map +1 -0
  45. package/dist/write-coordination.js +223 -0
  46. package/package.json +1 -1
  47. package/planner-skill.md +36 -20
@@ -1,4 +1,4 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
1
+ import { access, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve, sep } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z, ZodError } from "zod";
@@ -14,17 +14,83 @@ const PLANNER_GITIGNORE = [
14
14
  "generated/",
15
15
  "",
16
16
  ].join("\n");
17
- import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
17
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskSchema, TaskPauseSnapshotSchema, ProjectSchema, AcceptedDecisionSchema, RequirementSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
18
18
  import { createFeatureId, createIdeaId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatFeatureRef, formatIdeaRef, formatPhaseRef, formatThreeDigitNumber, isLegacyPhaseId } from "./naming.js";
19
19
  import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
20
+ import { buildHierarchicalDescriptionFreshness } from "./description-freshness.js";
20
21
  import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
21
22
  import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
22
23
  import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
23
- import { applyHandoffContextSync, auditPhaseHandoff, handoffContentHash, HandoffContractError, } from "./handoff-context.js";
24
+ import { applyHandoffContextSync, auditPhaseHandoff, externalizeOversizedHandoffContent, handoffContentHash, materializeHandoffMetadata, HandoffContractError, TARGET_HANDOFF_CONTENT_CHARS, validateHandoffReadBackVerification, } from "./handoff-context.js";
25
+ import { withPlanRootWriteLock } from "./write-coordination.js";
26
+ import { ALLOCATION_REGISTRY_VERSION, PLAN_SCHEMA_VERSION, SUPPORTED_ALLOCATION_KINDS, isSupportedAllocationKind, unsupportedAllocationKindDetails, } from "./runtime-diagnostics.js";
24
27
  function nowISO() {
25
28
  return new Date().toISOString();
26
29
  }
27
30
  const MAX_SESSION_INFO_ENTRIES = 16;
31
+ function normalizeAcceptedDecisionCreate(input, acceptedAt) {
32
+ const title = input.title.trim();
33
+ if (!title) {
34
+ throw new PlanStoreError("Accepted decision title is required.", undefined, {
35
+ errorCode: "ACCEPTED_DECISION_TITLE_REQUIRED",
36
+ entity: "acceptedDecision",
37
+ operation: "create",
38
+ });
39
+ }
40
+ return AcceptedDecisionSchema.parse({
41
+ id: randomUUID(),
42
+ title,
43
+ decision: input.decision?.trim() ?? "",
44
+ rationale: input.rationale?.trim() ?? "",
45
+ implementationNotes: input.implementationNotes?.trim() ?? "",
46
+ acceptedAt,
47
+ });
48
+ }
49
+ function updateAcceptedDecisionList(decisions, decisionId, input) {
50
+ const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
51
+ const receivedFields = mutableFields.filter((field) => input[field] !== undefined);
52
+ if (receivedFields.length === 0) {
53
+ throw new PlanStoreError("No mutable accepted decision fields were received.", undefined, {
54
+ errorCode: "NO_MUTABLE_FIELDS_RECEIVED",
55
+ entity: "acceptedDecision",
56
+ operation: "update",
57
+ mutableFields,
58
+ });
59
+ }
60
+ const index = decisions.findIndex((decision) => decision.id === decisionId);
61
+ if (index < 0) {
62
+ throw new PlanStoreError(`Accepted decision ${decisionId} not found.`, undefined, {
63
+ errorCode: "ACCEPTED_DECISION_NOT_FOUND",
64
+ entity: "acceptedDecision",
65
+ operation: "update",
66
+ decisionId,
67
+ });
68
+ }
69
+ const current = decisions[index];
70
+ const next = AcceptedDecisionSchema.parse({
71
+ ...current,
72
+ ...(input.title !== undefined ? { title: input.title.trim() } : {}),
73
+ ...(input.decision !== undefined ? { decision: input.decision.trim() } : {}),
74
+ ...(input.rationale !== undefined ? { rationale: input.rationale.trim() } : {}),
75
+ ...(input.implementationNotes !== undefined ? { implementationNotes: input.implementationNotes.trim() } : {}),
76
+ });
77
+ return {
78
+ decisions: decisions.map((decision) => decision.id === decisionId ? next : decision),
79
+ decision: next,
80
+ };
81
+ }
82
+ function deleteAcceptedDecisionFromList(decisions, decisionId) {
83
+ const decision = decisions.find((entry) => entry.id === decisionId);
84
+ if (!decision) {
85
+ throw new PlanStoreError(`Accepted decision ${decisionId} not found.`, undefined, {
86
+ errorCode: "ACCEPTED_DECISION_NOT_FOUND",
87
+ entity: "acceptedDecision",
88
+ operation: "delete",
89
+ decisionId,
90
+ });
91
+ }
92
+ return { decisions: decisions.filter((entry) => entry.id !== decisionId), decision };
93
+ }
28
94
  function upsertSessionInfo(entity, sessionId, createdAt) {
29
95
  const nextInfo = [
30
96
  ...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
@@ -36,6 +102,29 @@ function upsertSessionInfo(entity, sessionId, createdAt) {
36
102
  || nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
37
103
  return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
38
104
  }
105
+ function mergeSessionInfo(current, incoming) {
106
+ const newestBySession = new Map();
107
+ for (const entry of [...(current ?? []), ...(incoming ?? [])]) {
108
+ const prior = newestBySession.get(entry.sessionId);
109
+ if (!prior || entry.createdAt > prior)
110
+ newestBySession.set(entry.sessionId, entry.createdAt);
111
+ }
112
+ return [...newestBySession.entries()]
113
+ .map(([sessionId, createdAt]) => ({ sessionId, createdAt }))
114
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
115
+ .slice(0, MAX_SESSION_INFO_ENTRIES);
116
+ }
117
+ function assertSnapshotNotOlder(entity, entityId, incomingUpdatedAt, currentUpdatedAt) {
118
+ if (incomingUpdatedAt < currentUpdatedAt) {
119
+ throw new PlanStaleWriteError({
120
+ errorCode: "PLAN_STALE_WRITE",
121
+ entity,
122
+ entityId,
123
+ expectedUpdatedAt: incomingUpdatedAt,
124
+ actualUpdatedAt: currentUpdatedAt,
125
+ });
126
+ }
127
+ }
39
128
  function resolveStoredFeatureId(features, ref) {
40
129
  const raw = ref?.trim();
41
130
  if (!raw)
@@ -67,6 +156,36 @@ export class PlanStoreError extends Error {
67
156
  this.name = "PlanStoreError";
68
157
  }
69
158
  }
159
+ /** Optimistic-concurrency failure for a mutation based on an obsolete entity
160
+ * snapshot. Callers must reload, reapply only the intended fields, and retry. */
161
+ export class PlanStaleWriteError extends PlanStoreError {
162
+ details;
163
+ code = "PLAN_STALE_WRITE";
164
+ constructor(details) {
165
+ super(`PLAN_STALE_WRITE: ${details.entity} ${details.entityId} changed after it was read (expected ${details.expectedUpdatedAt || "an unversioned snapshot"}, found ${details.actualUpdatedAt || "an unversioned current value"}). Reload the entity, reapply only the intended fields, and retry.`, undefined, details);
166
+ this.details = details;
167
+ this.name = "PlanStaleWriteError";
168
+ }
169
+ }
170
+ export function assertPlannerRevision(entity, entityId, expectedUpdatedAt, actualUpdatedAt) {
171
+ if (expectedUpdatedAt !== undefined && expectedUpdatedAt !== actualUpdatedAt) {
172
+ throw new PlanStaleWriteError({
173
+ errorCode: "PLAN_STALE_WRITE",
174
+ entity,
175
+ entityId,
176
+ expectedUpdatedAt,
177
+ actualUpdatedAt,
178
+ });
179
+ }
180
+ }
181
+ export class PlanUnsupportedAllocationKindError extends PlanStoreError {
182
+ code = "PLAN_UNSUPPORTED_ALLOCATION_KIND";
183
+ constructor(kind) {
184
+ const details = unsupportedAllocationKindDetails(kind);
185
+ super(`PLAN_UNSUPPORTED_ALLOCATION_KIND: allocation kind "${kind}" is not supported by the loaded Agent Plan runtime. Supported kinds: ${details.supportedKinds.join(", ")}. ${details.action}`, undefined, details);
186
+ this.name = "PlanUnsupportedAllocationKindError";
187
+ }
188
+ }
70
189
  // ── Atomic file helpers ────────────────────────────────────────────────
71
190
  // Per-path write mutex: serializes concurrent writes to the SAME file so that
72
191
  // parallel tool calls (feature_create/phase_create/...) don't truncate JSON.
@@ -91,9 +210,9 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
91
210
  /** Allocation registry is deliberately outside the versioned plan. Git worktrees
92
211
  * share their common git dir, so reservations are serialized across branches
93
212
  * without rewriting project.json or unrelated planner entities. */
94
- const AllocationKindSchema = z.enum(["feature", "phase", "task", "idea"]);
213
+ const AllocationKindSchema = z.enum(SUPPORTED_ALLOCATION_KINDS);
95
214
  const AllocationRegistrySchema = z.object({
96
- version: z.literal(1),
215
+ version: z.literal(ALLOCATION_REGISTRY_VERSION),
97
216
  projectId: z.string().min(1),
98
217
  allocations: z.array(z.object({
99
218
  kind: AllocationKindSchema,
@@ -126,6 +245,34 @@ async function gitCommonDirFor(planRoot) {
126
245
  }
127
246
  }
128
247
  }
248
+ async function readAllocationRegistry(path, projectId) {
249
+ const fallback = { version: ALLOCATION_REGISTRY_VERSION, projectId, allocations: [] };
250
+ const raw = await readFile(path, "utf8")
251
+ .then((content) => JSON.parse(content))
252
+ .catch(() => fallback);
253
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
254
+ const allocations = raw.allocations;
255
+ if (Array.isArray(allocations)) {
256
+ const unsupported = allocations
257
+ .map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? String(entry.kind ?? "") : "")
258
+ .filter((kind) => kind && !isSupportedAllocationKind(kind));
259
+ if (unsupported.length > 0)
260
+ throw new PlanUnsupportedAllocationKindError(unsupported[0]);
261
+ }
262
+ }
263
+ const parsed = AllocationRegistrySchema.safeParse(raw);
264
+ if (!parsed.success) {
265
+ throw new PlanStoreError(`PLAN_RUNTIME_SCHEMA_INCOMPATIBLE: allocation registry ${path} is not compatible with the loaded Agent Plan runtime. Upgrade all Agent Plan packages and reload the harness before retrying the mutation.`, undefined, {
266
+ errorCode: "PLAN_RUNTIME_SCHEMA_INCOMPATIBLE",
267
+ schema: "allocationRegistry",
268
+ expectedVersion: ALLOCATION_REGISTRY_VERSION,
269
+ supportedKinds: SUPPORTED_ALLOCATION_KINDS,
270
+ path,
271
+ validationErrors: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
272
+ });
273
+ }
274
+ return parsed.data;
275
+ }
129
276
  async function writeRegistry(path, registry) {
130
277
  await mkdir(dirname(path), { recursive: true });
131
278
  const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
@@ -193,7 +340,7 @@ export function withFeatureLock(featureId, fn) {
193
340
  });
194
341
  }
195
342
  async function atomicWriteText(path, raw, root) {
196
- return withWriteLock(path, async () => {
343
+ const write = () => withWriteLock(path, async () => {
197
344
  writeBusyHook?.(true);
198
345
  const localRoot = root ? join(root, ".local") : undefined;
199
346
  const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
@@ -226,6 +373,7 @@ async function atomicWriteText(path, raw, root) {
226
373
  writeBusyHook?.(false);
227
374
  }
228
375
  });
376
+ return root ? withPlanRootWriteLock(root, write) : write();
229
377
  }
230
378
  async function atomicWriteJson(path, data, root) {
231
379
  return atomicWriteText(path, JSON.stringify(data, null, 2), root);
@@ -234,7 +382,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
234
382
  // NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
235
383
  // because those re-acquire withWriteLock(path) — and we already hold it (below).
236
384
  // Re-entrant locking is not supported, so calling them would deadlock.
237
- return withWriteLock(path, async () => {
385
+ const write = () => withWriteLock(path, async () => {
238
386
  const current = await readJson(path, schema);
239
387
  const updated = updater(current);
240
388
  const parsed = schema.parse(updated);
@@ -270,6 +418,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
270
418
  }
271
419
  return parsed;
272
420
  });
421
+ return root ? withPlanRootWriteLock(root, write) : write();
273
422
  }
274
423
  /** Extract the first meaningful line of a handoff: skip blank lines, strip a
275
424
  * leading markdown header (#), trim, and truncate to ~80 chars. */
@@ -559,22 +708,24 @@ export class PlanStore {
559
708
  * planners). The caller is responsible for triggering any needed final
560
709
  * sync explicitly. */
561
710
  async runAsBatch(fn) {
562
- const prev = this.batchInProgress;
563
- this.batchInProgress = true;
564
- try {
565
- return await fn();
566
- }
567
- finally {
568
- this.batchInProgress = prev;
569
- }
711
+ return withPlanRootWriteLock(this.root, async () => {
712
+ const prev = this.batchInProgress;
713
+ this.batchInProgress = true;
714
+ try {
715
+ return await fn();
716
+ }
717
+ finally {
718
+ this.batchInProgress = prev;
719
+ }
720
+ });
570
721
  }
571
722
  /** Public batch wrapper used by the module-level migrateToUuids helper. */
572
723
  async runBatchForMigration(fn) {
573
724
  return this.runAsBatch(fn);
574
725
  }
575
- /** Public batch wrapper: suspend autoSync (status rollup) for a sequence of
576
- * writes. Use for priority-only reorders so they don't recompute phase/feature
577
- * status (a reorder must not flip a partially-done feature to in-progress). */
726
+ /** Public root-scoped write transaction. It serializes the whole sequence
727
+ * across processes and suspends autoSync for nested writes. Use for composite
728
+ * mutations and priority reorders that must not be interleaved. */
578
729
  async runBatch(fn) {
579
730
  return this.runAsBatch(fn);
580
731
  }
@@ -596,11 +747,13 @@ export class PlanStore {
596
747
  fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
597
748
  toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
598
749
  }));
750
+ const activeOwnerSession = normalizedStatus === "in-progress" ? task.activeOwnerSession?.trim() ?? "" : "";
599
751
  if (descriptionUpdatedAt !== task.descriptionUpdatedAt
600
752
  || normalizedStatus !== task.status
601
753
  || pauseSnapshot !== task.pauseSnapshot
754
+ || activeOwnerSession !== task.activeOwnerSession
602
755
  || statusLog.some((entry, index) => entry !== task.statusLog[index])) {
603
- return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
756
+ return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog, activeOwnerSession };
604
757
  }
605
758
  return task;
606
759
  });
@@ -834,10 +987,11 @@ export class PlanStore {
834
987
  return join(this.featuresDir(), `${featureId}.json`);
835
988
  }
836
989
  withFeaturesLock(fn) {
837
- // Sentinel-keyed mutex (the features dir path) serializing all feature
838
- // mutations so read-modify-write via updateFeatures is race-free and the
839
- // one-time legacy→per-file migration never interleaves with a writer.
840
- return withWriteLock(this.featuresDir(), fn);
990
+ // Root-scoped coordination prevents another process from interleaving a
991
+ // feature collection transaction. The sentinel lock retains per-collection
992
+ // serialization within this process; nested file writes are re-entrant at
993
+ // the planner-root layer.
994
+ return withPlanRootWriteLock(this.root, () => withWriteLock(this.featuresDir(), fn));
841
995
  }
842
996
  /** Idempotent one-time migration: if a legacy features.json exists, split it
843
997
  * into features/<id>.json (one per feature) and remove the legacy file.
@@ -1124,7 +1278,21 @@ export class PlanStore {
1124
1278
  /** Read-only manifest load. Upgrading legacy `.local` state is explicit
1125
1279
  * maintenance (`repair`), never an incidental side effect of opening a plan. */
1126
1280
  async loadManifest() {
1127
- const manifest = await readJson(this.manifestPath(), ManifestSchema);
1281
+ let manifest;
1282
+ try {
1283
+ manifest = await readJson(this.manifestPath(), ManifestSchema);
1284
+ }
1285
+ catch (error) {
1286
+ if (error instanceof PlanStoreError && error.details?.validationErrors) {
1287
+ throw new PlanStoreError(`PLAN_RUNTIME_SCHEMA_INCOMPATIBLE: manifest ${this.manifestPath()} is not compatible with the loaded Agent Plan runtime. Expected manifest schemaVersion ${PLAN_SCHEMA_VERSION}. Upgrade all Agent Plan packages and reload the harness before retrying the operation.`, error, {
1288
+ ...error.details,
1289
+ errorCode: "PLAN_RUNTIME_SCHEMA_INCOMPATIBLE",
1290
+ schema: "manifest",
1291
+ expectedVersion: PLAN_SCHEMA_VERSION,
1292
+ });
1293
+ }
1294
+ throw error;
1295
+ }
1128
1296
  const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
1129
1297
  return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
1130
1298
  }
@@ -1152,6 +1320,9 @@ export class PlanStore {
1152
1320
  * rewritten; cross-clone coordination requires a shared allocator service.
1153
1321
  */
1154
1322
  async allocateEntityIdentity(kind, entityId) {
1323
+ if (!isSupportedAllocationKind(String(kind)))
1324
+ throw new PlanUnsupportedAllocationKindError(String(kind));
1325
+ const supportedKind = kind;
1155
1326
  const project = await this.loadProject();
1156
1327
  const manifest = await this.loadManifest();
1157
1328
  const commonGitDir = await gitCommonDirFor(this.root);
@@ -1159,31 +1330,29 @@ export class PlanStore {
1159
1330
  ? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
1160
1331
  : join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
1161
1332
  return withWriteLock(registryPath, async () => {
1162
- const registry = AllocationRegistrySchema.parse(await readFile(registryPath, "utf8")
1163
- .then(JSON.parse)
1164
- .catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
1333
+ const registry = await readAllocationRegistry(registryPath, manifest.projectId);
1165
1334
  if (registry.projectId !== manifest.projectId)
1166
1335
  throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
1167
- const prior = registry.allocations.find((entry) => entry.kind === kind && entry.entityId === entityId);
1336
+ const prior = registry.allocations.find((entry) => entry.kind === supportedKind && entry.entityId === entityId);
1168
1337
  if (prior)
1169
1338
  return { number: prior.number, shortId: prior.shortId };
1170
1339
  const phases = await this.loadAllPhases();
1171
1340
  const features = (await this.loadFeatures()).features;
1172
1341
  const ideasDocument = await this.loadIdeas();
1173
1342
  const ideas = ideasDocument.ideas;
1174
- const canonical = kind === "feature"
1343
+ const canonical = supportedKind === "feature"
1175
1344
  ? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
1176
- : kind === "phase"
1345
+ : supportedKind === "phase"
1177
1346
  ? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
1178
- : kind === "task"
1347
+ : supportedKind === "task"
1179
1348
  ? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
1180
1349
  : ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
1181
- const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
1182
- const counter = kind === "feature"
1350
+ const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === supportedKind).map((entry) => entry.number)]);
1351
+ const counter = supportedKind === "feature"
1183
1352
  ? project.nextFeatureNumber
1184
- : kind === "phase"
1353
+ : supportedKind === "phase"
1185
1354
  ? project.nextPhaseNumber
1186
- : kind === "task"
1355
+ : supportedKind === "task"
1187
1356
  ? project.nextTaskNumber
1188
1357
  : ideasDocument.nextIdeaNumber;
1189
1358
  let number = Math.max(1, counter);
@@ -1195,7 +1364,7 @@ export class PlanStore {
1195
1364
  ...ideas.map((idea) => idea.shortId),
1196
1365
  ...registry.allocations.map((entry) => entry.shortId),
1197
1366
  ].filter(Boolean));
1198
- const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
1367
+ const allocation = { kind: supportedKind, entityId, number, shortId: createShortId(allShortIds, `${supportedKind}:${entityId}`) };
1199
1368
  registry.allocations.push(allocation);
1200
1369
  await writeRegistry(registryPath, registry);
1201
1370
  return { number: allocation.number, shortId: allocation.shortId };
@@ -1497,6 +1666,14 @@ export class PlanStore {
1497
1666
  const normalized = this.normalizeStructureSnapshot({ features }, phases);
1498
1667
  return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
1499
1668
  }
1669
+ /**
1670
+ * Preview child-to-parent description drift without rewriting user-authored
1671
+ * feature or phase prose. Callers decide whether to reconcile each parent.
1672
+ */
1673
+ async previewDescriptionReconciliation() {
1674
+ const workspace = await this.loadAll();
1675
+ return buildHierarchicalDescriptionFreshness(workspace.features.features, workspace.phases);
1676
+ }
1500
1677
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
1501
1678
  * dangling feature.phaseIds references. Idempotent. */
1502
1679
  async migratePhaseIds() {
@@ -1856,11 +2033,17 @@ export class PlanStore {
1856
2033
  const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1857
2034
  return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
1858
2035
  }
1859
- /** A handoff remains active while any task needs work. Its automatic end-of-
1860
- * phase lifecycle is deliberately narrower than canonical display status:
1861
- * every task must be done or canceled (not merely derived "rejected"). */
1862
- hasCompletedHandoffLifecycle(tasks) {
1863
- return tasks.length > 0 && tasks.every((task) => task.status === "done" || task.status === "canceled");
2036
+ /** Return the archive reason for a canonical terminal phase outcome. Handoff
2037
+ * lifecycle follows the derived phase status, so rejected tasks cannot leave
2038
+ * an operational handoff active or merely hidden from the active list. */
2039
+ terminalHandoffReason(phase) {
2040
+ if (phase.status === "done")
2041
+ return "phase-done";
2042
+ if (phase.status === "rejected")
2043
+ return "phase-rejected";
2044
+ if (phase.status === "canceled")
2045
+ return "phase-canceled";
2046
+ return null;
1864
2047
  }
1865
2048
  derivePhaseStatus(tasks) {
1866
2049
  if (tasks.length === 0)
@@ -1933,14 +2116,14 @@ export class PlanStore {
1933
2116
  // (serve.ts, adapters) that invoke it after mutations.
1934
2117
  return [];
1935
2118
  }
1936
- /** Auto-clear a phase's handoff only when every task is terminal as done or
1937
- * canceled. This covers an all-canceled phase too, whose legacy canonical
1938
- * derived status is "rejected". Returns the composite ref when cleared. */
2119
+ /** Auto-clear a phase's handoff for every canonical terminal derived status.
2120
+ * Returns the composite ref when an active handoff was archived. */
1939
2121
  async syncTaskStatusRollup(phaseId) {
1940
2122
  const phase = await this.loadPhase(phaseId);
1941
2123
  let cleared = null;
1942
- if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff !== "") {
1943
- await this.clearPhaseHandoff(phaseId, "phase-done");
2124
+ const terminalReason = this.terminalHandoffReason(phase);
2125
+ if (terminalReason && phase.handoff !== "") {
2126
+ await this.clearPhaseHandoff(phaseId, terminalReason);
1944
2127
  const features = await this.loadFeatures();
1945
2128
  const feature = features.features.find((f) => f.id === phase.featureId);
1946
2129
  cleared = formatPhaseRef(phase.number, feature?.number);
@@ -2002,9 +2185,10 @@ export class PlanStore {
2002
2185
  }
2003
2186
  }
2004
2187
  // ── Savers ───────────────────────────────────────────────────────────
2005
- async updateProject(updater) {
2188
+ async updateProject(updater, options = {}) {
2006
2189
  const timestamp = nowISO();
2007
2190
  const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
2191
+ assertPlannerRevision("project", "project-guidelines", options.expectedGuidelinesUpdatedAt, current.projectGuidelines.updatedAt);
2008
2192
  const previousGuidelines = current.projectGuidelines.content;
2009
2193
  const candidate = updater(current);
2010
2194
  return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
@@ -2087,11 +2271,47 @@ export class PlanStore {
2087
2271
  await this.maybeAutoSync();
2088
2272
  return updated;
2089
2273
  }
2274
+ /** Field-scoped feature mutation against the lock-reloaded canonical document. */
2275
+ async updateFeature(featureId, updater, options = {}) {
2276
+ let persisted;
2277
+ await this.updateFeatures((document) => {
2278
+ const index = document.features.findIndex((feature) => feature.id === featureId);
2279
+ if (index < 0)
2280
+ throw new PlanStoreError(`Feature ${featureId} not found.`);
2281
+ const current = document.features[index];
2282
+ assertPlannerRevision("feature", featureId, options.expectedUpdatedAt, current.updatedAt);
2283
+ const next = updater(structuredClone(current));
2284
+ if (next.id !== current.id)
2285
+ throw new PlanStoreError("Feature identity cannot be changed by updateFeature.");
2286
+ document.features[index] = next;
2287
+ persisted = next;
2288
+ return document;
2289
+ });
2290
+ return persisted;
2291
+ }
2090
2292
  async updateRequirements(updater) {
2091
2293
  const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
2092
2294
  await this.maybeAutoSync();
2093
2295
  return updated;
2094
2296
  }
2297
+ /** Field-scoped requirement mutation against the lock-reloaded canonical document. */
2298
+ async updateRequirement(requirementId, updater, options = {}) {
2299
+ let persisted;
2300
+ await this.updateRequirements((document) => {
2301
+ const index = document.requirements.findIndex((requirement) => requirement.id === requirementId);
2302
+ if (index < 0)
2303
+ throw new PlanStoreError(`Requirement ${requirementId} not found.`);
2304
+ const current = document.requirements[index];
2305
+ assertPlannerRevision("requirement", requirementId, options.expectedUpdatedAt, current.updatedAt);
2306
+ const next = RequirementSchema.parse(updater(structuredClone(current)));
2307
+ if (next.id !== current.id)
2308
+ throw new PlanStoreError("Requirement identity cannot be changed by updateRequirement.");
2309
+ document.requirements[index] = next;
2310
+ persisted = next;
2311
+ return document;
2312
+ });
2313
+ return persisted;
2314
+ }
2095
2315
  async ensureIdeasFileForWrite() {
2096
2316
  try {
2097
2317
  await access(this.ideasPath());
@@ -2289,12 +2509,18 @@ export class PlanStore {
2289
2509
  }
2290
2510
  catch (error) {
2291
2511
  const rollbackErrors = [];
2292
- if (featureChanged && nextFeature)
2293
- await this.saveFeature(originalFeatures.features[featureIndex]).catch((rollbackError) => rollbackErrors.push(rollbackError));
2294
- if (phaseChanged)
2295
- await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
2296
- if (requirementsChanged)
2297
- await this.saveRequirements(originalRequirements).catch((rollbackError) => rollbackErrors.push(rollbackError));
2512
+ if (featureChanged && nextFeature) {
2513
+ await atomicWriteJson(this.featurePath(nextFeature.id), originalFeatures.features[featureIndex], this.root)
2514
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2515
+ }
2516
+ if (phaseChanged) {
2517
+ await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
2518
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2519
+ }
2520
+ if (requirementsChanged) {
2521
+ await atomicWriteJson(this.requirementsPath(), originalRequirements, this.root)
2522
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2523
+ }
2298
2524
  if (rollbackErrors.length > 0)
2299
2525
  throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
2300
2526
  throw error;
@@ -2322,21 +2548,40 @@ export class PlanStore {
2322
2548
  async saveProject(project) {
2323
2549
  // Runtime workDeviations live in .local/deviations.json (T299); never
2324
2550
  // persist them here so shared project.json stays stable across worktrees.
2325
- const previous = await readJson(this.projectPath(), ProjectSchema).catch(() => null);
2326
- const stamped = this.stampProjectGuidelinesUpdatedAt(project, previous?.projectGuidelines.content, nowISO());
2327
- const parsed = ProjectSchema.parse({ ...stamped, workDeviations: [] });
2328
- await atomicWriteJson(this.projectPath(), parsed, this.root);
2551
+ await withPlanRootWriteLock(this.root, async () => {
2552
+ const previous = await readJson(this.projectPath(), ProjectSchema).catch(() => null);
2553
+ const withMergedGuidelineReads = previous
2554
+ ? {
2555
+ ...project,
2556
+ projectGuidelines: {
2557
+ ...project.projectGuidelines,
2558
+ sessionInfo: mergeSessionInfo(previous.projectGuidelines.sessionInfo, project.projectGuidelines.sessionInfo),
2559
+ },
2560
+ }
2561
+ : project;
2562
+ const stamped = this.stampProjectGuidelinesUpdatedAt(withMergedGuidelineReads, previous?.projectGuidelines.content, nowISO());
2563
+ const parsed = ProjectSchema.parse({ ...stamped, workDeviations: [] });
2564
+ await atomicWriteJson(this.projectPath(), parsed, this.root);
2565
+ });
2329
2566
  await this.touchTimestamp();
2330
2567
  await this.maybeAutoSync();
2331
2568
  }
2332
2569
  async saveFeatures(features) {
2333
2570
  await this.withFeaturesLock(async () => {
2334
2571
  await this.migrateLegacy();
2335
- const previousDescriptions = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.description]));
2336
- const previousDescriptionRefs = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.descriptionRef]));
2572
+ const currentFeatures = await this.loadRawFeatures();
2573
+ const currentById = new Map(currentFeatures.map((feature) => [feature.id, feature]));
2337
2574
  const timestamp = nowISO();
2338
2575
  await this.saveFeaturesRaw({
2339
- features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
2576
+ features: features.features.map((feature) => {
2577
+ const current = currentById.get(feature.id);
2578
+ if (current)
2579
+ assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, current.updatedAt);
2580
+ const merged = current
2581
+ ? { ...feature, sessionInfo: mergeSessionInfo(current.sessionInfo, feature.sessionInfo) }
2582
+ : feature;
2583
+ return this.stampDescriptionUpdatedAt(merged, current?.description, current?.descriptionRef, timestamp);
2584
+ }),
2340
2585
  });
2341
2586
  });
2342
2587
  await this.touchTimestamp();
@@ -2371,66 +2616,105 @@ export class PlanStore {
2371
2616
  await this.withFeaturesLock(async () => {
2372
2617
  await this.migrateLegacy();
2373
2618
  const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
2619
+ if (previous)
2620
+ assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, previous.updatedAt);
2374
2621
  await mkdir(this.featuresDir(), { recursive: true });
2375
- const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, previous?.descriptionRef, nowISO()));
2622
+ const merged = previous
2623
+ ? { ...feature, sessionInfo: mergeSessionInfo(previous.sessionInfo, feature.sessionInfo) }
2624
+ : feature;
2625
+ const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(merged, previous?.description, previous?.descriptionRef, nowISO()));
2376
2626
  await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
2377
2627
  });
2378
2628
  await this.touchTimestamp();
2379
2629
  await this.maybeAutoSync();
2380
2630
  }
2381
2631
  async saveRequirements(reqs) {
2382
- const parsed = RequirementsDocumentSchema.parse(reqs);
2383
- await atomicWriteJson(this.requirementsPath(), parsed, this.root);
2632
+ await withPlanRootWriteLock(this.root, async () => {
2633
+ const current = await readJson(this.requirementsPath(), RequirementsDocumentSchema).catch(() => null);
2634
+ const currentById = new Map((current?.requirements ?? []).map((requirement) => [requirement.id, requirement]));
2635
+ const parsed = RequirementsDocumentSchema.parse({
2636
+ requirements: reqs.requirements.map((requirement) => {
2637
+ const previous = currentById.get(requirement.id);
2638
+ if (previous)
2639
+ assertSnapshotNotOlder("requirement", requirement.id, requirement.updatedAt, previous.updatedAt);
2640
+ return previous
2641
+ ? { ...requirement, sessionInfo: mergeSessionInfo(previous.sessionInfo, requirement.sessionInfo) }
2642
+ : requirement;
2643
+ }),
2644
+ });
2645
+ await atomicWriteJson(this.requirementsPath(), parsed, this.root);
2646
+ });
2384
2647
  await this.touchTimestamp();
2385
2648
  }
2386
2649
  async saveIdeas(ideas) {
2387
- const parsed = IdeasDocumentSchema.parse(ideas);
2388
- const maxNumber = parsed.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
2389
- await atomicWriteJson(this.ideasPath(), {
2390
- ...parsed,
2391
- nextIdeaNumber: Math.max(parsed.nextIdeaNumber, maxNumber + 1),
2392
- ideas: [...parsed.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
2393
- }, this.root);
2650
+ await withPlanRootWriteLock(this.root, async () => {
2651
+ const parsed = IdeasDocumentSchema.parse(ideas);
2652
+ const maxNumber = parsed.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
2653
+ await atomicWriteJson(this.ideasPath(), {
2654
+ ...parsed,
2655
+ nextIdeaNumber: Math.max(parsed.nextIdeaNumber, maxNumber + 1),
2656
+ ideas: [...parsed.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
2657
+ }, this.root);
2658
+ });
2394
2659
  await this.touchTimestamp();
2395
2660
  }
2396
- async savePhase(phase) {
2397
- const previous = await this.loadPhase(phase.id).catch(() => null);
2398
- const timestamp = nowISO();
2399
- const features = await this.loadRawFeatures();
2400
- const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
2401
- // Referential integrity: if a featureId is present but cannot be resolved
2402
- // to a known feature, REJECT — never persist an orphan featureId.
2403
- // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
2404
- // legacy migrations, repair, and feature-delete (unlink) can persist phases
2405
- // without a feature yet. The hard "featureId required" gate lives at the
2406
- // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
2407
- // planner-task-add), which is where user-facing creation happens.
2408
- if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
2409
- throw new PlanStoreError(`Cannot save phase "${phase.title}": featureId "${phase.featureId}" does not match any existing feature. Use a valid feature UUID, F00x ref, or shortId.`);
2410
- }
2411
- const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
2412
- ? { ...phase, featureId: resolvedFeatureId }
2413
- : phase;
2414
- const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
2415
- const previousTaskDescriptionRefs = new Map((previous?.tasks ?? []).map((task) => [task.id, task.descriptionRef]));
2416
- const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, previous?.descriptionRef, timestamp);
2417
- timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
2418
- const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
2419
- await mkdir(this.phasesDir(), { recursive: true });
2420
- await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
2661
+ async savePhase(phase, options = {}) {
2662
+ await withPlanRootWriteLock(this.root, async () => {
2663
+ const previous = await this.loadPhase(phase.id).catch(() => null);
2664
+ if (previous) {
2665
+ assertPlannerRevision("phase", phase.id, options.expectedUpdatedAt, previous.updatedAt);
2666
+ if (options.expectedUpdatedAt === undefined) {
2667
+ assertSnapshotNotOlder("phase", phase.id, phase.updatedAt, previous.updatedAt);
2668
+ }
2669
+ }
2670
+ const timestamp = nowISO();
2671
+ const features = await this.loadRawFeatures();
2672
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
2673
+ // Referential integrity: if a featureId is present but cannot be resolved
2674
+ // to a known feature, REJECT never persist an orphan featureId.
2675
+ // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
2676
+ // legacy migrations, repair, and feature-delete (unlink) can persist phases
2677
+ // without a feature yet. The hard "featureId required" gate lives at the
2678
+ // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
2679
+ // planner-task-add), which is where user-facing creation happens.
2680
+ if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
2681
+ throw new PlanStoreError(`Cannot save phase "${phase.title}": featureId "${phase.featureId}" does not match any existing feature. Use a valid feature UUID, F00x ref, or shortId.`);
2682
+ }
2683
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
2684
+ ? { ...phase, featureId: resolvedFeatureId }
2685
+ : phase;
2686
+ const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
2687
+ const previousTaskDescriptionRefs = new Map((previous?.tasks ?? []).map((task) => [task.id, task.descriptionRef]));
2688
+ const mergedPhase = previous
2689
+ ? { ...normalizedInput, sessionInfo: mergeSessionInfo(previous.sessionInfo, normalizedInput.sessionInfo) }
2690
+ : normalizedInput;
2691
+ const previousTasksById = new Map((previous?.tasks ?? []).map((task) => [task.id, task]));
2692
+ const timestamped = this.stampDescriptionUpdatedAt(mergedPhase, previous?.description, previous?.descriptionRef, timestamp);
2693
+ timestamped.tasks = timestamped.tasks.map((task) => {
2694
+ const previousTask = previousTasksById.get(task.id);
2695
+ const mergedTask = previousTask
2696
+ ? { ...task, sessionInfo: mergeSessionInfo(previousTask.sessionInfo, task.sessionInfo) }
2697
+ : task;
2698
+ return this.stampDescriptionUpdatedAt(mergedTask, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp);
2699
+ });
2700
+ const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
2701
+ await mkdir(this.phasesDir(), { recursive: true });
2702
+ await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
2703
+ });
2421
2704
  await this.touchTimestamp();
2422
2705
  await this.maybeAutoSync();
2423
2706
  }
2424
2707
  /** Atomic read-modify-write on a single phase file. Serializes concurrent
2425
2708
  * task_create / phase_update calls on the SAME phaseId so batch operations
2426
2709
  * don't lose tasks (last-write-wins race condition). */
2427
- async updatePhase(phaseId, updater) {
2710
+ async updatePhase(phaseId, updater, options = {}) {
2428
2711
  const features = await this.loadRawFeatures();
2429
2712
  // Augment the raw (on-disk) phase with its DERIVED status before handing it
2430
2713
  // to the updater, so updaters that read 'phase.status' see the truth. The
2431
2714
  // returned object's 'status' is stripped by PhaseSchema.parse (status is
2432
2715
  // not persisted); the return value is re-derived for the caller.
2433
2716
  const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
2717
+ assertPlannerRevision("phase", phaseId, options.expectedUpdatedAt, rawPhase.updatedAt);
2434
2718
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
2435
2719
  // The updater may mutate `current`, therefore snapshot descriptions first.
2436
2720
  const previousDescription = current.description;
@@ -2455,6 +2739,114 @@ export class PlanStore {
2455
2739
  await this.maybeAutoSync();
2456
2740
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
2457
2741
  }
2742
+ /** Field-scoped task mutation against the lock-reloaded canonical phase. */
2743
+ async updateTask(phaseId, taskId, updater, options = {}) {
2744
+ let persisted;
2745
+ const phase = await this.updatePhase(phaseId, (currentPhase) => {
2746
+ const index = currentPhase.tasks.findIndex((task) => task.id === taskId);
2747
+ if (index < 0)
2748
+ throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
2749
+ const current = currentPhase.tasks[index];
2750
+ assertPlannerRevision("task", taskId, options.expectedUpdatedAt, current.updatedAt);
2751
+ const next = TaskSchema.parse(updater(structuredClone(current)));
2752
+ if (next.id !== current.id || next.phaseId !== current.phaseId) {
2753
+ throw new PlanStoreError("Task identity and parent phase cannot be changed by updateTask.");
2754
+ }
2755
+ currentPhase.tasks[index] = next;
2756
+ currentPhase.updatedAt = next.updatedAt;
2757
+ persisted = next;
2758
+ return currentPhase;
2759
+ });
2760
+ const persistedPhase = await this.loadPhase(phaseId);
2761
+ const persistedTask = persistedPhase.tasks.find((task) => task.id === taskId);
2762
+ if (!persistedTask || JSON.stringify(persistedTask.checklist) !== JSON.stringify(persisted.checklist)) {
2763
+ throw new PlanStoreError(`Task checklist persistence verification failed for ${taskId}.`, undefined, { errorCode: "TASK_CHECKLIST_PERSISTENCE_FAILED", phaseId, taskId });
2764
+ }
2765
+ return { phase: persistedPhase, task: persistedTask };
2766
+ }
2767
+ async createAcceptedDecision(owner, input, acceptedAt = nowISO()) {
2768
+ const decision = normalizeAcceptedDecisionCreate(input, acceptedAt);
2769
+ if (owner.kind === "project") {
2770
+ await this.updateProject((project) => ({ ...project, acceptedDecisions: [...project.acceptedDecisions, decision] }));
2771
+ return decision;
2772
+ }
2773
+ if (owner.kind === "feature") {
2774
+ await this.updateFeature(owner.featureId, (feature) => ({ ...feature, acceptedDecisions: [...feature.acceptedDecisions, decision], updatedAt: acceptedAt }));
2775
+ return decision;
2776
+ }
2777
+ if (owner.kind === "phase") {
2778
+ await this.updatePhase(owner.phaseId, (phase) => ({ ...phase, acceptedDecisions: [...phase.acceptedDecisions, decision], updatedAt: acceptedAt }));
2779
+ return decision;
2780
+ }
2781
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => ({ ...task, acceptedDecisions: [...task.acceptedDecisions, decision], updatedAt: acceptedAt }));
2782
+ return decision;
2783
+ }
2784
+ async updateAcceptedDecision(owner, decisionId, input) {
2785
+ let updated;
2786
+ if (owner.kind === "project") {
2787
+ await this.updateProject((project) => {
2788
+ const result = updateAcceptedDecisionList(project.acceptedDecisions, decisionId, input);
2789
+ updated = result.decision;
2790
+ return { ...project, acceptedDecisions: result.decisions };
2791
+ });
2792
+ return updated;
2793
+ }
2794
+ if (owner.kind === "feature") {
2795
+ await this.updateFeature(owner.featureId, (feature) => {
2796
+ const result = updateAcceptedDecisionList(feature.acceptedDecisions, decisionId, input);
2797
+ updated = result.decision;
2798
+ return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2799
+ });
2800
+ return updated;
2801
+ }
2802
+ if (owner.kind === "phase") {
2803
+ await this.updatePhase(owner.phaseId, (phase) => {
2804
+ const result = updateAcceptedDecisionList(phase.acceptedDecisions, decisionId, input);
2805
+ updated = result.decision;
2806
+ return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2807
+ });
2808
+ return updated;
2809
+ }
2810
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => {
2811
+ const result = updateAcceptedDecisionList(task.acceptedDecisions, decisionId, input);
2812
+ updated = result.decision;
2813
+ return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2814
+ });
2815
+ return updated;
2816
+ }
2817
+ async deleteAcceptedDecision(owner, decisionId) {
2818
+ let deleted;
2819
+ if (owner.kind === "project") {
2820
+ await this.updateProject((project) => {
2821
+ const result = deleteAcceptedDecisionFromList(project.acceptedDecisions, decisionId);
2822
+ deleted = result.decision;
2823
+ return { ...project, acceptedDecisions: result.decisions };
2824
+ });
2825
+ return deleted;
2826
+ }
2827
+ if (owner.kind === "feature") {
2828
+ await this.updateFeature(owner.featureId, (feature) => {
2829
+ const result = deleteAcceptedDecisionFromList(feature.acceptedDecisions, decisionId);
2830
+ deleted = result.decision;
2831
+ return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2832
+ });
2833
+ return deleted;
2834
+ }
2835
+ if (owner.kind === "phase") {
2836
+ await this.updatePhase(owner.phaseId, (phase) => {
2837
+ const result = deleteAcceptedDecisionFromList(phase.acceptedDecisions, decisionId);
2838
+ deleted = result.decision;
2839
+ return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2840
+ });
2841
+ return deleted;
2842
+ }
2843
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => {
2844
+ const result = deleteAcceptedDecisionFromList(task.acceptedDecisions, decisionId);
2845
+ deleted = result.decision;
2846
+ return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2847
+ });
2848
+ return deleted;
2849
+ }
2458
2850
  /** Save a durable resume checkpoint without introducing a separate canonical task status. */
2459
2851
  async pauseTask(phaseId, taskId, input) {
2460
2852
  const snapshot = TaskPauseSnapshotSchema.parse(input);
@@ -2477,6 +2869,7 @@ export class PlanStore {
2477
2869
  status: "planned",
2478
2870
  pauseSnapshot: snapshot,
2479
2871
  pauseHistory: [...task.pauseHistory, snapshot],
2872
+ activeOwnerSession: "",
2480
2873
  statusLog: [...task.statusLog, {
2481
2874
  id: createStatusLogEntryId(),
2482
2875
  date: snapshot.pausedAt,
@@ -2493,7 +2886,7 @@ export class PlanStore {
2493
2886
  return paused;
2494
2887
  }
2495
2888
  /** Resume a checkpointed task without resetting its original startedAt. */
2496
- async resumeTask(phaseId, taskId, timestamp = nowISO()) {
2889
+ async resumeTask(phaseId, taskId, timestamp = nowISO(), ownerSessionId = "") {
2497
2890
  let resumed;
2498
2891
  await this.updatePhase(phaseId, (phase) => {
2499
2892
  const task = phase.tasks.find((candidate) => candidate.id === taskId);
@@ -2510,6 +2903,7 @@ export class PlanStore {
2510
2903
  ...task,
2511
2904
  status: "in-progress",
2512
2905
  pauseSnapshot: null,
2906
+ activeOwnerSession: ownerSessionId.trim() || task.activeOwnerSession,
2513
2907
  startedAt: task.startedAt || timestamp,
2514
2908
  statusLog: [...task.statusLog, {
2515
2909
  id: createStatusLogEntryId(),
@@ -2526,6 +2920,123 @@ export class PlanStore {
2526
2920
  });
2527
2921
  return resumed;
2528
2922
  }
2923
+ /** Reopen a completed task atomically without changing any other task.
2924
+ * Completion evidence remains in the description and status log; completedAt is
2925
+ * cleared because the task is once again active work. */
2926
+ async reopenTask(phaseId, taskId, options) {
2927
+ if (!options.confirmed) {
2928
+ throw new PlanStoreError("Task reopening requires explicit confirmation.", undefined, { errorCode: "TASK_REOPEN_CONFIRMATION_REQUIRED", taskId });
2929
+ }
2930
+ const timestamp = options.timestamp ?? nowISO();
2931
+ let reopened;
2932
+ await this.updatePhase(phaseId, (phase) => {
2933
+ const task = phase.tasks.find((candidate) => candidate.id === taskId);
2934
+ if (!task)
2935
+ throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`, undefined, { errorCode: "TASK_NOT_FOUND", taskId });
2936
+ if (task.status !== "done")
2937
+ throw new PlanStoreError(`Task ${taskId} is not completed.`, undefined, { errorCode: "TASK_REOPEN_NOT_DONE", taskId });
2938
+ reopened = {
2939
+ ...task,
2940
+ status: "in-progress",
2941
+ completedAt: "",
2942
+ pauseSnapshot: null,
2943
+ activeOwnerSession: options.ownerSessionId?.trim() ?? "",
2944
+ statusLog: [...task.statusLog, {
2945
+ id: createStatusLogEntryId(),
2946
+ date: timestamp,
2947
+ fromStatus: "done",
2948
+ toStatus: "in-progress",
2949
+ title: "done → in-progress (reopened)",
2950
+ description: "Reopened through the confirmed lifecycle operation; prior completion evidence is retained.",
2951
+ }],
2952
+ updatedAt: timestamp,
2953
+ };
2954
+ phase.tasks = phase.tasks.map((candidate) => candidate.id === taskId ? reopened : candidate);
2955
+ return phase;
2956
+ });
2957
+ return reopened;
2958
+ }
2959
+ /** Add a validated dependency edge from one task to another. */
2960
+ async addTaskDependency(phaseId, taskId, dependencyId, timestamp = nowISO()) {
2961
+ const phases = await this.loadAllPhases();
2962
+ const tasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
2963
+ const source = tasks.find((entry) => entry.phase.id === phaseId && entry.task.id === taskId);
2964
+ const dependency = tasks.find((entry) => entry.task.id === dependencyId);
2965
+ if (!source)
2966
+ throw new PlanStoreError("Task not found.", undefined, { errorCode: "TASK_NOT_FOUND" });
2967
+ if (!dependency)
2968
+ throw new PlanStoreError("Dependency task not found.", undefined, { errorCode: "DEPENDENCY_TASK_NOT_FOUND" });
2969
+ if (taskId === dependencyId)
2970
+ throw new PlanStoreError("A task cannot depend on itself.", undefined, { errorCode: "DEPENDENCY_SELF" });
2971
+ if (source.task.dependsOn.includes(dependencyId))
2972
+ return source.task;
2973
+ const graph = new Map(tasks.map(({ task }) => [task.id, [...task.dependsOn]]));
2974
+ graph.set(taskId, [...(graph.get(taskId) ?? []), dependencyId]);
2975
+ const visit = (id, path = new Set()) => { if (path.has(id))
2976
+ return true; const next = new Set(path); next.add(id); return (graph.get(id) ?? []).some((child) => visit(child, next)); };
2977
+ if (visit(taskId))
2978
+ throw new PlanStoreError("Dependency would create a cycle.", undefined, { errorCode: "DEPENDENCY_CYCLE" });
2979
+ return (await this.updateTask(phaseId, taskId, (task) => ({ ...task, dependsOn: [...task.dependsOn, dependencyId], updatedAt: timestamp }))).task;
2980
+ }
2981
+ /** Remove a dependency edge without affecting either task. */
2982
+ async deleteTaskDependency(phaseId, taskId, dependencyId) {
2983
+ return (await this.updateTask(phaseId, taskId, (task) => {
2984
+ if (!task.dependsOn.includes(dependencyId))
2985
+ throw new PlanStoreError("Dependency not found.", undefined, { errorCode: "DEPENDENCY_NOT_FOUND" });
2986
+ return { ...task, dependsOn: task.dependsOn.filter((id) => id !== dependencyId), updatedAt: nowISO() };
2987
+ })).task;
2988
+ }
2989
+ /** Create a planner-owned subtask with a stable ID under a task. */
2990
+ async createSubtask(phaseId, taskId, input, timestamp = nowISO()) {
2991
+ const title = input.title.trim();
2992
+ if (!title)
2993
+ throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
2994
+ let created;
2995
+ await this.updateTask(phaseId, taskId, (task) => {
2996
+ created = { id: randomUUID(), title, status: input.status ?? "planned", description: input.description?.trim() ?? "", createdAt: timestamp, updatedAt: timestamp };
2997
+ task.subtasks = [...(task.subtasks ?? []), created];
2998
+ return task;
2999
+ });
3000
+ return created;
3001
+ }
3002
+ /** Update a subtask by its parent task and planner-owned ID. */
3003
+ async updateSubtask(phaseId, taskId, subtaskId, input, timestamp = nowISO()) {
3004
+ let updated;
3005
+ await this.updateTask(phaseId, taskId, (task) => {
3006
+ const index = (task.subtasks ?? []).findIndex((subtask) => subtask.id === subtaskId);
3007
+ if (index < 0)
3008
+ throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
3009
+ const current = task.subtasks[index];
3010
+ if (input.title !== undefined && !input.title.trim())
3011
+ throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
3012
+ updated = { ...current, ...(input.title !== undefined ? { title: input.title.trim() } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}), ...(input.status !== undefined ? { status: input.status } : {}), updatedAt: timestamp };
3013
+ task.subtasks = task.subtasks.map((candidate, candidateIndex) => candidateIndex === index ? updated : candidate);
3014
+ return task;
3015
+ });
3016
+ return updated;
3017
+ }
3018
+ /** Delete a subtask by its planner-owned ID. */
3019
+ async deleteSubtask(phaseId, taskId, subtaskId) {
3020
+ await this.updateTask(phaseId, taskId, (task) => {
3021
+ if (!(task.subtasks ?? []).some((subtask) => subtask.id === subtaskId))
3022
+ throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
3023
+ task.subtasks = task.subtasks.filter((subtask) => subtask.id !== subtaskId);
3024
+ return task;
3025
+ });
3026
+ }
3027
+ /** Reorder subtasks without changing their stable identities. */
3028
+ async reorderSubtasks(phaseId, taskId, orderedIds) {
3029
+ let ordered = [];
3030
+ await this.updateTask(phaseId, taskId, (task) => {
3031
+ const current = task.subtasks ?? [];
3032
+ if (orderedIds.length !== current.length || new Set(orderedIds).size !== current.length || orderedIds.some((id) => !current.some((subtask) => subtask.id === id)))
3033
+ throw new PlanStoreError("Subtask order must contain every existing subtask exactly once.", undefined, { errorCode: "SUBTASK_ORDER_INVALID" });
3034
+ ordered = orderedIds.map((id) => current.find((subtask) => subtask.id === id));
3035
+ task.subtasks = ordered;
3036
+ return task;
3037
+ });
3038
+ return ordered;
3039
+ }
2529
3040
  // ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
2530
3041
  /** Get the handoff text for a phase ("" if none). Throws if phase missing. */
2531
3042
  async getPhaseHandoff(phaseId) {
@@ -2533,7 +3044,8 @@ export class PlanStore {
2533
3044
  }
2534
3045
  async validateHandoffSupportingDocuments(documents) {
2535
3046
  const docsRoot = resolve(this.root, "docs");
2536
- const verified = [];
3047
+ const metadata = [];
3048
+ const contents = [];
2537
3049
  const seen = new Set();
2538
3050
  for (const document of documents) {
2539
3051
  const normalizedPath = document.path.trim().replace(/\\/g, "/");
@@ -2548,18 +3060,23 @@ export class PlanStore {
2548
3060
  if (!target.startsWith(`${docsRoot}${sep}`)) {
2549
3061
  throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
2550
3062
  }
3063
+ const fileStat = await lstat(target).catch(() => null);
3064
+ if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
3065
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
3066
+ }
2551
3067
  const content = await readFile(target, "utf8").catch(() => null);
2552
3068
  if (content === null || !content.trim()) {
2553
3069
  throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
2554
3070
  }
2555
- verified.push({
3071
+ metadata.push({
2556
3072
  path: normalizedPath,
2557
3073
  description: document.description.trim(),
2558
3074
  contentHash: handoffContentHash(content),
2559
3075
  contentLength: content.length,
2560
3076
  });
3077
+ contents.push(content);
2561
3078
  }
2562
- return verified;
3079
+ return { metadata, contents };
2563
3080
  }
2564
3081
  /** Audit one exact phase before preparing a handoff refresh. */
2565
3082
  async preparePhaseHandoff(phaseId) {
@@ -2578,6 +3095,9 @@ export class PlanStore {
2578
3095
  async refreshPhaseHandoff(phaseId, input) {
2579
3096
  return this.runAsBatch(async () => {
2580
3097
  const originalPhase = await this.loadPhase(phaseId);
3098
+ if (this.terminalHandoffReason(originalPhase)) {
3099
+ throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
3100
+ }
2581
3101
  if (!originalPhase.featureId)
2582
3102
  throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
2583
3103
  const originalFeatures = await this.loadFeatures();
@@ -2585,8 +3105,35 @@ export class PlanStore {
2585
3105
  if (featureIndex < 0)
2586
3106
  throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
2587
3107
  const timestamp = nowISO();
2588
- const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(input.supportingDocuments ?? []);
2589
- const verifiedInput = { ...input, verifiedSupportingDocuments };
3108
+ const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
3109
+ const materializedContent = materializeHandoffMetadata(input.content, input.reason, existingCreatedAt, timestamp);
3110
+ let effectiveInput = { ...input, content: materializedContent };
3111
+ if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
3112
+ const stamp = timestamp.replace(/[:.]/g, "-");
3113
+ const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
3114
+ const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
3115
+ if (externalized.externalized) {
3116
+ const docsDir = resolve(this.root, "docs");
3117
+ await mkdir(docsDir, { recursive: true });
3118
+ const target = resolve(this.root, autoPath.slice(".planner/".length));
3119
+ if (!target.startsWith(`${docsDir}${sep}`)) {
3120
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
3121
+ }
3122
+ await writeFile(target, externalized.extendedContent, "utf8");
3123
+ const autoDoc = externalized.supportingDocument;
3124
+ effectiveInput = {
3125
+ ...input,
3126
+ content: externalized.content,
3127
+ supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
3128
+ };
3129
+ }
3130
+ }
3131
+ const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
3132
+ const verifiedInput = {
3133
+ ...effectiveInput,
3134
+ verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
3135
+ verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
3136
+ };
2590
3137
  const originalFeature = originalFeatures.features[featureIndex];
2591
3138
  const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
2592
3139
  try {
@@ -2627,14 +3174,38 @@ export class PlanStore {
2627
3174
  }
2628
3175
  });
2629
3176
  }
2630
- /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
2631
- * canceled phase cannot receive a new operational handoff. Replacing an
2632
- * existing handoff archives the previous content as `superseded` first. */
3177
+ /** Mark a persisted handoff resume-ready after a separate persisted read-back.
3178
+ * Legacy source evidence is optional; compact handoffs derive it from state. */
3179
+ async verifyPhaseHandoffReadBack(phaseId, input) {
3180
+ const timestamp = nowISO();
3181
+ const phase = await this.updatePhase(phaseId, (current) => {
3182
+ const sourceReviews = validateHandoffReadBackVerification(current, input);
3183
+ return {
3184
+ ...current,
3185
+ handoffReadAt: timestamp,
3186
+ handoffAudit: {
3187
+ ...current.handoffAudit,
3188
+ resumeReadyAt: timestamp,
3189
+ readBackSourceReviews: sourceReviews,
3190
+ },
3191
+ };
3192
+ });
3193
+ return {
3194
+ phaseId: phase.id,
3195
+ handoffUpdatedAt: phase.handoffUpdatedAt,
3196
+ contentHash: phase.handoffAudit.contentHash,
3197
+ resumeReadyAt: phase.handoffAudit.resumeReadyAt,
3198
+ sourceReviews: phase.handoffAudit.readBackSourceReviews,
3199
+ };
3200
+ }
3201
+ /** Set the handoff text for a phase + stamp handoffUpdatedAt. A terminal
3202
+ * phase cannot receive a new operational handoff. Replacing an existing
3203
+ * handoff archives the previous content as `superseded` first. */
2633
3204
  async setPhaseHandoff(phaseId, text) {
2634
3205
  const phase = await this.loadPhase(phaseId);
2635
3206
  const normalized = text.trim();
2636
- if (phase.status === "done" || phase.status === "canceled") {
2637
- throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; completed phases have no pending handoff.`);
3207
+ if (this.terminalHandoffReason(phase)) {
3208
+ throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
2638
3209
  }
2639
3210
  if (phase.handoff && normalized && phase.handoff !== normalized) {
2640
3211
  await this.clearPhaseHandoff(phaseId, "superseded");
@@ -2665,7 +3236,7 @@ export class PlanStore {
2665
3236
  }
2666
3237
  const phases = await this.loadAllPhases();
2667
3238
  const target = phases.find((p) => p.status === "in-progress")
2668
- ?? phases.find((p) => p.status !== "done" && p.status !== "canceled")
3239
+ ?? phases.find((p) => !this.terminalHandoffReason(p))
2669
3240
  ?? null;
2670
3241
  if (!target)
2671
3242
  return { imported: false }; // no non-completed phase — leave file for a later run
@@ -2686,7 +3257,8 @@ export class PlanStore {
2686
3257
  * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
2687
3258
  * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
2688
3259
  * left unchanged as an audit trail. If the handoff is empty, this is a no-op.
2689
- * reason: "phase-done" | "manual" | "superseded" | "imported". */
3260
+ * reason: "phase-done" | "phase-rejected" | "phase-canceled" |
3261
+ * "manual" | "superseded" | "imported". */
2690
3262
  async clearPhaseHandoff(phaseId, reason = "manual") {
2691
3263
  await this.migrateLegacyHandoffArchive();
2692
3264
  const phase = await this.loadPhase(phaseId).catch(() => null);
@@ -2712,14 +3284,15 @@ export class PlanStore {
2712
3284
  }
2713
3285
  await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
2714
3286
  }
2715
- /** Archive stale handoffs only after every task in their phase is done or
2716
- * canceled. Idempotent: only non-empty phase.handoff values are moved. */
3287
+ /** Archive stale handoffs for every canonical terminal phase outcome.
3288
+ * Idempotent: only non-empty phase.handoff values are moved. */
2717
3289
  async archiveStaleHandoffs() {
2718
3290
  const phases = await this.loadAllPhases();
2719
3291
  let archived = 0;
2720
3292
  for (const phase of phases) {
2721
- if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff) {
2722
- await this.clearPhaseHandoff(phase.id, "phase-done");
3293
+ const reason = this.terminalHandoffReason(phase);
3294
+ if (reason && phase.handoff) {
3295
+ await this.clearPhaseHandoff(phase.id, reason);
2723
3296
  archived += 1;
2724
3297
  }
2725
3298
  }
@@ -2730,7 +3303,7 @@ export class PlanStore {
2730
3303
  return this.runAsBatch(() => this.archiveStaleHandoffs());
2731
3304
  }
2732
3305
  /** List only active/pending phase handoffs, newest first. Handoffs from
2733
- * phases where every task is done/canceled are archived before returning. */
3306
+ * canonically terminal phases are archived before returning. */
2734
3307
  async listHandoffs(options = {}) {
2735
3308
  await this.archiveStaleHandoffs();
2736
3309
  const phases = await this.loadAllPhases();
@@ -2741,7 +3314,7 @@ export class PlanStore {
2741
3314
  featureNumber.set(f.id, f.number);
2742
3315
  const out = [];
2743
3316
  for (const p of phases) {
2744
- if (!p.handoff || p.status === "done" || p.status === "canceled")
3317
+ if (!p.handoff || this.terminalHandoffReason(p))
2745
3318
  continue;
2746
3319
  if (p.featureId && !featureIds.has(p.featureId))
2747
3320
  continue;
@@ -2757,6 +3330,8 @@ export class PlanStore {
2757
3330
  contentLength: p.handoff.length,
2758
3331
  contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
2759
3332
  verifiedAt: p.handoffAudit?.verifiedAt ?? "",
3333
+ resumeReady: Boolean(p.handoffAudit?.resumeReadyAt),
3334
+ resumeReadyAt: p.handoffAudit?.resumeReadyAt ?? "",
2760
3335
  });
2761
3336
  }
2762
3337
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));