@agent-plan/core 0.2.19-next.9 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,53 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, } from "./schema.js";
4
- import { createFeatureId, createPhaseId, createRequirementId, createShortId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
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";
3
+ import { randomUUID } from "node:crypto";
4
+ import { z, ZodError } from "zod";
5
+ /** Canonical `.planner/.gitignore` content (P042 spec): ignore the `.local/`
6
+ * transient root, legacy `*.bak` crash backups, `*.tmp.*` atomic-write temp
7
+ * files, and the legacy root-level `generated/` dir (now under `.local/`).
8
+ * Shared by `init()` and `ensureGitignore()` so the two never drift. */
9
+ const PLANNER_GITIGNORE = [
10
+ "# Agent Plan transient/derived/session-local files — do not track",
11
+ ".local/",
12
+ "*.bak",
13
+ "*.tmp.*",
14
+ "generated/",
15
+ "",
16
+ ].join("\n");
17
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, } from "./schema.js";
18
+ import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
19
+ import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
5
20
  function nowISO() {
6
21
  return new Date().toISOString();
7
22
  }
23
+ function resolveStoredFeatureId(features, ref) {
24
+ const raw = ref?.trim();
25
+ if (!raw)
26
+ return undefined;
27
+ const normalized = raw.toLowerCase();
28
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
29
+ if (byId)
30
+ return byId.id;
31
+ const byNumber = normalized.match(/^f(\d+)$/)
32
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
33
+ : undefined;
34
+ if (byNumber)
35
+ return byNumber.id;
36
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
37
+ if (byShortId)
38
+ return byShortId.id;
39
+ const byExactName = features.find((feature) => feature.name.toLowerCase() === normalized);
40
+ if (byExactName)
41
+ return byExactName.id;
42
+ return undefined;
43
+ }
8
44
  export class PlanStoreError extends Error {
9
45
  cause;
10
- constructor(message, cause) {
46
+ details;
47
+ constructor(message, cause, details) {
11
48
  super(message);
12
49
  this.cause = cause;
50
+ this.details = details;
13
51
  this.name = "PlanStoreError";
14
52
  }
15
53
  }
@@ -32,14 +70,97 @@ let writeNotifyHook;
32
70
  export function setWriteNotifyHook(hook) {
33
71
  writeNotifyHook = hook;
34
72
  }
73
+ const CROSS_PROCESS_LOCK_STALE_MS = 30_000;
74
+ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
75
+ /** Allocation registry is deliberately outside the versioned plan. Git worktrees
76
+ * share their common git dir, so reservations are serialized across branches
77
+ * without rewriting project.json or unrelated planner entities. */
78
+ const AllocationKindSchema = z.enum(["feature", "phase", "task"]);
79
+ const AllocationRegistrySchema = z.object({
80
+ version: z.literal(1),
81
+ projectId: z.string().min(1),
82
+ allocations: z.array(z.object({
83
+ kind: AllocationKindSchema,
84
+ entityId: z.string().min(1),
85
+ number: z.number().int().positive(),
86
+ shortId: z.string().regex(/^[A-Z2-9]{5}$/),
87
+ })).default([]),
88
+ });
89
+ async function gitCommonDirFor(planRoot) {
90
+ let current = resolve(planRoot);
91
+ for (;;) {
92
+ const dotGit = join(current, ".git");
93
+ try {
94
+ const info = await stat(dotGit);
95
+ if (info.isDirectory())
96
+ return dotGit;
97
+ const pointer = await readFile(dotGit, "utf8");
98
+ const match = pointer.match(/^gitdir:\s*(.+)\s*$/m);
99
+ if (!match?.[1])
100
+ return undefined;
101
+ const worktreeGitDir = resolve(current, match[1]);
102
+ const commonDirRef = await readFile(join(worktreeGitDir, "commondir"), "utf8").catch(() => "");
103
+ return commonDirRef.trim() ? resolve(worktreeGitDir, commonDirRef.trim()) : worktreeGitDir;
104
+ }
105
+ catch {
106
+ const parent = dirname(current);
107
+ if (parent === current)
108
+ return undefined;
109
+ current = parent;
110
+ }
111
+ }
112
+ }
113
+ async function writeRegistry(path, registry) {
114
+ await mkdir(dirname(path), { recursive: true });
115
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
116
+ await writeFile(tmp, JSON.stringify(registry, null, 2), "utf8");
117
+ await rename(tmp, path);
118
+ }
119
+ async function acquireCrossProcessLock(path) {
120
+ const lockPath = `${path}.lock`;
121
+ await mkdir(dirname(lockPath), { recursive: true });
122
+ for (;;) {
123
+ try {
124
+ await mkdir(lockPath);
125
+ return async () => {
126
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
127
+ };
128
+ }
129
+ catch (error) {
130
+ const err = error;
131
+ if (err?.code !== "EEXIST")
132
+ throw err;
133
+ try {
134
+ const info = await stat(lockPath);
135
+ if (Date.now() - info.mtimeMs > CROSS_PROCESS_LOCK_STALE_MS) {
136
+ await rm(lockPath, { recursive: true, force: true });
137
+ continue;
138
+ }
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ await new Promise((resolve) => setTimeout(resolve, CROSS_PROCESS_LOCK_RETRY_MS));
144
+ }
145
+ }
146
+ }
35
147
  function withWriteLock(path, fn) {
36
148
  const prev = writeLocks.get(path) ?? Promise.resolve();
37
149
  let release;
38
150
  const next = new Promise((resolve) => { release = resolve; });
39
- writeLocks.set(path, prev.then(() => next));
40
- return prev.then(fn).finally(() => {
151
+ const tail = prev.then(() => next);
152
+ writeLocks.set(path, tail);
153
+ return prev.then(async () => {
154
+ const releaseCrossProcess = await acquireCrossProcessLock(path);
155
+ try {
156
+ return await fn();
157
+ }
158
+ finally {
159
+ await releaseCrossProcess();
160
+ }
161
+ }).finally(() => {
41
162
  release();
42
- if (writeLocks.get(path) === prev.then(() => next))
163
+ if (writeLocks.get(path) === tail)
43
164
  writeLocks.delete(path);
44
165
  });
45
166
  }
@@ -47,21 +168,35 @@ export function withFeatureLock(featureId, fn) {
47
168
  const prev = featureLocks.get(featureId) ?? Promise.resolve();
48
169
  let release;
49
170
  const next = new Promise((resolve) => { release = resolve; });
50
- featureLocks.set(featureId, prev.then(() => next));
171
+ const tail = prev.then(() => next);
172
+ featureLocks.set(featureId, tail);
51
173
  return prev.then(fn).finally(() => {
52
174
  release();
53
- if (featureLocks.get(featureId) === prev.then(() => next))
175
+ if (featureLocks.get(featureId) === tail)
54
176
  featureLocks.delete(featureId);
55
177
  });
56
178
  }
57
- async function atomicWriteText(path, raw) {
179
+ async function atomicWriteText(path, raw, root) {
58
180
  return withWriteLock(path, async () => {
59
181
  writeBusyHook?.(true);
60
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
182
+ const localRoot = root ? join(root, ".local") : undefined;
183
+ const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
184
+ const backupsRoot = localRoot ? join(localRoot, "backups") : dirname(path);
185
+ const rel = localRoot && path.startsWith(localRoot)
186
+ ? path.slice(localRoot.length).replace(/^\//, "")
187
+ : root && path.startsWith(root)
188
+ ? path.slice(root.length).replace(/^\//, "")
189
+ : basename(path);
190
+ const backupRel = rel ? rel + ".bak" : basename(path) + ".bak";
191
+ const backupPath = join(backupsRoot, backupRel);
192
+ const tmpName = rel ? rel.replace(/[/\\]/g, "--") + `.tmp.${process.pid}.${Date.now()}` : `${basename(path)}.tmp.${process.pid}.${Date.now()}`;
193
+ const tmp = join(tmpDir, tmpName);
61
194
  try {
195
+ await mkdir(tmpDir, { recursive: true });
62
196
  await writeFile(tmp, raw, "utf-8");
63
197
  try {
64
- await copyFile(path, `${path}.bak`);
198
+ await mkdir(dirname(backupPath), { recursive: true });
199
+ await copyFile(path, backupPath);
65
200
  }
66
201
  catch { }
67
202
  await rename(tmp, path);
@@ -76,10 +211,10 @@ async function atomicWriteText(path, raw) {
76
211
  }
77
212
  });
78
213
  }
79
- async function atomicWriteJson(path, data) {
80
- return atomicWriteText(path, JSON.stringify(data, null, 2));
214
+ async function atomicWriteJson(path, data, root) {
215
+ return atomicWriteText(path, JSON.stringify(data, null, 2), root);
81
216
  }
82
- async function atomicUpdateJson(path, schema, updater) {
217
+ async function atomicUpdateJson(path, schema, updater, root) {
83
218
  // NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
84
219
  // because those re-acquire withWriteLock(path) — and we already hold it (below).
85
220
  // Re-entrant locking is not supported, so calling them would deadlock.
@@ -88,11 +223,23 @@ async function atomicUpdateJson(path, schema, updater) {
88
223
  const updated = updater(current);
89
224
  const parsed = schema.parse(updated);
90
225
  writeBusyHook?.(true);
91
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
226
+ const localRoot = root ? join(root, ".local") : undefined;
227
+ const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
228
+ const backupsRoot = localRoot ? join(localRoot, "backups") : dirname(path);
229
+ const rel = localRoot && path.startsWith(localRoot)
230
+ ? path.slice(localRoot.length).replace(/^\//, "")
231
+ : root && path.startsWith(root)
232
+ ? path.slice(root.length).replace(/^\//, "")
233
+ : basename(path);
234
+ const backupPath = join(backupsRoot, rel + ".bak");
235
+ const tmpName = rel ? rel.replace(/[/\\]/g, "--") + `.tmp.${process.pid}.${Date.now()}` : `${basename(path)}.tmp.${process.pid}.${Date.now()}`;
236
+ const tmp = join(tmpDir, tmpName);
92
237
  try {
238
+ await mkdir(tmpDir, { recursive: true });
93
239
  await writeFile(tmp, JSON.stringify(parsed, null, 2), "utf-8");
94
240
  try {
95
- await copyFile(path, `${path}.bak`);
241
+ await mkdir(dirname(backupPath), { recursive: true });
242
+ await copyFile(path, backupPath);
96
243
  }
97
244
  catch { }
98
245
  await rename(tmp, path);
@@ -271,20 +418,55 @@ export async function migrateToGlobalSequence(store) {
271
418
  });
272
419
  }
273
420
  async function readJson(path, schema) {
421
+ let backupTried = false;
422
+ let backupFailed = false;
423
+ let rawPreview;
274
424
  try {
275
425
  const raw = await readFile(path, "utf-8");
426
+ rawPreview = raw.slice(0, 240);
276
427
  return schema.parse(JSON.parse(raw));
277
428
  }
278
429
  catch (cause) {
279
430
  // Try the .bak backup before giving up (recover from external-write corruption).
431
+ backupTried = true;
280
432
  try {
281
433
  const bak = await readFile(`${path}.bak`, "utf-8");
434
+ rawPreview = bak.slice(0, 240);
282
435
  return schema.parse(JSON.parse(bak));
283
436
  }
284
437
  catch {
438
+ backupFailed = true;
285
439
  // fall through to original error
286
440
  }
287
- throw new PlanStoreError(`read failed: ${path}`, cause);
441
+ const details = {
442
+ path,
443
+ operation: "readJson",
444
+ backupTried,
445
+ backupFailed,
446
+ };
447
+ if (rawPreview != null)
448
+ details.rawPreview = rawPreview;
449
+ if (cause instanceof SyntaxError) {
450
+ const match = cause.message.match(/position\s+(\d+)/i);
451
+ const position = match && match[1] ? Number.parseInt(match[1], 10) : undefined;
452
+ if (rawPreview != null && position != null && position >= 0) {
453
+ const upTo = rawPreview.slice(0, position);
454
+ const line = upTo.split("\n").length;
455
+ const lastNL = upTo.lastIndexOf("\n");
456
+ const column = position - (lastNL >= 0 ? lastNL : 0);
457
+ details.jsonParseError = { message: cause.message, line, column };
458
+ }
459
+ else {
460
+ details.jsonParseError = { message: cause.message };
461
+ }
462
+ }
463
+ else if (cause instanceof ZodError) {
464
+ details.validationErrors = cause.issues.slice(0, 8).map((issue) => ({
465
+ path: issue.path.map((p) => (typeof p === "number" ? `[${p}]` : String(p))).join("."),
466
+ message: issue.message,
467
+ }));
468
+ }
469
+ throw new PlanStoreError(`read failed: ${path}`, cause, details);
288
470
  }
289
471
  }
290
472
  export class PlanStore {
@@ -336,23 +518,43 @@ export class PlanStore {
336
518
  normalizeTasks(tasks) {
337
519
  // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
338
520
  // Do NOT renumber here — renumbering would break references after deletions.
339
- return { tasks, changed: false };
521
+ const normalized = tasks.map((task) => task.description && !task.descriptionUpdatedAt
522
+ ? { ...task, descriptionUpdatedAt: task.createdAt }
523
+ : task);
524
+ return { tasks: normalized, changed: normalized.some((task, index) => task !== tasks[index]) };
525
+ }
526
+ /** Stamp description edits independently from generic entity mutations.
527
+ * Legacy entities cannot reveal their historical description-edit time, so
528
+ * their creation time is the earliest truthful fallback. */
529
+ stampDescriptionUpdatedAt(entity, previousDescription, timestamp) {
530
+ if (!entity.description)
531
+ return { ...entity, descriptionUpdatedAt: "" };
532
+ if (previousDescription === undefined || previousDescription !== entity.description) {
533
+ return { ...entity, descriptionUpdatedAt: timestamp };
534
+ }
535
+ return entity.descriptionUpdatedAt ? entity : { ...entity, descriptionUpdatedAt: entity.createdAt };
340
536
  }
341
537
  normalizeFeaturesDocument(doc) {
342
538
  // Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
343
- return { doc, changed: false };
539
+ const features = doc.features.map((feature) => feature.description && !feature.descriptionUpdatedAt
540
+ ? { ...feature, descriptionUpdatedAt: feature.createdAt }
541
+ : feature);
542
+ return { doc: { ...doc, features }, changed: features.some((feature, index) => feature !== doc.features[index]) };
344
543
  }
345
544
  normalizePhaseDocument(phase) {
346
545
  const { tasks, changed } = this.normalizeTasks(phase.tasks);
347
546
  const nextTaskIds = tasks.map((task) => task.id);
348
547
  const taskIdsChanged = nextTaskIds.length !== phase.taskIds.length || nextTaskIds.some((id, index) => id !== phase.taskIds[index]);
548
+ const descriptionUpdatedAt = phase.description && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
549
+ const descriptionTimestampChanged = descriptionUpdatedAt !== phase.descriptionUpdatedAt;
349
550
  return {
350
551
  phase: {
351
552
  ...phase,
553
+ descriptionUpdatedAt,
352
554
  tasks,
353
555
  taskIds: nextTaskIds,
354
556
  },
355
- changed: changed || taskIdsChanged,
557
+ changed: changed || taskIdsChanged || descriptionTimestampChanged,
356
558
  };
357
559
  }
358
560
  normalizeStructureSnapshot(featuresDoc, phases) {
@@ -361,7 +563,12 @@ export class PlanStore {
361
563
  const phasesByFeature = new Map();
362
564
  const orphanPhases = [];
363
565
  for (const phase of phases) {
364
- if (phase.featureId) {
566
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
567
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
568
+ phase.featureId = resolvedFeatureId;
569
+ changed = true;
570
+ }
571
+ if (phase.featureId && featuresDoc.features.some((feature) => feature.id === phase.featureId)) {
365
572
  const bucket = phasesByFeature.get(phase.featureId) ?? [];
366
573
  bucket.push(phase);
367
574
  phasesByFeature.set(phase.featureId, bucket);
@@ -504,7 +711,7 @@ export class PlanStore {
504
711
  }
505
712
  await mkdir(this.featuresDir(), { recursive: true });
506
713
  for (const feat of legacy.features) {
507
- await atomicWriteJson(this.featurePath(feat.id), feat);
714
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
508
715
  }
509
716
  await unlink(this.featuresPath()).catch(() => { });
510
717
  }
@@ -515,16 +722,81 @@ export class PlanStore {
515
722
  return join(this.phasesDir(), `${phaseId}.json`);
516
723
  }
517
724
  generatedDir() {
518
- return join(this.root, "generated");
725
+ return join(this.localRoot(), "generated");
519
726
  }
520
727
  codebasePath() {
521
728
  return join(this.root, "codebase.json");
522
729
  }
523
730
  resumePath() {
524
- return join(this.root, "resume.json");
731
+ return join(this.localRoot(), "resume.json");
525
732
  }
526
733
  activityPath() {
527
- return join(this.root, "activity.json");
734
+ return join(this.localRoot(), "activity.json");
735
+ }
736
+ localRoot() {
737
+ return join(this.root, ".local");
738
+ }
739
+ timestampPath() {
740
+ return join(this.localRoot(), "timestamp.json");
741
+ }
742
+ backupsDir() {
743
+ return join(this.localRoot(), "backups");
744
+ }
745
+ tmpDir() {
746
+ return join(this.localRoot(), "tmp");
747
+ }
748
+ handoffArchiveDir() {
749
+ return join(this.localRoot(), "handoff-archive");
750
+ }
751
+ /** One-time migration for plans created before .planner/.local/ existed.
752
+ * Moves a legacy root-level file into .local/ if the legacy file exists and
753
+ * the .local/ counterpart does not. Safe to call on every load. */
754
+ async migrateLegacyLocalFile(oldPath, newPath) {
755
+ try {
756
+ await access(oldPath);
757
+ }
758
+ catch {
759
+ return;
760
+ }
761
+ try {
762
+ await access(newPath);
763
+ return;
764
+ }
765
+ catch { }
766
+ await mkdir(dirname(newPath), { recursive: true });
767
+ await rename(oldPath, newPath);
768
+ }
769
+ async migrateLegacyGeneratedDir() {
770
+ const oldDir = join(this.root, "generated");
771
+ const newDir = this.generatedDir();
772
+ try {
773
+ await access(oldDir);
774
+ }
775
+ catch {
776
+ return;
777
+ }
778
+ try {
779
+ await access(newDir);
780
+ return;
781
+ }
782
+ catch { }
783
+ await rename(oldDir, newDir);
784
+ }
785
+ async migrateLegacyHandoffArchive() {
786
+ const oldDir = join(this.root, "handoff-archive");
787
+ const newDir = this.handoffArchiveDir();
788
+ try {
789
+ await access(oldDir);
790
+ }
791
+ catch {
792
+ return;
793
+ }
794
+ try {
795
+ await access(newDir);
796
+ return;
797
+ }
798
+ catch { }
799
+ await rename(oldDir, newDir);
528
800
  }
529
801
  // ── Init ─────────────────────────────────────────────────────────────
530
802
  async init(projectName) {
@@ -534,7 +806,11 @@ export class PlanStore {
534
806
  await mkdir(this.root, { recursive: true });
535
807
  await mkdir(this.phasesDir(), { recursive: true });
536
808
  await mkdir(this.featuresDir(), { recursive: true });
809
+ await mkdir(this.localRoot(), { recursive: true });
810
+ await mkdir(this.backupsDir(), { recursive: true });
811
+ await mkdir(this.tmpDir(), { recursive: true });
537
812
  await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
813
+ await mkdir(this.handoffArchiveDir(), { recursive: true });
538
814
  await mkdir(join(this.root, "schema"), { recursive: true });
539
815
  await mkdir(join(this.root, "adapters"), { recursive: true });
540
816
  const manifest = {
@@ -544,7 +820,8 @@ export class PlanStore {
544
820
  createdAt: nowISO(),
545
821
  updatedAt: nowISO(),
546
822
  };
547
- await atomicWriteJson(this.manifestPath(), manifest);
823
+ await atomicWriteJson(this.manifestPath(), manifest, this.root);
824
+ await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
548
825
  await this.saveProject({
549
826
  name: projectName,
550
827
  goal: "",
@@ -567,6 +844,7 @@ export class PlanStore {
567
844
  nextFeatureNumber: 1,
568
845
  nextPhaseNumber: 1,
569
846
  nextTaskNumber: 1,
847
+ workDeviations: [],
570
848
  });
571
849
  await this.saveRequirements({ requirements: [] });
572
850
  await this.saveFeatures({ features: [] });
@@ -594,7 +872,7 @@ export class PlanStore {
594
872
  "- `project.json` — scope, rules, stack, tools",
595
873
  "- `requirements.json` — requirements and macro-tasks",
596
874
  "- `phases/` — one JSON file per phase",
597
- "- `generated/` — auto-generated markdown views",
875
+ "- `generated/` — auto-generated markdown views (under `.local/`)",
598
876
  "- `schema/plan.schema.json` — JSON Schema for tooling",
599
877
  ].join("\n");
600
878
  await writeFile(join(this.root, "README.md"), readme, "utf-8");
@@ -604,16 +882,29 @@ export class PlanStore {
604
882
  // - resume.json: per-session resume focus + the machine-local guard-bypass
605
883
  // timestamp (guardBypassUntil must NOT leak into git/other clones)
606
884
  // - generated/: auto-regenerated markdown views (derived from JSON; churn)
607
- await writeFile(join(this.root, ".gitignore"), [
608
- "# Agent Plan transient/derived files — do not track",
609
- "*.bak",
610
- "*.tmp.*",
611
- "resume.json",
612
- "resume.*.json",
613
- "generated/",
614
- "handoff-archive/",
615
- "",
616
- ].join("\n"), "utf-8");
885
+ await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
886
+ }
887
+ /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
888
+ * canonical transient/derived patterns). Projects initialized before the
889
+ * `.local/` move either have no `.planner/.gitignore` or one with stale
890
+ * root-level patterns. This upgrades them safely on load and on repair.
891
+ * Returns true if the file was (re)written. Safe to call on every load. */
892
+ async ensureGitignore() {
893
+ const gi = join(this.root, ".gitignore");
894
+ try {
895
+ const existing = await readFile(gi, "utf8").catch(() => null);
896
+ // Up to date iff it contains all canonical patterns (P042 spec):
897
+ // .local/ (transients), *.bak (crash backups), *.tmp.* (atomic-write
898
+ // temp files), generated/ (legacy dir).
899
+ if (existing != null && existing.includes(".local/") && existing.includes("*.bak") && existing.includes("*.tmp.*") && existing.includes("generated/")) {
900
+ return false;
901
+ }
902
+ await writeFile(gi, PLANNER_GITIGNORE, "utf-8");
903
+ return true;
904
+ }
905
+ catch {
906
+ return false;
907
+ }
617
908
  }
618
909
  async exists() {
619
910
  try {
@@ -625,29 +916,71 @@ export class PlanStore {
625
916
  }
626
917
  }
627
918
  // ── Loaders ──────────────────────────────────────────────────────────
919
+ /** Read-only manifest load. Upgrading legacy `.local` state is explicit
920
+ * maintenance (`repair`), never an incidental side effect of opening a plan. */
628
921
  async loadManifest() {
629
- return readJson(this.manifestPath(), ManifestSchema);
922
+ const manifest = await readJson(this.manifestPath(), ManifestSchema);
923
+ const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
924
+ return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
630
925
  }
631
926
  async loadProject() {
632
927
  return readJson(this.projectPath(), ProjectSchema);
633
928
  }
634
929
  /**
635
- * Allocate the next global sequence number for a feature/phase/task.
636
- * Reads the monotonic counter from project.json, increments it, persists,
637
- * and returns the allocated number. MUST be called within withFeatureLock
638
- * (adapters create entities inside a lock) so the counter is race-free.
639
- * The counter never reuses a number deletions leave gaps (by design:
640
- * stable references survive deletion).
930
+ * Reserve immutable human identifiers without touching tracked `project.json`.
931
+ * Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
932
+ * the same cross-process lock used for atomic writes. The registry reserves
933
+ * numbers/short IDs before an entity file is written, so parallel branches
934
+ * cannot allocate the same F/P/T or shortId. Existing entities are never
935
+ * rewritten; cross-clone coordination requires a shared allocator service.
641
936
  */
642
- async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber"); }
643
- async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber"); }
644
- async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber"); }
645
- async allocSeqNumber(key) {
937
+ async allocateEntityIdentity(kind, entityId) {
646
938
  const project = await this.loadProject();
647
- const n = project[key];
648
- project[key] = n + 1;
649
- await this.saveProject(project);
650
- return n;
939
+ const manifest = await this.loadManifest();
940
+ const commonGitDir = await gitCommonDirFor(this.root);
941
+ const registryPath = commonGitDir
942
+ ? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
943
+ : join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
944
+ return withWriteLock(registryPath, async () => {
945
+ const registry = AllocationRegistrySchema.parse(await readFile(registryPath, "utf8")
946
+ .then(JSON.parse)
947
+ .catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
948
+ if (registry.projectId !== manifest.projectId)
949
+ throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
950
+ const prior = registry.allocations.find((entry) => entry.kind === kind && entry.entityId === entityId);
951
+ if (prior)
952
+ return { number: prior.number, shortId: prior.shortId };
953
+ const phases = await this.loadAllPhases();
954
+ const features = (await this.loadFeatures()).features;
955
+ const canonical = kind === "feature"
956
+ ? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
957
+ : kind === "phase"
958
+ ? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
959
+ : phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })));
960
+ const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
961
+ const counter = kind === "feature" ? project.nextFeatureNumber : kind === "phase" ? project.nextPhaseNumber : project.nextTaskNumber;
962
+ let number = Math.max(1, counter);
963
+ while (usedNumbers.has(number))
964
+ number += 1;
965
+ const allShortIds = new Set([
966
+ ...features.map((feature) => feature.shortId),
967
+ ...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
968
+ ...registry.allocations.map((entry) => entry.shortId),
969
+ ].filter(Boolean));
970
+ const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
971
+ registry.allocations.push(allocation);
972
+ await writeRegistry(registryPath, registry);
973
+ return { number: allocation.number, shortId: allocation.shortId };
974
+ });
975
+ }
976
+ /** Compatibility helpers. New callers should allocate the number and shortId
977
+ * together with allocateEntityIdentity so reservation cannot be split. */
978
+ async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
979
+ async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
980
+ async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
981
+ async allocateLegacyNumber(kind) {
982
+ const id = `legacy-${kind}-${randomUUID()}`;
983
+ return (await this.allocateEntityIdentity(kind, id)).number;
651
984
  }
652
985
  async loadPhase(phaseId) {
653
986
  const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
@@ -700,7 +1033,10 @@ export class PlanStore {
700
1033
  const raws = await this.loadRawFeatures();
701
1034
  const phases = await this.loadAllPhases();
702
1035
  const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
703
- return this.normalizeFeaturesDocument({ features }).doc;
1036
+ // Keep legacy feature descriptions displayable without rewriting on read.
1037
+ // Phase/task reads already apply the equivalent creation-date fallback.
1038
+ const normalizedFeatures = this.normalizeFeaturesDocument({ features }).doc.features;
1039
+ return this.normalizeStructureSnapshot({ features: normalizedFeatures }, phases).features;
704
1040
  }
705
1041
  async loadCodebaseProfile() {
706
1042
  try {
@@ -712,10 +1048,11 @@ export class PlanStore {
712
1048
  }
713
1049
  async saveCodebaseProfile(profile) {
714
1050
  const parsed = CodebaseProfileSchema.parse(profile);
715
- await atomicWriteJson(this.codebasePath(), parsed);
716
- await this.touchManifest();
1051
+ await atomicWriteJson(this.codebasePath(), parsed, this.root);
1052
+ await this.touchTimestamp();
717
1053
  }
718
1054
  async loadResume() {
1055
+ await this.migrateLegacyLocalFile(join(this.root, "resume.json"), this.resumePath());
719
1056
  try {
720
1057
  return await readJson(this.resumePath(), ResumeFocusSchema);
721
1058
  }
@@ -736,8 +1073,8 @@ export class PlanStore {
736
1073
  : (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
737
1074
  };
738
1075
  const parsed = ResumeFocusSchema.parse(withTs);
739
- await atomicWriteJson(this.resumePath(), parsed);
740
- await this.touchManifest();
1076
+ await atomicWriteJson(this.resumePath(), parsed, this.root);
1077
+ await this.touchTimestamp();
741
1078
  }
742
1079
  /**
743
1080
  * Authorize a temporary guard bypass so edit/write tools may proceed even
@@ -783,6 +1120,7 @@ export class PlanStore {
783
1120
  return until > Date.now();
784
1121
  }
785
1122
  async loadActivityLog() {
1123
+ await this.migrateLegacyLocalFile(join(this.root, "activity.json"), this.activityPath());
786
1124
  try {
787
1125
  return await readJson(this.activityPath(), ActivityLogSchema);
788
1126
  }
@@ -798,8 +1136,8 @@ export class PlanStore {
798
1136
  // Cap to last 200 entries
799
1137
  if (log.entries.length > 200)
800
1138
  log.entries = log.entries.slice(-200);
801
- await atomicWriteJson(this.activityPath(), { entries: log.entries });
802
- await this.touchManifest();
1139
+ await atomicWriteJson(this.activityPath(), { entries: log.entries }, this.root);
1140
+ await this.touchTimestamp();
803
1141
  return entry;
804
1142
  }
805
1143
  /** Derive an up-to-date resume focus from the current workspace state. */
@@ -831,6 +1169,33 @@ export class PlanStore {
831
1169
  return { requirements: [] };
832
1170
  }
833
1171
  }
1172
+ async linkedRequirementsForPhase(phaseId) {
1173
+ const requirements = await this.loadRequirements();
1174
+ return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
1175
+ }
1176
+ /** Requirements linked to any phase belonging to a feature, deduplicated by ID. */
1177
+ async linkedRequirementsForFeature(featureId) {
1178
+ const [phases, requirements] = await Promise.all([this.loadAllPhases(), this.loadRequirements()]);
1179
+ const phaseIds = new Set(phases.filter((phase) => phase.featureId === featureId).map((phase) => phase.id));
1180
+ return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.some((phaseId) => phaseIds.has(phaseId)));
1181
+ }
1182
+ async loadPhaseWithRequirements(phaseId) {
1183
+ const [phase, linkedRequirements] = await Promise.all([
1184
+ this.loadPhase(phaseId),
1185
+ this.linkedRequirementsForPhase(phaseId),
1186
+ ]);
1187
+ return { ...phase, linkedRequirements };
1188
+ }
1189
+ async loadAllPhasesWithRequirements() {
1190
+ const [phases, requirements] = await Promise.all([
1191
+ this.loadAllPhases(),
1192
+ this.loadRequirements(),
1193
+ ]);
1194
+ return phases.map((phase) => ({
1195
+ ...phase,
1196
+ linkedRequirements: requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phase.id)),
1197
+ }));
1198
+ }
834
1199
  async loadAllPhases() {
835
1200
  const { readdir } = await import("node:fs/promises");
836
1201
  let files;
@@ -861,6 +1226,22 @@ export class PlanStore {
861
1226
  return left.createdAt.localeCompare(right.createdAt);
862
1227
  });
863
1228
  }
1229
+ /** Derive the parent display snapshot for a phase from its tasks' canonical
1230
+ * statuses. Pure, non-persisting. */
1231
+ async loadPhaseDisplay(phaseId) {
1232
+ const phase = await this.loadPhase(phaseId);
1233
+ const childStatuses = phase.tasks.map((t) => fromCanonicalStatus(t.status));
1234
+ return deriveParentDisplay(childStatuses);
1235
+ }
1236
+ /** Derive the parent display snapshot for a feature from its phases' DERIVED
1237
+ * canonical statuses (each phase status is derived from its tasks at read
1238
+ * time, then mapped via fromCanonicalStatus). Pure, non-persisting. */
1239
+ async loadFeatureDisplay(featureId) {
1240
+ const phases = await this.loadAllPhases();
1241
+ const featurePhases = phases.filter((p) => p.featureId === featureId);
1242
+ const childStatuses = featurePhases.map((p) => fromCanonicalStatus(p.status));
1243
+ return deriveParentDisplay(childStatuses);
1244
+ }
864
1245
  async loadAll() {
865
1246
  const [manifest, project, requirements, phases] = await Promise.all([
866
1247
  this.loadManifest(),
@@ -870,7 +1251,8 @@ export class PlanStore {
870
1251
  ]);
871
1252
  const rawFeatures = await this.loadRawFeatures();
872
1253
  const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
873
- return { manifest, project, requirements, phases, features: { features } };
1254
+ const normalized = this.normalizeStructureSnapshot({ features }, phases);
1255
+ return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
874
1256
  }
875
1257
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
876
1258
  * dangling feature.phaseIds references. Idempotent. */
@@ -910,10 +1292,7 @@ export class PlanStore {
910
1292
  task.phaseId = newId;
911
1293
  }
912
1294
  await this.savePhase(phase);
913
- try {
914
- await unlink(this.phasePath(oldId));
915
- }
916
- catch { }
1295
+ await this.unlinkPhaseFiles(oldId);
917
1296
  renamed += 1;
918
1297
  }
919
1298
  // Repair feature.phaseIds: replace legacy refs with new ids, drop dangling ones.
@@ -1055,7 +1434,7 @@ export class PlanStore {
1055
1434
  const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
1056
1435
  sortedFeatures.forEach((f, index) => {
1057
1436
  if (!f.shortId) {
1058
- f.shortId = createShortId(existing);
1437
+ f.shortId = createShortId(existing, `feature:${f.number}:${f.id}`);
1059
1438
  existing.add(f.shortId);
1060
1439
  shortIdsAssigned += 1;
1061
1440
  featuresDirty = true;
@@ -1074,7 +1453,7 @@ export class PlanStore {
1074
1453
  for (const phase of phases) {
1075
1454
  let phaseDirty = false;
1076
1455
  if (!phase.shortId) {
1077
- phase.shortId = createShortId(existing);
1456
+ phase.shortId = createShortId(existing, `phase:${phase.number}:${phase.id}`);
1078
1457
  existing.add(phase.shortId);
1079
1458
  shortIdsAssigned += 1;
1080
1459
  phaseDirty = true;
@@ -1088,7 +1467,7 @@ export class PlanStore {
1088
1467
  const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
1089
1468
  sortedTasks.forEach((t, index) => {
1090
1469
  if (!t.shortId) {
1091
- t.shortId = createShortId(existing);
1470
+ t.shortId = createShortId(existing, `task:${t.number}:${t.id}`);
1092
1471
  existing.add(t.shortId);
1093
1472
  shortIdsAssigned += 1;
1094
1473
  phaseDirty = true;
@@ -1128,17 +1507,38 @@ export class PlanStore {
1128
1507
  /** Repair dangling references and report integrity. One-shot maintenance op. */
1129
1508
  async repair() {
1130
1509
  return this.runAsBatch(async () => {
1510
+ // Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
1511
+ // upgrades plans initialized before the .local/ move.
1512
+ await this.ensureGitignore().catch(() => { });
1131
1513
  const migrated = await this.migratePhaseIds();
1514
+ await this.repairPhaseFeatureRefs();
1132
1515
  const backfill = await this.ensureShortIdsAndPriority();
1133
1516
  // Rebuild phase containment from each task's own phaseId. Heals plans
1134
1517
  // corrupted by the migrateToGlobalSequence index-mismatch bug (core
1135
1518
  // <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
1136
1519
  const containment = await this.rebuildContainment();
1520
+ const handoffs = { archived: await this.archiveStaleHandoffs() };
1137
1521
  const integrity = await this.validateIntegrity();
1138
1522
  await this.writeGenerated();
1139
- return { migrated, backfill, containment, integrity };
1523
+ return { migrated, backfill, containment, handoffs, integrity };
1140
1524
  });
1141
1525
  }
1526
+ async repairPhaseFeatureRefs() {
1527
+ const features = await this.loadRawFeatures();
1528
+ const phases = await this.loadAllPhases();
1529
+ let changed = 0;
1530
+ for (const phase of phases) {
1531
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1532
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
1533
+ await this.savePhase({ ...phase, featureId: resolvedFeatureId });
1534
+ changed += 1;
1535
+ }
1536
+ }
1537
+ if (changed > 0) {
1538
+ await this.updateFeatures((doc) => doc);
1539
+ }
1540
+ return changed;
1541
+ }
1142
1542
  /** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
1143
1543
  async validateIntegrity() {
1144
1544
  const phases = await this.loadAllPhases();
@@ -1174,6 +1574,12 @@ export class PlanStore {
1174
1574
  const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1175
1575
  return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
1176
1576
  }
1577
+ /** A handoff remains active while any task needs work. Its automatic end-of-
1578
+ * phase lifecycle is deliberately narrower than canonical display status:
1579
+ * every task must be done or canceled (not merely derived "rejected"). */
1580
+ hasCompletedHandoffLifecycle(tasks) {
1581
+ return tasks.length > 0 && tasks.every((task) => task.status === "done" || task.status === "canceled");
1582
+ }
1177
1583
  derivePhaseStatus(tasks) {
1178
1584
  if (tasks.length === 0)
1179
1585
  return "draft";
@@ -1184,18 +1590,28 @@ export class PlanStore {
1184
1590
  return "rejected";
1185
1591
  if (meaningful.every((s) => s === "done"))
1186
1592
  return "done";
1187
- // Lifecycle truth: any progress (active work OR partial completion)
1188
- // in-progress, until fully done. This is what prevents a single
1189
- // blocked/waiting/deferred task from poisoning the parent when there is
1190
- // substantial done or in-progress work (the long-standing rollup bug).
1191
- if (meaningful.some((s) => s === "in-progress") || meaningful.some((s) => s === "done"))
1593
+ const hasDone = meaningful.some((s) => s === "done");
1594
+ const hasActive = meaningful.some((s) => s === "in-progress");
1595
+ const hasPlanned = meaningful.some((s) => s === "planned");
1596
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1597
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1598
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1599
+ if (hasActive)
1600
+ return "in-progress";
1601
+ // If completed work exists and the ONLY remaining meaningful work is deferred,
1602
+ // surface deferred instead of implying active execution.
1603
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1604
+ return "deferred";
1605
+ // Partial completion with remaining planned/blocked/waiting work still means
1606
+ // the phase has genuinely started and is not terminal yet.
1607
+ if (hasDone)
1192
1608
  return "in-progress";
1193
1609
  // No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
1194
- if (meaningful.some((s) => s === "blocked"))
1610
+ if (hasBlocked)
1195
1611
  return "blocked";
1196
- if (meaningful.some((s) => s === "waiting"))
1612
+ if (hasWaiting)
1197
1613
  return "waiting";
1198
- if (meaningful.some((s) => s === "deferred"))
1614
+ if (hasDeferred)
1199
1615
  return "deferred";
1200
1616
  return "planned";
1201
1617
  }
@@ -1210,17 +1626,25 @@ export class PlanStore {
1210
1626
  return "rejected";
1211
1627
  if (meaningful.every((s) => s === "done"))
1212
1628
  return "done";
1213
- // Any progress (an active phase, or a partially-complete done phase)
1214
- // in-progress. Prevents a single stalled phase from poisoning the feature
1215
- // when other phases have done/in-progress work.
1216
- if (meaningful.some((s) => s === "discovery" || s === "in-progress") || meaningful.some((s) => s === "done"))
1629
+ const hasDone = meaningful.some((s) => s === "done");
1630
+ const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
1631
+ const hasPlanned = meaningful.some((s) => s === "planned");
1632
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1633
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1634
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1635
+ if (hasActive)
1636
+ return "in-progress";
1637
+ // Same rule as phases: done + deferred-only remainder is deferred, not active.
1638
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1639
+ return "deferred";
1640
+ if (hasDone)
1217
1641
  return "in-progress";
1218
1642
  // No progress at all ⇒ surface the stall / not-started state.
1219
- if (meaningful.some((s) => s === "blocked"))
1643
+ if (hasBlocked)
1220
1644
  return "blocked";
1221
- if (meaningful.some((s) => s === "waiting"))
1645
+ if (hasWaiting)
1222
1646
  return "waiting";
1223
- if (meaningful.some((s) => s === "deferred"))
1647
+ if (hasDeferred)
1224
1648
  return "deferred";
1225
1649
  return "planned";
1226
1650
  }
@@ -1230,33 +1654,112 @@ export class PlanStore {
1230
1654
  // (serve.ts, adapters) that invoke it after mutations.
1231
1655
  return [];
1232
1656
  }
1233
- /** Auto-clear a phase's handoff when its DERIVED status is done. Status itself
1234
- * is no longer persisted (derived on read), so the only remaining side effect
1235
- * of a task→done transition is clearing a stale handoff on a completed phase.
1236
- * Returns the composite ref of the phase if its handoff was cleared, else null. */
1657
+ /** Auto-clear a phase's handoff only when every task is terminal as done or
1658
+ * canceled. This covers an all-canceled phase too, whose legacy canonical
1659
+ * derived status is "rejected". Returns the composite ref when cleared. */
1237
1660
  async syncTaskStatusRollup(phaseId) {
1238
1661
  const phase = await this.loadPhase(phaseId);
1239
1662
  let cleared = null;
1240
- if (phase.status === "done" && phase.handoff !== "") {
1663
+ if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff !== "") {
1241
1664
  await this.clearPhaseHandoff(phaseId, "phase-done");
1242
1665
  const features = await this.loadFeatures();
1243
1666
  const feature = features.features.find((f) => f.id === phase.featureId);
1244
1667
  cleared = formatPhaseRef(phase.number, feature?.number);
1245
1668
  }
1669
+ // Append a statusLog entry to the phase when its DERIVED status changed
1670
+ // (audit trail; status itself is NOT persisted). Idempotent: only appends
1671
+ // when the new derived status differs from the last recorded toStatus.
1672
+ await this.#appendPhaseStatusLog(phaseId);
1673
+ // Roll up to the parent feature's statusLog too.
1674
+ if (phase.featureId)
1675
+ await this.#appendFeatureStatusLog(phase.featureId);
1246
1676
  await this.refreshResume();
1247
1677
  return cleared;
1248
1678
  }
1679
+ /** Append a PhaseStatusLogEntry to the phase when its derived status changed
1680
+ * vs. the last recorded toStatus (baseline "draft" when empty, matching the
1681
+ * phase-creation literal). Idempotent across repeated reads of the same state. */
1682
+ async #appendPhaseStatusLog(phaseId) {
1683
+ await this.updatePhase(phaseId, (p) => {
1684
+ const last = p.statusLog.at(-1)?.toStatus ?? "draft";
1685
+ if (p.status !== last) {
1686
+ p.statusLog = [...p.statusLog, {
1687
+ id: createStatusLogEntryId(),
1688
+ date: nowISO(),
1689
+ fromStatus: last,
1690
+ toStatus: p.status,
1691
+ title: `${last} → ${p.status}`,
1692
+ description: "",
1693
+ }];
1694
+ }
1695
+ return p;
1696
+ });
1697
+ }
1698
+ /** Append a StatusLogEntry to the feature when its derived status changed
1699
+ * vs. the last recorded toStatus (baseline "planned" when empty, matching
1700
+ * the feature-creation/empty-phases derivation). Idempotent. */
1701
+ async #appendFeatureStatusLog(featureId) {
1702
+ const features = await this.loadFeatures();
1703
+ const feature = features.features.find((f) => f.id === featureId);
1704
+ if (!feature)
1705
+ return;
1706
+ const last = feature.statusLog.at(-1)?.toStatus ?? "planned";
1707
+ if (feature.status !== last) {
1708
+ await this.updateFeatures((doc) => {
1709
+ const target = doc.features.find((f) => f.id === featureId);
1710
+ if (target && target.statusLog.at(-1)?.toStatus !== feature.status) {
1711
+ const baseline = target.statusLog.at(-1)?.toStatus ?? "planned";
1712
+ target.statusLog = [...target.statusLog, {
1713
+ id: createStatusLogEntryId(),
1714
+ date: nowISO(),
1715
+ fromStatus: baseline,
1716
+ toStatus: feature.status,
1717
+ title: `${baseline} → ${feature.status}`,
1718
+ description: "",
1719
+ }];
1720
+ }
1721
+ return doc;
1722
+ });
1723
+ }
1724
+ }
1249
1725
  // ── Savers ───────────────────────────────────────────────────────────
1250
1726
  async updateProject(updater) {
1251
- const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
1727
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
1252
1728
  await this.maybeAutoSync();
1253
1729
  return updated;
1254
1730
  }
1731
+ /** Persist an explicitly approved work deviation without coupling it to a harness. */
1732
+ async addWorkDeviation(deviation) {
1733
+ return this.updateProject((project) => ({
1734
+ ...project,
1735
+ workDeviations: [...project.workDeviations, deviation],
1736
+ }));
1737
+ }
1738
+ /** Mark an approved/active deviation as resolved or canceled while retaining its audit record. */
1739
+ async setWorkDeviationState(id, state, timestamp = nowISO()) {
1740
+ return this.updateProject((project) => ({
1741
+ ...project,
1742
+ workDeviations: project.workDeviations.map((deviation) => deviation.id !== id ? deviation : {
1743
+ ...deviation,
1744
+ state,
1745
+ activatedAt: state === "active" ? timestamp : deviation.activatedAt,
1746
+ resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
1747
+ }),
1748
+ }));
1749
+ }
1255
1750
  async updateFeatures(updater) {
1256
1751
  const updated = await this.withFeaturesLock(async () => {
1257
1752
  await this.migrateLegacy();
1258
1753
  const current = await this.loadFeatures();
1259
- const upd = this.normalizeFeaturesDocument(updater(current)).doc;
1754
+ // Updaters commonly mutate `current` in place, so snapshot descriptions
1755
+ // before invoking them rather than comparing object references afterward.
1756
+ const previousDescriptions = new Map(current.features.map((feature) => [feature.id, feature.description]));
1757
+ const timestamp = nowISO();
1758
+ const candidate = updater(current);
1759
+ const stamped = {
1760
+ features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
1761
+ };
1762
+ const upd = this.normalizeFeaturesDocument(stamped).doc;
1260
1763
  await this.saveFeaturesRaw(upd);
1261
1764
  return upd;
1262
1765
  });
@@ -1264,22 +1767,26 @@ export class PlanStore {
1264
1767
  return updated;
1265
1768
  }
1266
1769
  async updateRequirements(updater) {
1267
- const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
1770
+ const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
1268
1771
  await this.maybeAutoSync();
1269
1772
  return updated;
1270
1773
  }
1271
1774
  async saveProject(project) {
1272
1775
  const parsed = ProjectSchema.parse(project);
1273
- await atomicWriteJson(this.projectPath(), parsed);
1274
- await this.touchManifest();
1776
+ await atomicWriteJson(this.projectPath(), parsed, this.root);
1777
+ await this.touchTimestamp();
1275
1778
  await this.maybeAutoSync();
1276
1779
  }
1277
1780
  async saveFeatures(features) {
1278
1781
  await this.withFeaturesLock(async () => {
1279
1782
  await this.migrateLegacy();
1280
- await this.saveFeaturesRaw(features);
1783
+ const previousDescriptions = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.description]));
1784
+ const timestamp = nowISO();
1785
+ await this.saveFeaturesRaw({
1786
+ features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
1787
+ });
1281
1788
  });
1282
- await this.touchManifest();
1789
+ await this.touchTimestamp();
1283
1790
  await this.maybeAutoSync();
1284
1791
  }
1285
1792
  /** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
@@ -1288,7 +1795,7 @@ export class PlanStore {
1288
1795
  await mkdir(this.featuresDir(), { recursive: true });
1289
1796
  const wantIds = new Set(parsed.features.map((f) => f.id));
1290
1797
  for (const feat of parsed.features) {
1291
- await atomicWriteJson(this.featurePath(feat.id), feat);
1798
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
1292
1799
  }
1293
1800
  // Orphan reconcile: remove feature files no longer in the document.
1294
1801
  try {
@@ -1310,37 +1817,73 @@ export class PlanStore {
1310
1817
  async saveFeature(feature) {
1311
1818
  await this.withFeaturesLock(async () => {
1312
1819
  await this.migrateLegacy();
1820
+ const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
1313
1821
  await mkdir(this.featuresDir(), { recursive: true });
1314
- const parsed = FeatureSchema.parse(feature);
1315
- await atomicWriteJson(this.featurePath(parsed.id), parsed);
1822
+ const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, nowISO()));
1823
+ await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
1316
1824
  });
1317
- await this.touchManifest();
1825
+ await this.touchTimestamp();
1318
1826
  await this.maybeAutoSync();
1319
1827
  }
1320
1828
  async saveRequirements(reqs) {
1321
1829
  const parsed = RequirementsDocumentSchema.parse(reqs);
1322
- await atomicWriteJson(this.requirementsPath(), parsed);
1323
- await this.touchManifest();
1830
+ await atomicWriteJson(this.requirementsPath(), parsed, this.root);
1831
+ await this.touchTimestamp();
1324
1832
  }
1325
1833
  async savePhase(phase) {
1326
- const parsed = PhaseSchema.parse(this.normalizePhaseDocument(phase).phase);
1834
+ const previous = await this.loadPhase(phase.id).catch(() => null);
1835
+ const timestamp = nowISO();
1836
+ const features = await this.loadRawFeatures();
1837
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1838
+ // Referential integrity: if a featureId is present but cannot be resolved
1839
+ // to a known feature, REJECT — never persist an orphan featureId.
1840
+ // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
1841
+ // legacy migrations, repair, and feature-delete (unlink) can persist phases
1842
+ // without a feature yet. The hard "featureId required" gate lives at the
1843
+ // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
1844
+ // planner-task-add), which is where user-facing creation happens.
1845
+ if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
1846
+ 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.`);
1847
+ }
1848
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
1849
+ ? { ...phase, featureId: resolvedFeatureId }
1850
+ : phase;
1851
+ const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
1852
+ const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, timestamp);
1853
+ timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
1854
+ const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
1327
1855
  await mkdir(this.phasesDir(), { recursive: true });
1328
- await atomicWriteJson(this.phasePath(parsed.id), parsed);
1329
- await this.touchManifest();
1856
+ await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
1857
+ await this.touchTimestamp();
1330
1858
  await this.maybeAutoSync();
1331
1859
  }
1332
1860
  /** Atomic read-modify-write on a single phase file. Serializes concurrent
1333
1861
  * task_create / phase_update calls on the SAME phaseId so batch operations
1334
1862
  * don't lose tasks (last-write-wins race condition). */
1335
1863
  async updatePhase(phaseId, updater) {
1864
+ const features = await this.loadRawFeatures();
1336
1865
  // Augment the raw (on-disk) phase with its DERIVED status before handing it
1337
1866
  // to the updater, so updaters that read 'phase.status' see the truth. The
1338
1867
  // returned object's 'status' is stripped by PhaseSchema.parse (status is
1339
1868
  // not persisted); the return value is re-derived for the caller.
1340
1869
  const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
1341
1870
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
1871
+ // The updater may mutate `current`, therefore snapshot descriptions first.
1872
+ const previousDescription = current.description;
1873
+ const previousTaskDescriptions = new Map(current.tasks.map((task) => [task.id, task.description]));
1874
+ const timestamp = nowISO();
1342
1875
  const next = updater(current);
1343
- return this.normalizePhaseDocument(next).phase;
1876
+ const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, timestamp);
1877
+ timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
1878
+ const resolvedFeatureId = resolveStoredFeatureId(features, timestamped.featureId);
1879
+ // Referential integrity: reject orphan featureId.
1880
+ if (timestamped.featureId && timestamped.featureId.trim() && !resolvedFeatureId) {
1881
+ throw new PlanStoreError(`Cannot update phase: featureId "${timestamped.featureId}" does not match any existing feature.`);
1882
+ }
1883
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== timestamped.featureId
1884
+ ? { ...timestamped, featureId: resolvedFeatureId }
1885
+ : timestamped;
1886
+ return this.normalizePhaseDocument(normalizedInput).phase;
1344
1887
  });
1345
1888
  await this.maybeAutoSync();
1346
1889
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
@@ -1350,21 +1893,23 @@ export class PlanStore {
1350
1893
  async getPhaseHandoff(phaseId) {
1351
1894
  return (await this.loadPhase(phaseId)).handoff;
1352
1895
  }
1353
- /** Set the handoff text for a phase + stamp handoffUpdatedAt. Atomic per-file
1354
- * update via updatePhase (so the web UI refreshes via maybeAutoSync). */
1896
+ /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
1897
+ * canceled phase cannot receive a new operational handoff. Replacing an
1898
+ * existing handoff archives the previous content as `superseded` first. */
1355
1899
  async setPhaseHandoff(phaseId, text) {
1900
+ const phase = await this.loadPhase(phaseId);
1901
+ const normalized = text.trim();
1902
+ if (phase.status === "done" || phase.status === "canceled") {
1903
+ throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; completed phases have no pending handoff.`);
1904
+ }
1905
+ if (phase.handoff && normalized && phase.handoff !== normalized) {
1906
+ await this.clearPhaseHandoff(phaseId, "superseded");
1907
+ }
1356
1908
  const now = new Date().toISOString();
1357
- await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
1358
- }
1359
- /** Directory where cleared handoff content is archived as .md files
1360
- * (gitignored). Keeps the phase JSON lean while making past handoffs
1361
- * recoverable + human-readable. */
1362
- handoffArchiveDir() {
1363
- return join(this.root, "handoff-archive");
1909
+ await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
1364
1910
  }
1365
1911
  /** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
1366
- * Does NOT clear the handoff content is kept until a task starts or the
1367
- * phase completes, so a restart between read and resume does not lose it. */
1912
+ * Does NOT clear it: read/load/show are non-mutating resume operations. */
1368
1913
  async markHandoffRead(phaseId) {
1369
1914
  await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
1370
1915
  }
@@ -1385,9 +1930,11 @@ export class PlanStore {
1385
1930
  return { imported: false };
1386
1931
  }
1387
1932
  const phases = await this.loadAllPhases();
1388
- const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
1933
+ const target = phases.find((p) => p.status === "in-progress")
1934
+ ?? phases.find((p) => p.status !== "done" && p.status !== "canceled")
1935
+ ?? null;
1389
1936
  if (!target)
1390
- return { imported: false }; // no phases yet — leave file for a later run
1937
+ return { imported: false }; // no non-completed phase — leave file for a later run
1391
1938
  if ((target.handoff ?? "") === "") {
1392
1939
  await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
1393
1940
  await this.updatePhase(target.id, (p) => ({
@@ -1405,8 +1952,9 @@ export class PlanStore {
1405
1952
  * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
1406
1953
  * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
1407
1954
  * left unchanged as an audit trail. If the handoff is empty, this is a no-op.
1408
- * reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
1955
+ * reason: "phase-done" | "manual" | "superseded" | "imported". */
1409
1956
  async clearPhaseHandoff(phaseId, reason = "manual") {
1957
+ await this.migrateLegacyHandoffArchive();
1410
1958
  const phase = await this.loadPhase(phaseId).catch(() => null);
1411
1959
  if (!phase || phase.handoff === "")
1412
1960
  return; // nothing to archive
@@ -1416,28 +1964,52 @@ export class PlanStore {
1416
1964
  await mkdir(archiveDir, { recursive: true }).catch(() => { });
1417
1965
  const fileName = `${phaseId}-${safeTs}.md`;
1418
1966
  const filePath = join(archiveDir, fileName);
1419
- await atomicWriteText(filePath, phase.handoff);
1967
+ await atomicWriteText(filePath, phase.handoff, this.root);
1420
1968
  const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
1421
1969
  // Cap history at 5: prepend new entry, drop oldest (and delete its file).
1422
1970
  const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
1423
1971
  const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
1424
1972
  for (const d of dropped) {
1425
- if (d?.file)
1426
- await unlink(join(this.root, d.file)).catch(() => { });
1973
+ if (!d?.file)
1974
+ continue;
1975
+ // Legacy entries used `.planner/handoff-archive/...`; new entries use `.planner/.local/handoff-archive/...`.
1976
+ await unlink(join(this.handoffArchiveDir(), basename(d.file))).catch(() => { });
1977
+ await unlink(join(this.root, d.file)).catch(() => { });
1427
1978
  }
1428
1979
  await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
1429
1980
  }
1430
- /** List all phases that have a non-empty handoff, newest first, with a
1431
- * human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
1981
+ /** Archive stale handoffs only after every task in their phase is done or
1982
+ * canceled. Idempotent: only non-empty phase.handoff values are moved. */
1983
+ async archiveStaleHandoffs() {
1984
+ const phases = await this.loadAllPhases();
1985
+ let archived = 0;
1986
+ for (const phase of phases) {
1987
+ if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff) {
1988
+ await this.clearPhaseHandoff(phase.id, "phase-done");
1989
+ archived += 1;
1990
+ }
1991
+ }
1992
+ return archived;
1993
+ }
1994
+ /** Public maintenance operation for retroactively archiving stale handoffs. */
1995
+ async cleanupStaleHandoffs() {
1996
+ return this.runAsBatch(() => this.archiveStaleHandoffs());
1997
+ }
1998
+ /** List only active/pending phase handoffs, newest first. Handoffs from
1999
+ * phases where every task is done/canceled are archived before returning. */
1432
2000
  async listHandoffs() {
2001
+ await this.archiveStaleHandoffs();
1433
2002
  const phases = await this.loadAllPhases();
1434
2003
  const features = await this.loadFeatures();
2004
+ const featureIds = new Set(features.features.map((f) => f.id));
1435
2005
  const featureNumber = new Map();
1436
2006
  for (const f of features.features)
1437
2007
  featureNumber.set(f.id, f.number);
1438
2008
  const out = [];
1439
2009
  for (const p of phases) {
1440
- if (!p.handoff)
2010
+ if (!p.handoff || p.status === "done" || p.status === "canceled")
2011
+ continue;
2012
+ if (p.featureId && !featureIds.has(p.featureId))
1441
2013
  continue;
1442
2014
  const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
1443
2015
  out.push({
@@ -1452,14 +2024,96 @@ export class PlanStore {
1452
2024
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1453
2025
  return out;
1454
2026
  }
1455
- async deletePhase(phaseId) {
1456
- try {
1457
- await unlink(this.phasePath(phaseId));
2027
+ /** List recoverable archived handoffs from phase.handoffHistory. Archived
2028
+ * entries are never returned by listHandoffs() and are safe to show on a
2029
+ * dedicated history page. */
2030
+ async listArchivedHandoffs() {
2031
+ const phases = await this.loadAllPhases();
2032
+ const features = await this.loadFeatures();
2033
+ const featureNumber = new Map();
2034
+ for (const f of features.features)
2035
+ featureNumber.set(f.id, f.number);
2036
+ const out = [];
2037
+ for (const phase of phases) {
2038
+ const fnum = phase.featureId ? featureNumber.get(phase.featureId) : undefined;
2039
+ for (const entry of phase.handoffHistory ?? []) {
2040
+ if (!entry.file)
2041
+ continue;
2042
+ const localPath = join(this.localRoot(), entry.file);
2043
+ const legacyPath = join(this.root, entry.file);
2044
+ const content = await readFile(localPath, "utf-8").catch(() => readFile(legacyPath, "utf-8").catch(() => ""));
2045
+ out.push({
2046
+ phaseId: phase.id,
2047
+ featureId: phase.featureId,
2048
+ compositeRef: formatPhaseRef(phase.number, fnum),
2049
+ file: entry.file,
2050
+ archivedAt: entry.clearedAt,
2051
+ reason: entry.reason,
2052
+ firstLine: handoffFirstLine(content),
2053
+ content,
2054
+ });
2055
+ }
1458
2056
  }
1459
- catch {
1460
- // already gone
2057
+ out.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
2058
+ return out;
2059
+ }
2060
+ async listOrphanPhases() {
2061
+ const phases = await this.loadAllPhases();
2062
+ const featuresDoc = await this.loadFeatures();
2063
+ const out = [];
2064
+ for (const phase of phases) {
2065
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
2066
+ if (resolvedFeatureId)
2067
+ continue;
2068
+ const reason = phase.featureId?.trim()
2069
+ ? `feature not found: ${phase.featureId}`
2070
+ : "missing featureId";
2071
+ out.push({
2072
+ phaseId: phase.id,
2073
+ featureId: phase.featureId,
2074
+ shortId: phase.shortId,
2075
+ compositeRef: formatPhaseRef(phase.number),
2076
+ title: phase.title,
2077
+ reason,
2078
+ });
1461
2079
  }
1462
- await this.touchManifest();
2080
+ out.sort((a, b) => a.compositeRef.localeCompare(b.compositeRef));
2081
+ return out;
2082
+ }
2083
+ async cleanupOrphanPhases() {
2084
+ return this.runAsBatch(async () => {
2085
+ const found = await this.listOrphanPhases();
2086
+ if (found.length === 0)
2087
+ return { found, removed: [] };
2088
+ const orphanIds = new Set(found.map((phase) => phase.phaseId));
2089
+ for (const orphan of found) {
2090
+ await this.unlinkPhaseFiles(orphan.phaseId);
2091
+ }
2092
+ await this.updateFeatures((doc) => {
2093
+ for (const feature of doc.features) {
2094
+ feature.phaseIds = feature.phaseIds.filter((id) => !orphanIds.has(id));
2095
+ }
2096
+ return doc;
2097
+ });
2098
+ await this.touchTimestamp();
2099
+ await this.writeGenerated();
2100
+ return { found, removed: found };
2101
+ });
2102
+ }
2103
+ async deletePhase(phaseId) {
2104
+ await this.unlinkPhaseFiles(phaseId);
2105
+ await this.touchTimestamp();
2106
+ }
2107
+ /** Remove a phase file AND its inline .bak backup. atomicUpdateJson (used by
2108
+ * updatePhase without root) writes the backup inline at phases/<id>.json.bak,
2109
+ * and readJson falls back to `${path}.bak` on a missing main file — so a
2110
+ * delete that leaves the .bak behind would RESURRECT the deleted phase on
2111
+ * the next read. Feature backups (written with root) live under
2112
+ * .local/backups/ and are never read by readJson, so only the inline .bak
2113
+ * needs removing here. */
2114
+ async unlinkPhaseFiles(phaseId) {
2115
+ await unlink(this.phasePath(phaseId)).catch(() => { });
2116
+ await unlink(`${this.phasePath(phaseId)}.bak`).catch(() => { });
1463
2117
  }
1464
2118
  // ── Workspace-level operations ─────────────────────────────────────
1465
2119
  /** Load the full workspace (manifest + phases + project + requirements + features) */
@@ -1472,8 +2126,10 @@ export class PlanStore {
1472
2126
  return { manifest, phases, project, features, requirements };
1473
2127
  }
1474
2128
  // ── Markdown generation ────────────────────────────────────────────
1475
- /** Load all data, render markdown, and write into generated/. */
2129
+ /** Load all data, render markdown, and write into generated/. Skips files
2130
+ * whose content is unchanged to avoid unnecessary backup churn. */
1476
2131
  async writeGenerated() {
2132
+ await this.migrateLegacyGeneratedDir();
1477
2133
  const { PlanRenderer } = await import("./renderer.js");
1478
2134
  const plan = await this.loadAll();
1479
2135
  const renderer = new PlanRenderer();
@@ -1489,21 +2145,27 @@ export class PlanStore {
1489
2145
  if (dir !== genDir) {
1490
2146
  await mkdir(dir, { recursive: true });
1491
2147
  }
2148
+ try {
2149
+ const existing = await readFile(fullPath, "utf-8");
2150
+ if (existing === content)
2151
+ continue;
2152
+ }
2153
+ catch {
2154
+ // file does not exist yet — write it
2155
+ }
1492
2156
  await writeFile(fullPath, content, "utf-8");
1493
2157
  written.push(relPath);
1494
2158
  }
1495
2159
  return written;
1496
2160
  }
1497
2161
  // ── Touch ────────────────────────────────────────────────────────────
1498
- /** Update manifest.updatedAt to reflect a change. */
1499
- async touchManifest() {
2162
+ /** Update .local/timestamp.json to reflect a change. */
2163
+ async touchTimestamp() {
1500
2164
  try {
1501
- const m = await this.loadManifest();
1502
- m.updatedAt = nowISO();
1503
- await atomicWriteJson(this.manifestPath(), m);
2165
+ await atomicWriteJson(this.timestampPath(), { updatedAt: nowISO() }, this.root);
1504
2166
  }
1505
2167
  catch {
1506
- // if manifest doesn't exist yet, skip
2168
+ // if .local/ doesn't exist yet, skip
1507
2169
  }
1508
2170
  }
1509
2171
  }