@agent-plan/core 0.2.25 → 0.2.27

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 (66) 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/display-status.d.ts +3 -3
  5. package/dist/display-status.d.ts.map +1 -1
  6. package/dist/display-status.js +5 -4
  7. package/dist/handoff-context.d.ts +222 -1
  8. package/dist/handoff-context.d.ts.map +1 -1
  9. package/dist/handoff-context.js +461 -11
  10. package/dist/index.d.ts +8 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -1
  13. package/dist/naming.d.ts +3 -0
  14. package/dist/naming.d.ts.map +1 -1
  15. package/dist/naming.js +7 -0
  16. package/dist/package-version.d.ts +2 -0
  17. package/dist/package-version.d.ts.map +1 -1
  18. package/dist/package-version.js +1 -1
  19. package/dist/payload-fallback.d.ts +38 -0
  20. package/dist/payload-fallback.d.ts.map +1 -0
  21. package/dist/payload-fallback.js +79 -0
  22. package/dist/plan-store.d.ts +192 -36
  23. package/dist/plan-store.d.ts.map +1 -1
  24. package/dist/plan-store.js +1048 -126
  25. package/dist/planner-rules.d.ts.map +1 -1
  26. package/dist/planner-rules.js +9 -3
  27. package/dist/planner-skill.d.ts +24 -0
  28. package/dist/planner-skill.d.ts.map +1 -0
  29. package/dist/planner-skill.js +113 -0
  30. package/dist/project-context-migration.d.ts +47 -0
  31. package/dist/project-context-migration.d.ts.map +1 -0
  32. package/dist/project-context-migration.js +168 -0
  33. package/dist/read-tracking.d.ts +47 -13
  34. package/dist/read-tracking.d.ts.map +1 -1
  35. package/dist/read-tracking.js +88 -33
  36. package/dist/recap.d.ts.map +1 -1
  37. package/dist/recap.js +34 -9
  38. package/dist/refs.d.ts +6 -1
  39. package/dist/refs.d.ts.map +1 -1
  40. package/dist/refs.js +25 -0
  41. package/dist/renderer.d.ts.map +1 -1
  42. package/dist/renderer.js +24 -2
  43. package/dist/requirement-macro-tasks.d.ts +18 -0
  44. package/dist/requirement-macro-tasks.d.ts.map +1 -0
  45. package/dist/requirement-macro-tasks.js +55 -0
  46. package/dist/runtime-diagnostics.d.ts +34 -0
  47. package/dist/runtime-diagnostics.d.ts.map +1 -0
  48. package/dist/runtime-diagnostics.js +39 -0
  49. package/dist/schema.d.ts +1575 -290
  50. package/dist/schema.d.ts.map +1 -1
  51. package/dist/schema.js +89 -4
  52. package/dist/task-context.d.ts +41 -2
  53. package/dist/task-context.d.ts.map +1 -1
  54. package/dist/task-context.js +102 -4
  55. package/dist/task-selection.d.ts +44 -1
  56. package/dist/task-selection.d.ts.map +1 -1
  57. package/dist/task-selection.js +158 -7
  58. package/dist/task-start-outcome.d.ts +1 -1
  59. package/dist/task-start-outcome.d.ts.map +1 -1
  60. package/dist/task-start-outcome.js +1 -0
  61. package/dist/write-coordination.d.ts +27 -0
  62. package/dist/write-coordination.d.ts.map +1 -0
  63. package/dist/write-coordination.js +223 -0
  64. package/package.json +3 -1
  65. package/planner-skill.md +226 -0
  66. package/skills/grill-me/SKILL.md +10 -0
@@ -1,5 +1,5 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { access, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve, sep } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z, ZodError } from "zod";
5
5
  /** Canonical `.planner/.gitignore` content (P042 spec): ignore the `.local/`
@@ -14,15 +14,83 @@ const PLANNER_GITIGNORE = [
14
14
  "generated/",
15
15
  "",
16
16
  ].join("\n");
17
- import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
18
- import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
17
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskSchema, TaskPauseSnapshotSchema, ProjectSchema, AcceptedDecisionSchema, RequirementSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
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
- import { applyHandoffContextSync, auditPhaseHandoff, } from "./handoff-context.js";
22
+ import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
23
+ import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.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";
22
27
  function nowISO() {
23
28
  return new Date().toISOString();
24
29
  }
25
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
+ }
26
94
  function upsertSessionInfo(entity, sessionId, createdAt) {
27
95
  const nextInfo = [
28
96
  ...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
@@ -34,6 +102,29 @@ function upsertSessionInfo(entity, sessionId, createdAt) {
34
102
  || nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
35
103
  return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
36
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
+ }
37
128
  function resolveStoredFeatureId(features, ref) {
38
129
  const raw = ref?.trim();
39
130
  if (!raw)
@@ -65,6 +156,36 @@ export class PlanStoreError extends Error {
65
156
  this.name = "PlanStoreError";
66
157
  }
67
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
+ }
68
189
  // ── Atomic file helpers ────────────────────────────────────────────────
69
190
  // Per-path write mutex: serializes concurrent writes to the SAME file so that
70
191
  // parallel tool calls (feature_create/phase_create/...) don't truncate JSON.
@@ -89,9 +210,9 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
89
210
  /** Allocation registry is deliberately outside the versioned plan. Git worktrees
90
211
  * share their common git dir, so reservations are serialized across branches
91
212
  * without rewriting project.json or unrelated planner entities. */
92
- const AllocationKindSchema = z.enum(["feature", "phase", "task"]);
213
+ const AllocationKindSchema = z.enum(SUPPORTED_ALLOCATION_KINDS);
93
214
  const AllocationRegistrySchema = z.object({
94
- version: z.literal(1),
215
+ version: z.literal(ALLOCATION_REGISTRY_VERSION),
95
216
  projectId: z.string().min(1),
96
217
  allocations: z.array(z.object({
97
218
  kind: AllocationKindSchema,
@@ -124,6 +245,34 @@ async function gitCommonDirFor(planRoot) {
124
245
  }
125
246
  }
126
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
+ }
127
276
  async function writeRegistry(path, registry) {
128
277
  await mkdir(dirname(path), { recursive: true });
129
278
  const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
@@ -191,7 +340,7 @@ export function withFeatureLock(featureId, fn) {
191
340
  });
192
341
  }
193
342
  async function atomicWriteText(path, raw, root) {
194
- return withWriteLock(path, async () => {
343
+ const write = () => withWriteLock(path, async () => {
195
344
  writeBusyHook?.(true);
196
345
  const localRoot = root ? join(root, ".local") : undefined;
197
346
  const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
@@ -224,6 +373,7 @@ async function atomicWriteText(path, raw, root) {
224
373
  writeBusyHook?.(false);
225
374
  }
226
375
  });
376
+ return root ? withPlanRootWriteLock(root, write) : write();
227
377
  }
228
378
  async function atomicWriteJson(path, data, root) {
229
379
  return atomicWriteText(path, JSON.stringify(data, null, 2), root);
@@ -232,7 +382,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
232
382
  // NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
233
383
  // because those re-acquire withWriteLock(path) — and we already hold it (below).
234
384
  // Re-entrant locking is not supported, so calling them would deadlock.
235
- return withWriteLock(path, async () => {
385
+ const write = () => withWriteLock(path, async () => {
236
386
  const current = await readJson(path, schema);
237
387
  const updated = updater(current);
238
388
  const parsed = schema.parse(updated);
@@ -268,6 +418,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
268
418
  }
269
419
  return parsed;
270
420
  });
421
+ return root ? withPlanRootWriteLock(root, write) : write();
271
422
  }
272
423
  /** Extract the first meaningful line of a handoff: skip blank lines, strip a
273
424
  * leading markdown header (#), trim, and truncate to ~80 chars. */
@@ -557,22 +708,24 @@ export class PlanStore {
557
708
  * planners). The caller is responsible for triggering any needed final
558
709
  * sync explicitly. */
559
710
  async runAsBatch(fn) {
560
- const prev = this.batchInProgress;
561
- this.batchInProgress = true;
562
- try {
563
- return await fn();
564
- }
565
- finally {
566
- this.batchInProgress = prev;
567
- }
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
+ });
568
721
  }
569
722
  /** Public batch wrapper used by the module-level migrateToUuids helper. */
570
723
  async runBatchForMigration(fn) {
571
724
  return this.runAsBatch(fn);
572
725
  }
573
- /** Public batch wrapper: suspend autoSync (status rollup) for a sequence of
574
- * writes. Use for priority-only reorders so they don't recompute phase/feature
575
- * 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. */
576
729
  async runBatch(fn) {
577
730
  return this.runAsBatch(fn);
578
731
  }
@@ -584,7 +737,7 @@ export class PlanStore {
584
737
  // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
585
738
  // Do NOT renumber here — renumbering would break references after deletions.
586
739
  const normalized = tasks.map((task) => {
587
- const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
740
+ const descriptionUpdatedAt = (task.description || task.descriptionRef) && !task.descriptionUpdatedAt
588
741
  ? task.createdAt
589
742
  : task.descriptionUpdatedAt;
590
743
  const normalizedStatus = task.status === "paused" ? "planned" : task.status;
@@ -594,11 +747,13 @@ export class PlanStore {
594
747
  fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
595
748
  toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
596
749
  }));
750
+ const activeOwnerSession = normalizedStatus === "in-progress" ? task.activeOwnerSession?.trim() ?? "" : "";
597
751
  if (descriptionUpdatedAt !== task.descriptionUpdatedAt
598
752
  || normalizedStatus !== task.status
599
753
  || pauseSnapshot !== task.pauseSnapshot
754
+ || activeOwnerSession !== task.activeOwnerSession
600
755
  || statusLog.some((entry, index) => entry !== task.statusLog[index])) {
601
- return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
756
+ return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog, activeOwnerSession };
602
757
  }
603
758
  return task;
604
759
  });
@@ -623,17 +778,56 @@ export class PlanStore {
623
778
  /** Stamp description edits independently from generic entity mutations.
624
779
  * Legacy entities cannot reveal their historical description-edit time, so
625
780
  * their creation time is the earliest truthful fallback. */
626
- stampDescriptionUpdatedAt(entity, previousDescription, timestamp) {
627
- if (!entity.description)
781
+ stampDescriptionUpdatedAt(entity, previousDescription, previousDescriptionRef, timestamp) {
782
+ if (!entity.description && !entity.descriptionRef)
628
783
  return { ...entity, descriptionUpdatedAt: "" };
629
- if (previousDescription === undefined || previousDescription !== entity.description) {
784
+ if (previousDescription === undefined
785
+ || previousDescription !== entity.description
786
+ || previousDescriptionRef !== entity.descriptionRef) {
630
787
  return { ...entity, descriptionUpdatedAt: timestamp };
631
788
  }
632
789
  return entity.descriptionUpdatedAt ? entity : { ...entity, descriptionUpdatedAt: entity.createdAt };
633
790
  }
791
+ stampProjectGuidelinesUpdatedAt(project, previousContent, timestamp) {
792
+ const nextContent = project.projectGuidelines.content.trim();
793
+ const nextGuidelines = {
794
+ content: nextContent,
795
+ updatedAt: project.projectGuidelines.updatedAt,
796
+ sessionInfo: project.projectGuidelines.sessionInfo,
797
+ };
798
+ if (!nextContent) {
799
+ return {
800
+ ...project,
801
+ projectGuidelines: {
802
+ ...nextGuidelines,
803
+ updatedAt: "",
804
+ sessionInfo: [],
805
+ },
806
+ };
807
+ }
808
+ if (previousContent === undefined || previousContent !== nextContent) {
809
+ return {
810
+ ...project,
811
+ projectGuidelines: {
812
+ ...nextGuidelines,
813
+ updatedAt: timestamp,
814
+ },
815
+ };
816
+ }
817
+ if (nextGuidelines.updatedAt) {
818
+ return { ...project, projectGuidelines: nextGuidelines };
819
+ }
820
+ return {
821
+ ...project,
822
+ projectGuidelines: {
823
+ ...nextGuidelines,
824
+ updatedAt: timestamp,
825
+ },
826
+ };
827
+ }
634
828
  normalizeFeaturesDocument(doc) {
635
829
  // Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
636
- const features = doc.features.map((feature) => feature.description && !feature.descriptionUpdatedAt
830
+ const features = doc.features.map((feature) => (feature.description || feature.descriptionRef) && !feature.descriptionUpdatedAt
637
831
  ? { ...feature, descriptionUpdatedAt: feature.createdAt }
638
832
  : feature);
639
833
  return { doc: { ...doc, features }, changed: features.some((feature, index) => feature !== doc.features[index]) };
@@ -642,7 +836,7 @@ export class PlanStore {
642
836
  const { tasks, changed } = this.normalizeTasks(phase.tasks);
643
837
  const nextTaskIds = tasks.map((task) => task.id);
644
838
  const taskIdsChanged = nextTaskIds.length !== phase.taskIds.length || nextTaskIds.some((id, index) => id !== phase.taskIds[index]);
645
- const descriptionUpdatedAt = phase.description && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
839
+ const descriptionUpdatedAt = (phase.description || phase.descriptionRef) && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
646
840
  const descriptionTimestampChanged = descriptionUpdatedAt !== phase.descriptionUpdatedAt;
647
841
  return {
648
842
  phase: {
@@ -780,6 +974,9 @@ export class PlanStore {
780
974
  requirementsPath() {
781
975
  return join(this.root, "requirements.json");
782
976
  }
977
+ ideasPath() {
978
+ return join(this.root, "ideas.json");
979
+ }
783
980
  featuresPath() {
784
981
  return join(this.root, "features.json");
785
982
  }
@@ -790,10 +987,11 @@ export class PlanStore {
790
987
  return join(this.featuresDir(), `${featureId}.json`);
791
988
  }
792
989
  withFeaturesLock(fn) {
793
- // Sentinel-keyed mutex (the features dir path) serializing all feature
794
- // mutations so read-modify-write via updateFeatures is race-free and the
795
- // one-time legacy→per-file migration never interleaves with a writer.
796
- 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));
797
995
  }
798
996
  /** Idempotent one-time migration: if a legacy features.json exists, split it
799
997
  * into features/<id>.json (one per feature) and remove the legacy file.
@@ -946,6 +1144,11 @@ export class PlanStore {
946
1144
  name: projectName,
947
1145
  goal: "",
948
1146
  description: "",
1147
+ projectGuidelines: {
1148
+ content: "",
1149
+ updatedAt: "",
1150
+ sessionInfo: [],
1151
+ },
949
1152
  webPort: 0,
950
1153
  scope: [],
951
1154
  outOfScope: [],
@@ -967,6 +1170,7 @@ export class PlanStore {
967
1170
  workDeviations: [],
968
1171
  });
969
1172
  await this.saveRequirements({ requirements: [] });
1173
+ await this.saveIdeas({ nextIdeaNumber: 1, ideas: [] });
970
1174
  await this.saveFeatures({ features: [] });
971
1175
  await this.saveResume({
972
1176
  updatedAt: nowISO(),
@@ -985,6 +1189,8 @@ export class PlanStore {
985
1189
  // never causes git conflicts. The canonical source is plan-core; this file
986
1190
  // is the per-project copy / override point, loaded at planner startup.
987
1191
  await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
1192
+ await this.syncPlannerSkill();
1193
+ await this.syncGrillMeSkill();
988
1194
  // Write a README stub
989
1195
  const readme = [
990
1196
  "# Project Plan",
@@ -994,8 +1200,11 @@ export class PlanStore {
994
1200
  "## Structure",
995
1201
  "",
996
1202
  "- `manifest.json` — metadata",
1203
+ "- `SKILL.md` — managed cross-harness planner operating guide",
1204
+ "- `skills/grill-me/SKILL.md` — managed idea-discussion interview skill",
997
1205
  "- `project.json` — scope, rules, stack, tools",
998
1206
  "- `requirements.json` — requirements and macro-tasks",
1207
+ "- `ideas.json` — top-level Ideas Inbox (excluded from work status derivation)",
999
1208
  "- `phases/` — one JSON file per phase",
1000
1209
  "- `generated/` — auto-generated markdown views (under `.local/`)",
1001
1210
  "- `schema/plan.schema.json` — JSON Schema for tooling",
@@ -1018,6 +1227,22 @@ export class PlanStore {
1018
1227
  async extensionRules() {
1019
1228
  return loadExtensionRules(this.root);
1020
1229
  }
1230
+ /**
1231
+ * Create or safely refresh the project-local planner usage skill. Explicit
1232
+ * planner-load surfaces call this so unmodified managed copies follow the
1233
+ * installed Agent Plan version while project customizations are preserved.
1234
+ */
1235
+ async syncPlannerSkill() {
1236
+ return syncProjectPlannerSkill(this.root);
1237
+ }
1238
+ /** Safely create or refresh the project-local grill-me skill for Ideas. */
1239
+ async syncGrillMeSkill() {
1240
+ return syncProjectGrillMeSkill(this.root);
1241
+ }
1242
+ /** Load grill-me instructions only when an Ideas discussion requests them. */
1243
+ async ideaDiscussionSkill() {
1244
+ return loadProjectGrillMeSkill(this.root);
1245
+ }
1021
1246
  /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
1022
1247
  * canonical transient/derived patterns). Projects initialized before the
1023
1248
  * `.local/` move either have no `.planner/.gitignore` or one with stale
@@ -1053,7 +1278,21 @@ export class PlanStore {
1053
1278
  /** Read-only manifest load. Upgrading legacy `.local` state is explicit
1054
1279
  * maintenance (`repair`), never an incidental side effect of opening a plan. */
1055
1280
  async loadManifest() {
1056
- 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
+ }
1057
1296
  const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
1058
1297
  return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
1059
1298
  }
@@ -1077,10 +1316,13 @@ export class PlanStore {
1077
1316
  * Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
1078
1317
  * the same cross-process lock used for atomic writes. The registry reserves
1079
1318
  * numbers/short IDs before an entity file is written, so parallel branches
1080
- * cannot allocate the same F/P/T or shortId. Existing entities are never
1319
+ * cannot allocate the same F/P/T/I or shortId. Existing entities are never
1081
1320
  * rewritten; cross-clone coordination requires a shared allocator service.
1082
1321
  */
1083
1322
  async allocateEntityIdentity(kind, entityId) {
1323
+ if (!isSupportedAllocationKind(String(kind)))
1324
+ throw new PlanUnsupportedAllocationKindError(String(kind));
1325
+ const supportedKind = kind;
1084
1326
  const project = await this.loadProject();
1085
1327
  const manifest = await this.loadManifest();
1086
1328
  const commonGitDir = await gitCommonDirFor(this.root);
@@ -1088,32 +1330,41 @@ export class PlanStore {
1088
1330
  ? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
1089
1331
  : join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
1090
1332
  return withWriteLock(registryPath, async () => {
1091
- const registry = AllocationRegistrySchema.parse(await readFile(registryPath, "utf8")
1092
- .then(JSON.parse)
1093
- .catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
1333
+ const registry = await readAllocationRegistry(registryPath, manifest.projectId);
1094
1334
  if (registry.projectId !== manifest.projectId)
1095
1335
  throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
1096
- 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);
1097
1337
  if (prior)
1098
1338
  return { number: prior.number, shortId: prior.shortId };
1099
1339
  const phases = await this.loadAllPhases();
1100
1340
  const features = (await this.loadFeatures()).features;
1101
- const canonical = kind === "feature"
1341
+ const ideasDocument = await this.loadIdeas();
1342
+ const ideas = ideasDocument.ideas;
1343
+ const canonical = supportedKind === "feature"
1102
1344
  ? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
1103
- : kind === "phase"
1345
+ : supportedKind === "phase"
1104
1346
  ? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
1105
- : phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })));
1106
- const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
1107
- const counter = kind === "feature" ? project.nextFeatureNumber : kind === "phase" ? project.nextPhaseNumber : project.nextTaskNumber;
1347
+ : supportedKind === "task"
1348
+ ? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
1349
+ : ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
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"
1352
+ ? project.nextFeatureNumber
1353
+ : supportedKind === "phase"
1354
+ ? project.nextPhaseNumber
1355
+ : supportedKind === "task"
1356
+ ? project.nextTaskNumber
1357
+ : ideasDocument.nextIdeaNumber;
1108
1358
  let number = Math.max(1, counter);
1109
1359
  while (usedNumbers.has(number))
1110
1360
  number += 1;
1111
1361
  const allShortIds = new Set([
1112
1362
  ...features.map((feature) => feature.shortId),
1113
1363
  ...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
1364
+ ...ideas.map((idea) => idea.shortId),
1114
1365
  ...registry.allocations.map((entry) => entry.shortId),
1115
1366
  ].filter(Boolean));
1116
- const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
1367
+ const allocation = { kind: supportedKind, entityId, number, shortId: createShortId(allShortIds, `${supportedKind}:${entityId}`) };
1117
1368
  registry.allocations.push(allocation);
1118
1369
  await writeRegistry(registryPath, registry);
1119
1370
  return { number: allocation.number, shortId: allocation.shortId };
@@ -1124,6 +1375,7 @@ export class PlanStore {
1124
1375
  async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
1125
1376
  async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
1126
1377
  async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
1378
+ async allocIdeaNumber() { return this.allocateLegacyNumber("idea"); }
1127
1379
  async allocateLegacyNumber(kind) {
1128
1380
  const id = `legacy-${kind}-${randomUUID()}`;
1129
1381
  return (await this.allocateEntityIdentity(kind, id)).number;
@@ -1316,6 +1568,18 @@ export class PlanStore {
1316
1568
  return { requirements: [] };
1317
1569
  }
1318
1570
  }
1571
+ async loadIdeas() {
1572
+ try {
1573
+ const document = await readJson(this.ideasPath(), IdeasDocumentSchema);
1574
+ return {
1575
+ ...document,
1576
+ ideas: [...document.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
1577
+ };
1578
+ }
1579
+ catch {
1580
+ return { nextIdeaNumber: 1, ideas: [] };
1581
+ }
1582
+ }
1319
1583
  async linkedRequirementsForPhase(phaseId) {
1320
1584
  const requirements = await this.loadRequirements();
1321
1585
  return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
@@ -1390,16 +1654,25 @@ export class PlanStore {
1390
1654
  return deriveParentDisplay(childStatuses);
1391
1655
  }
1392
1656
  async loadAll() {
1393
- const [manifest, project, requirements, phases] = await Promise.all([
1657
+ const [manifest, project, requirements, ideas, phases] = await Promise.all([
1394
1658
  this.loadManifest(),
1395
1659
  this.loadProject(),
1396
1660
  this.loadRequirements(),
1661
+ this.loadIdeas(),
1397
1662
  this.loadAllPhases(),
1398
1663
  ]);
1399
1664
  const rawFeatures = await this.loadRawFeatures();
1400
1665
  const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
1401
1666
  const normalized = this.normalizeStructureSnapshot({ features }, phases);
1402
- return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
1667
+ return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
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);
1403
1676
  }
1404
1677
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
1405
1678
  * dangling feature.phaseIds references. Idempotent. */
@@ -1760,11 +2033,17 @@ export class PlanStore {
1760
2033
  const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1761
2034
  return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
1762
2035
  }
1763
- /** A handoff remains active while any task needs work. Its automatic end-of-
1764
- * phase lifecycle is deliberately narrower than canonical display status:
1765
- * every task must be done or canceled (not merely derived "rejected"). */
1766
- hasCompletedHandoffLifecycle(tasks) {
1767
- 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;
1768
2047
  }
1769
2048
  derivePhaseStatus(tasks) {
1770
2049
  if (tasks.length === 0)
@@ -1837,14 +2116,14 @@ export class PlanStore {
1837
2116
  // (serve.ts, adapters) that invoke it after mutations.
1838
2117
  return [];
1839
2118
  }
1840
- /** Auto-clear a phase's handoff only when every task is terminal as done or
1841
- * canceled. This covers an all-canceled phase too, whose legacy canonical
1842
- * 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. */
1843
2121
  async syncTaskStatusRollup(phaseId) {
1844
2122
  const phase = await this.loadPhase(phaseId);
1845
2123
  let cleared = null;
1846
- if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff !== "") {
1847
- await this.clearPhaseHandoff(phaseId, "phase-done");
2124
+ const terminalReason = this.terminalHandoffReason(phase);
2125
+ if (terminalReason && phase.handoff !== "") {
2126
+ await this.clearPhaseHandoff(phaseId, terminalReason);
1848
2127
  const features = await this.loadFeatures();
1849
2128
  const feature = features.features.find((f) => f.id === phase.featureId);
1850
2129
  cleared = formatPhaseRef(phase.number, feature?.number);
@@ -1906,11 +2185,52 @@ export class PlanStore {
1906
2185
  }
1907
2186
  }
1908
2187
  // ── Savers ───────────────────────────────────────────────────────────
1909
- async updateProject(updater) {
1910
- const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
2188
+ async updateProject(updater, options = {}) {
2189
+ const timestamp = nowISO();
2190
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
2191
+ assertPlannerRevision("project", "project-guidelines", options.expectedGuidelinesUpdatedAt, current.projectGuidelines.updatedAt);
2192
+ const previousGuidelines = current.projectGuidelines.content;
2193
+ const candidate = updater(current);
2194
+ return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
2195
+ }, this.root);
1911
2196
  await this.maybeAutoSync();
1912
2197
  return updated;
1913
2198
  }
2199
+ /** Preview the explicit, lossless migration from legacy project rule and
2200
+ * decision fields into Project Guidelines and structured accepted decisions. */
2201
+ async previewLegacyProjectContextMigration() {
2202
+ return previewLegacyProjectContextMigration(await readJson(this.projectPath(), ProjectSchema));
2203
+ }
2204
+ /** Prepare an explicit planner session before recap/context delivery.
2205
+ * Ordinary entity reads remain non-mutating. */
2206
+ async preparePlannerSession() {
2207
+ return plannerSessionPreparationResult(await this.migrateLegacyProjectContext());
2208
+ }
2209
+ /** Apply and verify the legacy project-context migration. Callers must invoke
2210
+ * this only from an explicit preparation or compatibility-recovery flow. */
2211
+ async migrateLegacyProjectContext() {
2212
+ const original = await readJson(this.projectPath(), ProjectSchema);
2213
+ const acceptedAt = nowISO();
2214
+ const expected = applyLegacyProjectContextMigration(original, acceptedAt);
2215
+ if (!expected.applied)
2216
+ return expected;
2217
+ await this.updateProject(() => expected.project);
2218
+ const persisted = await readJson(this.projectPath(), ProjectSchema);
2219
+ const migratedDecisionIds = new Set(expected.preview.acceptedDecisionAdditions.map((decision) => decision.id));
2220
+ const persistedDecisionIds = new Set(persisted.acceptedDecisions.map((decision) => decision.id));
2221
+ const verified = persisted.projectGuidelines.content === expected.preview.resultingGuidelinesContent
2222
+ && persisted.globalRules.length === 0
2223
+ && persisted.workflowRules.beforePhaseStart.length === 0
2224
+ && persisted.workflowRules.beforeTaskStart.length === 0
2225
+ && persisted.workflowRules.afterPhaseComplete.length === 0
2226
+ && persisted.decisions.length === 0
2227
+ && [...migratedDecisionIds].every((id) => persistedDecisionIds.has(id));
2228
+ if (!verified) {
2229
+ await atomicWriteJson(this.projectPath(), ProjectSchema.parse(original), this.root);
2230
+ throw new PlanStoreError("Legacy project-context migration failed persisted read-back verification; the original project was restored.");
2231
+ }
2232
+ return { applied: true, preview: expected.preview, project: persisted };
2233
+ }
1914
2234
  /** Persist an explicitly approved work deviation without coupling it to a harness. */
1915
2235
  async addWorkDeviation(deviation) {
1916
2236
  const deviations = [...(await this.loadWorkDeviations()), deviation];
@@ -1938,10 +2258,11 @@ export class PlanStore {
1938
2258
  // Updaters commonly mutate `current` in place, so snapshot descriptions
1939
2259
  // before invoking them rather than comparing object references afterward.
1940
2260
  const previousDescriptions = new Map(current.features.map((feature) => [feature.id, feature.description]));
2261
+ const previousDescriptionRefs = new Map(current.features.map((feature) => [feature.id, feature.descriptionRef]));
1941
2262
  const timestamp = nowISO();
1942
2263
  const candidate = updater(current);
1943
2264
  const stamped = {
1944
- features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
2265
+ features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
1945
2266
  };
1946
2267
  const upd = this.normalizeFeaturesDocument(stamped).doc;
1947
2268
  await this.saveFeaturesRaw(upd);
@@ -1950,11 +2271,176 @@ export class PlanStore {
1950
2271
  await this.maybeAutoSync();
1951
2272
  return updated;
1952
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
+ }
1953
2292
  async updateRequirements(updater) {
1954
2293
  const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
1955
2294
  await this.maybeAutoSync();
1956
2295
  return updated;
1957
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
+ }
2315
+ async ensureIdeasFileForWrite() {
2316
+ try {
2317
+ await access(this.ideasPath());
2318
+ }
2319
+ catch {
2320
+ await atomicWriteJson(this.ideasPath(), IdeasDocumentSchema.parse({ nextIdeaNumber: 1, ideas: [] }), this.root);
2321
+ }
2322
+ }
2323
+ async updateIdeas(updater) {
2324
+ await this.ensureIdeasFileForWrite();
2325
+ const updated = await atomicUpdateJson(this.ideasPath(), IdeasDocumentSchema, (current) => {
2326
+ const candidate = IdeasDocumentSchema.parse(updater(current));
2327
+ const maxNumber = candidate.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
2328
+ return {
2329
+ ...candidate,
2330
+ nextIdeaNumber: Math.max(candidate.nextIdeaNumber, maxNumber + 1),
2331
+ ideas: [...candidate.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
2332
+ };
2333
+ }, this.root);
2334
+ await this.touchTimestamp();
2335
+ await this.maybeAutoSync();
2336
+ return updated;
2337
+ }
2338
+ async createIdea(input, timestamp = nowISO()) {
2339
+ const id = createIdeaId();
2340
+ const { number, shortId } = await this.allocateEntityIdentity("idea", id);
2341
+ const idea = IdeaSchema.parse({
2342
+ id,
2343
+ number,
2344
+ shortId,
2345
+ title: input.title.trim(),
2346
+ description: input.description?.trim() ?? "",
2347
+ promotion: null,
2348
+ createdAt: timestamp,
2349
+ updatedAt: timestamp,
2350
+ });
2351
+ await this.updateIdeas((document) => ({
2352
+ nextIdeaNumber: Math.max(document.nextIdeaNumber, number + 1),
2353
+ ideas: [...document.ideas, idea],
2354
+ }));
2355
+ return idea;
2356
+ }
2357
+ async updateIdea(ideaId, input, timestamp = nowISO()) {
2358
+ let updated;
2359
+ await this.updateIdeas((document) => {
2360
+ const existing = document.ideas.find((idea) => idea.id === ideaId);
2361
+ if (!existing)
2362
+ throw new PlanStoreError(`Idea ${ideaId} not found.`);
2363
+ updated = IdeaSchema.parse({
2364
+ ...existing,
2365
+ ...(input.title !== undefined ? { title: input.title.trim() } : {}),
2366
+ ...(input.description !== undefined ? { description: input.description.trim() } : {}),
2367
+ updatedAt: timestamp,
2368
+ });
2369
+ return {
2370
+ ...document,
2371
+ ideas: document.ideas.map((idea) => idea.id === ideaId ? updated : idea),
2372
+ };
2373
+ });
2374
+ return updated;
2375
+ }
2376
+ async deleteIdea(ideaId) {
2377
+ let deleted = false;
2378
+ await this.updateIdeas((document) => {
2379
+ deleted = document.ideas.some((idea) => idea.id === ideaId);
2380
+ return deleted
2381
+ ? { ...document, ideas: document.ideas.filter((idea) => idea.id !== ideaId) }
2382
+ : document;
2383
+ });
2384
+ return deleted;
2385
+ }
2386
+ async resolveIdeaPromotionTarget(targetType, targetId) {
2387
+ const phases = await this.loadAllPhases();
2388
+ const features = (await this.loadFeatures()).features;
2389
+ if (targetType === "feature") {
2390
+ const feature = features.find((candidate) => candidate.id === targetId);
2391
+ if (!feature)
2392
+ throw new PlanStoreError(`Idea promotion target feature ${targetId} not found.`);
2393
+ return formatFeatureRef(feature.number);
2394
+ }
2395
+ if (targetType === "phase") {
2396
+ const phase = phases.find((candidate) => candidate.id === targetId);
2397
+ if (!phase)
2398
+ throw new PlanStoreError(`Idea promotion target phase ${targetId} not found.`);
2399
+ const feature = features.find((candidate) => candidate.id === phase.featureId);
2400
+ return formatPhaseRef(phase.number, feature?.number);
2401
+ }
2402
+ for (const phase of phases) {
2403
+ const task = phase.tasks.find((candidate) => candidate.id === targetId);
2404
+ if (!task)
2405
+ continue;
2406
+ const feature = features.find((candidate) => candidate.id === phase.featureId);
2407
+ return `${formatPhaseRef(phase.number, feature?.number)}/T${formatThreeDigitNumber(task.number)}`;
2408
+ }
2409
+ throw new PlanStoreError(`Idea promotion target task ${targetId} not found.`);
2410
+ }
2411
+ async promoteIdea(ideaId, input) {
2412
+ const targetRef = await this.resolveIdeaPromotionTarget(input.targetType, input.targetId);
2413
+ const promotedAt = input.promotedAt ?? nowISO();
2414
+ let promoted;
2415
+ await this.updateIdeas((document) => {
2416
+ const existing = document.ideas.find((idea) => idea.id === ideaId);
2417
+ if (!existing)
2418
+ throw new PlanStoreError(`Idea ${ideaId} not found.`);
2419
+ if (existing.promotion) {
2420
+ if (existing.promotion.targetType === input.targetType && existing.promotion.targetId === input.targetId) {
2421
+ promoted = existing;
2422
+ return document;
2423
+ }
2424
+ throw new PlanStoreError(`Idea ${formatIdeaRef(existing.number)} is already promoted to ${existing.promotion.targetRef}.`);
2425
+ }
2426
+ const promotion = {
2427
+ targetType: input.targetType,
2428
+ targetId: input.targetId,
2429
+ targetRef,
2430
+ promotedAt,
2431
+ };
2432
+ promoted = IdeaSchema.parse({
2433
+ ...existing,
2434
+ promotion,
2435
+ updatedAt: promotedAt,
2436
+ });
2437
+ return {
2438
+ ...document,
2439
+ ideas: document.ideas.map((idea) => idea.id === ideaId ? promoted : idea),
2440
+ };
2441
+ });
2442
+ return promoted;
2443
+ }
1958
2444
  /**
1959
2445
  * Persist completion of one full task → phase → feature → requirements read
1960
2446
  * sequence. Session metadata is deliberately the only changed entity field:
@@ -2023,12 +2509,18 @@ export class PlanStore {
2023
2509
  }
2024
2510
  catch (error) {
2025
2511
  const rollbackErrors = [];
2026
- if (featureChanged && nextFeature)
2027
- await this.saveFeature(originalFeatures.features[featureIndex]).catch((rollbackError) => rollbackErrors.push(rollbackError));
2028
- if (phaseChanged)
2029
- await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
2030
- if (requirementsChanged)
2031
- 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
+ }
2032
2524
  if (rollbackErrors.length > 0)
2033
2525
  throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
2034
2526
  throw error;
@@ -2039,21 +2531,57 @@ export class PlanStore {
2039
2531
  return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
2040
2532
  });
2041
2533
  }
2534
+ async recordProjectGuidelinesRead(input) {
2535
+ const sessionId = input.sessionId.trim();
2536
+ if (!sessionId)
2537
+ throw new PlanStoreError("Project-guidelines attestation requires a non-empty sessionId.");
2538
+ const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
2539
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (project) => {
2540
+ if (!project.projectGuidelines.content.trim())
2541
+ return project;
2542
+ const result = upsertSessionInfo(project.projectGuidelines, sessionId, createdAt);
2543
+ return result.changed ? { ...project, projectGuidelines: result.entity } : project;
2544
+ }, this.root);
2545
+ await this.maybeAutoSync();
2546
+ return updated;
2547
+ }
2042
2548
  async saveProject(project) {
2043
2549
  // Runtime workDeviations live in .local/deviations.json (T299); never
2044
2550
  // persist them here so shared project.json stays stable across worktrees.
2045
- const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
2046
- 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
+ });
2047
2566
  await this.touchTimestamp();
2048
2567
  await this.maybeAutoSync();
2049
2568
  }
2050
2569
  async saveFeatures(features) {
2051
2570
  await this.withFeaturesLock(async () => {
2052
2571
  await this.migrateLegacy();
2053
- const previousDescriptions = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.description]));
2572
+ const currentFeatures = await this.loadRawFeatures();
2573
+ const currentById = new Map(currentFeatures.map((feature) => [feature.id, feature]));
2054
2574
  const timestamp = nowISO();
2055
2575
  await this.saveFeaturesRaw({
2056
- features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.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
+ }),
2057
2585
  });
2058
2586
  });
2059
2587
  await this.touchTimestamp();
@@ -2088,63 +2616,115 @@ export class PlanStore {
2088
2616
  await this.withFeaturesLock(async () => {
2089
2617
  await this.migrateLegacy();
2090
2618
  const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
2619
+ if (previous)
2620
+ assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, previous.updatedAt);
2091
2621
  await mkdir(this.featuresDir(), { recursive: true });
2092
- const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, 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()));
2093
2626
  await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
2094
2627
  });
2095
2628
  await this.touchTimestamp();
2096
2629
  await this.maybeAutoSync();
2097
2630
  }
2098
2631
  async saveRequirements(reqs) {
2099
- const parsed = RequirementsDocumentSchema.parse(reqs);
2100
- 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
+ });
2101
2647
  await this.touchTimestamp();
2102
2648
  }
2103
- async savePhase(phase) {
2104
- const previous = await this.loadPhase(phase.id).catch(() => null);
2105
- const timestamp = nowISO();
2106
- const features = await this.loadRawFeatures();
2107
- const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
2108
- // Referential integrity: if a featureId is present but cannot be resolved
2109
- // to a known feature, REJECT — never persist an orphan featureId.
2110
- // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
2111
- // legacy migrations, repair, and feature-delete (unlink) can persist phases
2112
- // without a feature yet. The hard "featureId required" gate lives at the
2113
- // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
2114
- // planner-task-add), which is where user-facing creation happens.
2115
- if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
2116
- 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.`);
2117
- }
2118
- const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
2119
- ? { ...phase, featureId: resolvedFeatureId }
2120
- : phase;
2121
- const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
2122
- const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, timestamp);
2123
- timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
2124
- const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
2125
- await mkdir(this.phasesDir(), { recursive: true });
2126
- await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
2649
+ async saveIdeas(ideas) {
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
+ });
2659
+ await this.touchTimestamp();
2660
+ }
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
+ });
2127
2704
  await this.touchTimestamp();
2128
2705
  await this.maybeAutoSync();
2129
2706
  }
2130
2707
  /** Atomic read-modify-write on a single phase file. Serializes concurrent
2131
2708
  * task_create / phase_update calls on the SAME phaseId so batch operations
2132
2709
  * don't lose tasks (last-write-wins race condition). */
2133
- async updatePhase(phaseId, updater) {
2710
+ async updatePhase(phaseId, updater, options = {}) {
2134
2711
  const features = await this.loadRawFeatures();
2135
2712
  // Augment the raw (on-disk) phase with its DERIVED status before handing it
2136
2713
  // to the updater, so updaters that read 'phase.status' see the truth. The
2137
2714
  // returned object's 'status' is stripped by PhaseSchema.parse (status is
2138
2715
  // not persisted); the return value is re-derived for the caller.
2139
2716
  const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
2717
+ assertPlannerRevision("phase", phaseId, options.expectedUpdatedAt, rawPhase.updatedAt);
2140
2718
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
2141
2719
  // The updater may mutate `current`, therefore snapshot descriptions first.
2142
2720
  const previousDescription = current.description;
2721
+ const previousDescriptionRef = current.descriptionRef;
2143
2722
  const previousTaskDescriptions = new Map(current.tasks.map((task) => [task.id, task.description]));
2723
+ const previousTaskDescriptionRefs = new Map(current.tasks.map((task) => [task.id, task.descriptionRef]));
2144
2724
  const timestamp = nowISO();
2145
2725
  const next = updater(current);
2146
- const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, timestamp);
2147
- timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
2726
+ const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, previousDescriptionRef, timestamp);
2727
+ timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
2148
2728
  const resolvedFeatureId = resolveStoredFeatureId(features, timestamped.featureId);
2149
2729
  // Referential integrity: reject orphan featureId.
2150
2730
  if (timestamped.featureId && timestamped.featureId.trim() && !resolvedFeatureId) {
@@ -2159,6 +2739,109 @@ export class PlanStore {
2159
2739
  await this.maybeAutoSync();
2160
2740
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
2161
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
+ return { phase, task: persisted };
2761
+ }
2762
+ async createAcceptedDecision(owner, input, acceptedAt = nowISO()) {
2763
+ const decision = normalizeAcceptedDecisionCreate(input, acceptedAt);
2764
+ if (owner.kind === "project") {
2765
+ await this.updateProject((project) => ({ ...project, acceptedDecisions: [...project.acceptedDecisions, decision] }));
2766
+ return decision;
2767
+ }
2768
+ if (owner.kind === "feature") {
2769
+ await this.updateFeature(owner.featureId, (feature) => ({ ...feature, acceptedDecisions: [...feature.acceptedDecisions, decision], updatedAt: acceptedAt }));
2770
+ return decision;
2771
+ }
2772
+ if (owner.kind === "phase") {
2773
+ await this.updatePhase(owner.phaseId, (phase) => ({ ...phase, acceptedDecisions: [...phase.acceptedDecisions, decision], updatedAt: acceptedAt }));
2774
+ return decision;
2775
+ }
2776
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => ({ ...task, acceptedDecisions: [...task.acceptedDecisions, decision], updatedAt: acceptedAt }));
2777
+ return decision;
2778
+ }
2779
+ async updateAcceptedDecision(owner, decisionId, input) {
2780
+ let updated;
2781
+ if (owner.kind === "project") {
2782
+ await this.updateProject((project) => {
2783
+ const result = updateAcceptedDecisionList(project.acceptedDecisions, decisionId, input);
2784
+ updated = result.decision;
2785
+ return { ...project, acceptedDecisions: result.decisions };
2786
+ });
2787
+ return updated;
2788
+ }
2789
+ if (owner.kind === "feature") {
2790
+ await this.updateFeature(owner.featureId, (feature) => {
2791
+ const result = updateAcceptedDecisionList(feature.acceptedDecisions, decisionId, input);
2792
+ updated = result.decision;
2793
+ return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2794
+ });
2795
+ return updated;
2796
+ }
2797
+ if (owner.kind === "phase") {
2798
+ await this.updatePhase(owner.phaseId, (phase) => {
2799
+ const result = updateAcceptedDecisionList(phase.acceptedDecisions, decisionId, input);
2800
+ updated = result.decision;
2801
+ return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2802
+ });
2803
+ return updated;
2804
+ }
2805
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => {
2806
+ const result = updateAcceptedDecisionList(task.acceptedDecisions, decisionId, input);
2807
+ updated = result.decision;
2808
+ return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2809
+ });
2810
+ return updated;
2811
+ }
2812
+ async deleteAcceptedDecision(owner, decisionId) {
2813
+ let deleted;
2814
+ if (owner.kind === "project") {
2815
+ await this.updateProject((project) => {
2816
+ const result = deleteAcceptedDecisionFromList(project.acceptedDecisions, decisionId);
2817
+ deleted = result.decision;
2818
+ return { ...project, acceptedDecisions: result.decisions };
2819
+ });
2820
+ return deleted;
2821
+ }
2822
+ if (owner.kind === "feature") {
2823
+ await this.updateFeature(owner.featureId, (feature) => {
2824
+ const result = deleteAcceptedDecisionFromList(feature.acceptedDecisions, decisionId);
2825
+ deleted = result.decision;
2826
+ return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2827
+ });
2828
+ return deleted;
2829
+ }
2830
+ if (owner.kind === "phase") {
2831
+ await this.updatePhase(owner.phaseId, (phase) => {
2832
+ const result = deleteAcceptedDecisionFromList(phase.acceptedDecisions, decisionId);
2833
+ deleted = result.decision;
2834
+ return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2835
+ });
2836
+ return deleted;
2837
+ }
2838
+ await this.updateTask(owner.phaseId, owner.taskId, (task) => {
2839
+ const result = deleteAcceptedDecisionFromList(task.acceptedDecisions, decisionId);
2840
+ deleted = result.decision;
2841
+ return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
2842
+ });
2843
+ return deleted;
2844
+ }
2162
2845
  /** Save a durable resume checkpoint without introducing a separate canonical task status. */
2163
2846
  async pauseTask(phaseId, taskId, input) {
2164
2847
  const snapshot = TaskPauseSnapshotSchema.parse(input);
@@ -2181,6 +2864,7 @@ export class PlanStore {
2181
2864
  status: "planned",
2182
2865
  pauseSnapshot: snapshot,
2183
2866
  pauseHistory: [...task.pauseHistory, snapshot],
2867
+ activeOwnerSession: "",
2184
2868
  statusLog: [...task.statusLog, {
2185
2869
  id: createStatusLogEntryId(),
2186
2870
  date: snapshot.pausedAt,
@@ -2197,7 +2881,7 @@ export class PlanStore {
2197
2881
  return paused;
2198
2882
  }
2199
2883
  /** Resume a checkpointed task without resetting its original startedAt. */
2200
- async resumeTask(phaseId, taskId, timestamp = nowISO()) {
2884
+ async resumeTask(phaseId, taskId, timestamp = nowISO(), ownerSessionId = "") {
2201
2885
  let resumed;
2202
2886
  await this.updatePhase(phaseId, (phase) => {
2203
2887
  const task = phase.tasks.find((candidate) => candidate.id === taskId);
@@ -2214,6 +2898,7 @@ export class PlanStore {
2214
2898
  ...task,
2215
2899
  status: "in-progress",
2216
2900
  pauseSnapshot: null,
2901
+ activeOwnerSession: ownerSessionId.trim() || task.activeOwnerSession,
2217
2902
  startedAt: task.startedAt || timestamp,
2218
2903
  statusLog: [...task.statusLog, {
2219
2904
  id: createStatusLogEntryId(),
@@ -2230,11 +2915,164 @@ export class PlanStore {
2230
2915
  });
2231
2916
  return resumed;
2232
2917
  }
2918
+ /** Reopen a completed task atomically without changing any other task.
2919
+ * Completion evidence remains in the description and status log; completedAt is
2920
+ * cleared because the task is once again active work. */
2921
+ async reopenTask(phaseId, taskId, options) {
2922
+ if (!options.confirmed) {
2923
+ throw new PlanStoreError("Task reopening requires explicit confirmation.", undefined, { errorCode: "TASK_REOPEN_CONFIRMATION_REQUIRED", taskId });
2924
+ }
2925
+ const timestamp = options.timestamp ?? nowISO();
2926
+ let reopened;
2927
+ await this.updatePhase(phaseId, (phase) => {
2928
+ const task = phase.tasks.find((candidate) => candidate.id === taskId);
2929
+ if (!task)
2930
+ throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`, undefined, { errorCode: "TASK_NOT_FOUND", taskId });
2931
+ if (task.status !== "done")
2932
+ throw new PlanStoreError(`Task ${taskId} is not completed.`, undefined, { errorCode: "TASK_REOPEN_NOT_DONE", taskId });
2933
+ reopened = {
2934
+ ...task,
2935
+ status: "in-progress",
2936
+ completedAt: "",
2937
+ pauseSnapshot: null,
2938
+ activeOwnerSession: options.ownerSessionId?.trim() ?? "",
2939
+ statusLog: [...task.statusLog, {
2940
+ id: createStatusLogEntryId(),
2941
+ date: timestamp,
2942
+ fromStatus: "done",
2943
+ toStatus: "in-progress",
2944
+ title: "done → in-progress (reopened)",
2945
+ description: "Reopened through the confirmed lifecycle operation; prior completion evidence is retained.",
2946
+ }],
2947
+ updatedAt: timestamp,
2948
+ };
2949
+ phase.tasks = phase.tasks.map((candidate) => candidate.id === taskId ? reopened : candidate);
2950
+ return phase;
2951
+ });
2952
+ return reopened;
2953
+ }
2954
+ /** Add a validated dependency edge from one task to another. */
2955
+ async addTaskDependency(phaseId, taskId, dependencyId, timestamp = nowISO()) {
2956
+ const phases = await this.loadAllPhases();
2957
+ const tasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
2958
+ const source = tasks.find((entry) => entry.phase.id === phaseId && entry.task.id === taskId);
2959
+ const dependency = tasks.find((entry) => entry.task.id === dependencyId);
2960
+ if (!source)
2961
+ throw new PlanStoreError("Task not found.", undefined, { errorCode: "TASK_NOT_FOUND" });
2962
+ if (!dependency)
2963
+ throw new PlanStoreError("Dependency task not found.", undefined, { errorCode: "DEPENDENCY_TASK_NOT_FOUND" });
2964
+ if (taskId === dependencyId)
2965
+ throw new PlanStoreError("A task cannot depend on itself.", undefined, { errorCode: "DEPENDENCY_SELF" });
2966
+ if (source.task.dependsOn.includes(dependencyId))
2967
+ return source.task;
2968
+ const graph = new Map(tasks.map(({ task }) => [task.id, [...task.dependsOn]]));
2969
+ graph.set(taskId, [...(graph.get(taskId) ?? []), dependencyId]);
2970
+ const visit = (id, path = new Set()) => { if (path.has(id))
2971
+ return true; const next = new Set(path); next.add(id); return (graph.get(id) ?? []).some((child) => visit(child, next)); };
2972
+ if (visit(taskId))
2973
+ throw new PlanStoreError("Dependency would create a cycle.", undefined, { errorCode: "DEPENDENCY_CYCLE" });
2974
+ return (await this.updateTask(phaseId, taskId, (task) => ({ ...task, dependsOn: [...task.dependsOn, dependencyId], updatedAt: timestamp }))).task;
2975
+ }
2976
+ /** Remove a dependency edge without affecting either task. */
2977
+ async deleteTaskDependency(phaseId, taskId, dependencyId) {
2978
+ return (await this.updateTask(phaseId, taskId, (task) => {
2979
+ if (!task.dependsOn.includes(dependencyId))
2980
+ throw new PlanStoreError("Dependency not found.", undefined, { errorCode: "DEPENDENCY_NOT_FOUND" });
2981
+ return { ...task, dependsOn: task.dependsOn.filter((id) => id !== dependencyId), updatedAt: nowISO() };
2982
+ })).task;
2983
+ }
2984
+ /** Create a planner-owned subtask with a stable ID under a task. */
2985
+ async createSubtask(phaseId, taskId, input, timestamp = nowISO()) {
2986
+ const title = input.title.trim();
2987
+ if (!title)
2988
+ throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
2989
+ let created;
2990
+ await this.updateTask(phaseId, taskId, (task) => {
2991
+ created = { id: randomUUID(), title, status: input.status ?? "planned", description: input.description?.trim() ?? "", createdAt: timestamp, updatedAt: timestamp };
2992
+ task.subtasks = [...(task.subtasks ?? []), created];
2993
+ return task;
2994
+ });
2995
+ return created;
2996
+ }
2997
+ /** Update a subtask by its parent task and planner-owned ID. */
2998
+ async updateSubtask(phaseId, taskId, subtaskId, input, timestamp = nowISO()) {
2999
+ let updated;
3000
+ await this.updateTask(phaseId, taskId, (task) => {
3001
+ const index = (task.subtasks ?? []).findIndex((subtask) => subtask.id === subtaskId);
3002
+ if (index < 0)
3003
+ throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
3004
+ const current = task.subtasks[index];
3005
+ if (input.title !== undefined && !input.title.trim())
3006
+ throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
3007
+ updated = { ...current, ...(input.title !== undefined ? { title: input.title.trim() } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}), ...(input.status !== undefined ? { status: input.status } : {}), updatedAt: timestamp };
3008
+ task.subtasks = task.subtasks.map((candidate, candidateIndex) => candidateIndex === index ? updated : candidate);
3009
+ return task;
3010
+ });
3011
+ return updated;
3012
+ }
3013
+ /** Delete a subtask by its planner-owned ID. */
3014
+ async deleteSubtask(phaseId, taskId, subtaskId) {
3015
+ await this.updateTask(phaseId, taskId, (task) => {
3016
+ if (!(task.subtasks ?? []).some((subtask) => subtask.id === subtaskId))
3017
+ throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
3018
+ task.subtasks = task.subtasks.filter((subtask) => subtask.id !== subtaskId);
3019
+ return task;
3020
+ });
3021
+ }
3022
+ /** Reorder subtasks without changing their stable identities. */
3023
+ async reorderSubtasks(phaseId, taskId, orderedIds) {
3024
+ let ordered = [];
3025
+ await this.updateTask(phaseId, taskId, (task) => {
3026
+ const current = task.subtasks ?? [];
3027
+ if (orderedIds.length !== current.length || new Set(orderedIds).size !== current.length || orderedIds.some((id) => !current.some((subtask) => subtask.id === id)))
3028
+ throw new PlanStoreError("Subtask order must contain every existing subtask exactly once.", undefined, { errorCode: "SUBTASK_ORDER_INVALID" });
3029
+ ordered = orderedIds.map((id) => current.find((subtask) => subtask.id === id));
3030
+ task.subtasks = ordered;
3031
+ return task;
3032
+ });
3033
+ return ordered;
3034
+ }
2233
3035
  // ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
2234
3036
  /** Get the handoff text for a phase ("" if none). Throws if phase missing. */
2235
3037
  async getPhaseHandoff(phaseId) {
2236
3038
  return (await this.loadPhase(phaseId)).handoff;
2237
3039
  }
3040
+ async validateHandoffSupportingDocuments(documents) {
3041
+ const docsRoot = resolve(this.root, "docs");
3042
+ const metadata = [];
3043
+ const contents = [];
3044
+ const seen = new Set();
3045
+ for (const document of documents) {
3046
+ const normalizedPath = document.path.trim().replace(/\\/g, "/");
3047
+ if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
3048
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
3049
+ }
3050
+ if (seen.has(normalizedPath)) {
3051
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
3052
+ }
3053
+ seen.add(normalizedPath);
3054
+ const target = resolve(this.root, normalizedPath.slice(".planner/".length));
3055
+ if (!target.startsWith(`${docsRoot}${sep}`)) {
3056
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
3057
+ }
3058
+ const fileStat = await lstat(target).catch(() => null);
3059
+ if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
3060
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
3061
+ }
3062
+ const content = await readFile(target, "utf8").catch(() => null);
3063
+ if (content === null || !content.trim()) {
3064
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
3065
+ }
3066
+ metadata.push({
3067
+ path: normalizedPath,
3068
+ description: document.description.trim(),
3069
+ contentHash: handoffContentHash(content),
3070
+ contentLength: content.length,
3071
+ });
3072
+ contents.push(content);
3073
+ }
3074
+ return { metadata, contents };
3075
+ }
2238
3076
  /** Audit one exact phase before preparing a handoff refresh. */
2239
3077
  async preparePhaseHandoff(phaseId) {
2240
3078
  const phase = await this.loadPhase(phaseId);
@@ -2252,6 +3090,9 @@ export class PlanStore {
2252
3090
  async refreshPhaseHandoff(phaseId, input) {
2253
3091
  return this.runAsBatch(async () => {
2254
3092
  const originalPhase = await this.loadPhase(phaseId);
3093
+ if (this.terminalHandoffReason(originalPhase)) {
3094
+ throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
3095
+ }
2255
3096
  if (!originalPhase.featureId)
2256
3097
  throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
2257
3098
  const originalFeatures = await this.loadFeatures();
@@ -2259,41 +3100,113 @@ export class PlanStore {
2259
3100
  if (featureIndex < 0)
2260
3101
  throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
2261
3102
  const timestamp = nowISO();
2262
- const applied = applyHandoffContextSync(originalPhase, originalFeatures.features[featureIndex], input, timestamp);
2263
- const nextFeatures = structuredClone(originalFeatures);
2264
- nextFeatures.features[featureIndex] = applied.feature;
3103
+ const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
3104
+ const materializedContent = materializeHandoffMetadata(input.content, input.reason, existingCreatedAt, timestamp);
3105
+ let effectiveInput = { ...input, content: materializedContent };
3106
+ if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
3107
+ const stamp = timestamp.replace(/[:.]/g, "-");
3108
+ const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
3109
+ const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
3110
+ if (externalized.externalized) {
3111
+ const docsDir = resolve(this.root, "docs");
3112
+ await mkdir(docsDir, { recursive: true });
3113
+ const target = resolve(this.root, autoPath.slice(".planner/".length));
3114
+ if (!target.startsWith(`${docsDir}${sep}`)) {
3115
+ throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
3116
+ }
3117
+ await writeFile(target, externalized.extendedContent, "utf8");
3118
+ const autoDoc = externalized.supportingDocument;
3119
+ effectiveInput = {
3120
+ ...input,
3121
+ content: externalized.content,
3122
+ supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
3123
+ };
3124
+ }
3125
+ }
3126
+ const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
3127
+ const verifiedInput = {
3128
+ ...effectiveInput,
3129
+ verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
3130
+ verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
3131
+ };
3132
+ const originalFeature = originalFeatures.features[featureIndex];
3133
+ const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
2265
3134
  try {
2266
- await this.saveFeatures(nextFeatures);
3135
+ // Keep the transaction granular: only the parent feature and target phase
3136
+ // belong to this handoff refresh. Rewriting the whole feature document can
3137
+ // disturb unrelated feature containment metadata.
3138
+ await this.saveFeature(applied.feature);
2267
3139
  await this.savePhase(applied.phase);
3140
+ const phase = await this.loadPhase(phaseId);
3141
+ const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
3142
+ const persistedHash = handoffContentHash(phase.handoff);
3143
+ if (phase.handoff !== applied.phase.handoff
3144
+ || !phase.handoffAudit
3145
+ || phase.handoffAudit.contentHash !== persistedHash
3146
+ || phase.handoffAudit.contentLength !== phase.handoff.length) {
3147
+ throw new HandoffContractError("HANDOFF_PERSISTENCE_VERIFICATION_FAILED", "The persisted handoff did not match the verified content. The write was rolled back.", { expectedHash: applied.phase.handoffAudit?.contentHash ?? "", actualHash: persistedHash });
3148
+ }
3149
+ return {
3150
+ phase,
3151
+ feature,
3152
+ updatedTaskIds: applied.updatedTaskIds,
3153
+ handoffUpdatedAt: phase.handoffUpdatedAt,
3154
+ handoffAudit: phase.handoffAudit,
3155
+ };
2268
3156
  }
2269
3157
  catch (error) {
2270
3158
  const rollbackErrors = [];
2271
- await this.saveFeatures(originalFeatures).catch((rollbackError) => rollbackErrors.push(rollbackError));
2272
- await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
3159
+ // Restore the exact parsed snapshots directly. Going through saveFeature /
3160
+ // savePhase would restamp description freshness against the failed write.
3161
+ await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
3162
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
3163
+ await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
3164
+ .catch((rollbackError) => rollbackErrors.push(rollbackError));
2273
3165
  if (rollbackErrors.length > 0) {
2274
3166
  throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
2275
3167
  }
2276
3168
  throw error;
2277
3169
  }
2278
- const phase = await this.loadPhase(phaseId);
2279
- const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
2280
- return { phase, feature, updatedTaskIds: applied.updatedTaskIds, handoffUpdatedAt: phase.handoffUpdatedAt };
2281
3170
  });
2282
3171
  }
2283
- /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
2284
- * canceled phase cannot receive a new operational handoff. Replacing an
2285
- * existing handoff archives the previous content as `superseded` first. */
3172
+ /** Mark a persisted handoff resume-ready only after a separate full read-back
3173
+ * has reconciled every required source and found no omissions. */
3174
+ async verifyPhaseHandoffReadBack(phaseId, input) {
3175
+ const timestamp = nowISO();
3176
+ const phase = await this.updatePhase(phaseId, (current) => {
3177
+ const sourceReviews = validateHandoffReadBackVerification(current, input);
3178
+ return {
3179
+ ...current,
3180
+ handoffReadAt: timestamp,
3181
+ handoffAudit: {
3182
+ ...current.handoffAudit,
3183
+ resumeReadyAt: timestamp,
3184
+ readBackSourceReviews: sourceReviews,
3185
+ },
3186
+ };
3187
+ });
3188
+ return {
3189
+ phaseId: phase.id,
3190
+ handoffUpdatedAt: phase.handoffUpdatedAt,
3191
+ contentHash: phase.handoffAudit.contentHash,
3192
+ resumeReadyAt: phase.handoffAudit.resumeReadyAt,
3193
+ sourceReviews: phase.handoffAudit.readBackSourceReviews,
3194
+ };
3195
+ }
3196
+ /** Set the handoff text for a phase + stamp handoffUpdatedAt. A terminal
3197
+ * phase cannot receive a new operational handoff. Replacing an existing
3198
+ * handoff archives the previous content as `superseded` first. */
2286
3199
  async setPhaseHandoff(phaseId, text) {
2287
3200
  const phase = await this.loadPhase(phaseId);
2288
3201
  const normalized = text.trim();
2289
- if (phase.status === "done" || phase.status === "canceled") {
2290
- throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; completed phases have no pending handoff.`);
3202
+ if (this.terminalHandoffReason(phase)) {
3203
+ throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
2291
3204
  }
2292
3205
  if (phase.handoff && normalized && phase.handoff !== normalized) {
2293
3206
  await this.clearPhaseHandoff(phaseId, "superseded");
2294
3207
  }
2295
3208
  const now = new Date().toISOString();
2296
- await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
3209
+ await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now, handoffAudit: null }));
2297
3210
  }
2298
3211
  /** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
2299
3212
  * Does NOT clear it: read/load/show are non-mutating resume operations. */
@@ -2318,7 +3231,7 @@ export class PlanStore {
2318
3231
  }
2319
3232
  const phases = await this.loadAllPhases();
2320
3233
  const target = phases.find((p) => p.status === "in-progress")
2321
- ?? phases.find((p) => p.status !== "done" && p.status !== "canceled")
3234
+ ?? phases.find((p) => !this.terminalHandoffReason(p))
2322
3235
  ?? null;
2323
3236
  if (!target)
2324
3237
  return { imported: false }; // no non-completed phase — leave file for a later run
@@ -2339,7 +3252,8 @@ export class PlanStore {
2339
3252
  * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
2340
3253
  * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
2341
3254
  * left unchanged as an audit trail. If the handoff is empty, this is a no-op.
2342
- * reason: "phase-done" | "manual" | "superseded" | "imported". */
3255
+ * reason: "phase-done" | "phase-rejected" | "phase-canceled" |
3256
+ * "manual" | "superseded" | "imported". */
2343
3257
  async clearPhaseHandoff(phaseId, reason = "manual") {
2344
3258
  await this.migrateLegacyHandoffArchive();
2345
3259
  const phase = await this.loadPhase(phaseId).catch(() => null);
@@ -2365,14 +3279,15 @@ export class PlanStore {
2365
3279
  }
2366
3280
  await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
2367
3281
  }
2368
- /** Archive stale handoffs only after every task in their phase is done or
2369
- * canceled. Idempotent: only non-empty phase.handoff values are moved. */
3282
+ /** Archive stale handoffs for every canonical terminal phase outcome.
3283
+ * Idempotent: only non-empty phase.handoff values are moved. */
2370
3284
  async archiveStaleHandoffs() {
2371
3285
  const phases = await this.loadAllPhases();
2372
3286
  let archived = 0;
2373
3287
  for (const phase of phases) {
2374
- if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff) {
2375
- await this.clearPhaseHandoff(phase.id, "phase-done");
3288
+ const reason = this.terminalHandoffReason(phase);
3289
+ if (reason && phase.handoff) {
3290
+ await this.clearPhaseHandoff(phase.id, reason);
2376
3291
  archived += 1;
2377
3292
  }
2378
3293
  }
@@ -2383,8 +3298,8 @@ export class PlanStore {
2383
3298
  return this.runAsBatch(() => this.archiveStaleHandoffs());
2384
3299
  }
2385
3300
  /** List only active/pending phase handoffs, newest first. Handoffs from
2386
- * phases where every task is done/canceled are archived before returning. */
2387
- async listHandoffs() {
3301
+ * canonically terminal phases are archived before returning. */
3302
+ async listHandoffs(options = {}) {
2388
3303
  await this.archiveStaleHandoffs();
2389
3304
  const phases = await this.loadAllPhases();
2390
3305
  const features = await this.loadFeatures();
@@ -2394,7 +3309,7 @@ export class PlanStore {
2394
3309
  featureNumber.set(f.id, f.number);
2395
3310
  const out = [];
2396
3311
  for (const p of phases) {
2397
- if (!p.handoff || p.status === "done" || p.status === "canceled")
3312
+ if (!p.handoff || this.terminalHandoffReason(p))
2398
3313
  continue;
2399
3314
  if (p.featureId && !featureIds.has(p.featureId))
2400
3315
  continue;
@@ -2405,7 +3320,13 @@ export class PlanStore {
2405
3320
  compositeRef: formatPhaseRef(p.number, fnum),
2406
3321
  updatedAt: p.handoffUpdatedAt || p.updatedAt,
2407
3322
  firstLine: handoffFirstLine(p.handoff),
2408
- content: p.handoff,
3323
+ ...(options.includeContent ? { content: p.handoff } : {}),
3324
+ auditVersion: p.handoffAudit?.version ?? null,
3325
+ contentLength: p.handoff.length,
3326
+ contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
3327
+ verifiedAt: p.handoffAudit?.verifiedAt ?? "",
3328
+ resumeReady: Boolean(p.handoffAudit?.resumeReadyAt),
3329
+ resumeReadyAt: p.handoffAudit?.resumeReadyAt ?? "",
2409
3330
  });
2410
3331
  }
2411
3332
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -2503,14 +3424,15 @@ export class PlanStore {
2503
3424
  await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
2504
3425
  }
2505
3426
  // ── Workspace-level operations ─────────────────────────────────────
2506
- /** Load the full workspace (manifest + phases + project + requirements + features) */
3427
+ /** Load the full workspace, including the rollup-independent Ideas Inbox. */
2507
3428
  async loadWorkspace() {
2508
3429
  const manifest = await this.loadManifest();
2509
3430
  const phases = await this.loadAllPhases();
2510
3431
  const project = await this.loadProject();
2511
3432
  const features = await this.loadFeatures();
2512
3433
  const requirements = await this.loadRequirements();
2513
- return { manifest, phases, project, features, requirements };
3434
+ const ideas = await this.loadIdeas();
3435
+ return { manifest, phases, project, features, requirements, ideas };
2514
3436
  }
2515
3437
  // ── Markdown generation ────────────────────────────────────────────
2516
3438
  /** Load all data, render markdown, and write into generated/. Skips files