@agent-plan/core 0.2.19-next.2 → 0.2.19-next.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,52 @@
1
- import { access, copyFile, mkdir, readdir, readFile, rename, stat, 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 } from "node:path";
3
+ import { z, ZodError } from "zod";
4
+ /** Canonical `.planner/.gitignore` content (P042 spec): ignore the `.local/`
5
+ * transient root, legacy `*.bak` crash backups, `*.tmp.*` atomic-write temp
6
+ * files, and the legacy root-level `generated/` dir (now under `.local/`).
7
+ * Shared by `init()` and `ensureGitignore()` so the two never drift. */
8
+ const PLANNER_GITIGNORE = [
9
+ "# Agent Plan transient/derived/session-local files — do not track",
10
+ ".local/",
11
+ "*.bak",
12
+ "*.tmp.*",
13
+ "generated/",
14
+ "",
15
+ ].join("\n");
16
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, } from "./schema.js";
17
+ import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
18
+ import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
5
19
  function nowISO() {
6
20
  return new Date().toISOString();
7
21
  }
22
+ function resolveStoredFeatureId(features, ref) {
23
+ const raw = ref?.trim();
24
+ if (!raw)
25
+ return undefined;
26
+ const normalized = raw.toLowerCase();
27
+ const byId = features.find((feature) => feature.id.toLowerCase() === normalized);
28
+ if (byId)
29
+ return byId.id;
30
+ const byNumber = normalized.match(/^f(\d+)$/)
31
+ ? features.find((feature) => feature.number === parseInt(normalized.slice(1), 10))
32
+ : undefined;
33
+ if (byNumber)
34
+ return byNumber.id;
35
+ const byShortId = features.find((feature) => feature.shortId?.toLowerCase() === normalized);
36
+ if (byShortId)
37
+ return byShortId.id;
38
+ const byExactName = features.find((feature) => feature.name.toLowerCase() === normalized);
39
+ if (byExactName)
40
+ return byExactName.id;
41
+ return undefined;
42
+ }
8
43
  export class PlanStoreError extends Error {
9
44
  cause;
10
- constructor(message, cause) {
45
+ details;
46
+ constructor(message, cause, details) {
11
47
  super(message);
12
48
  this.cause = cause;
49
+ this.details = details;
13
50
  this.name = "PlanStoreError";
14
51
  }
15
52
  }
@@ -32,14 +69,52 @@ let writeNotifyHook;
32
69
  export function setWriteNotifyHook(hook) {
33
70
  writeNotifyHook = hook;
34
71
  }
72
+ const CROSS_PROCESS_LOCK_STALE_MS = 30_000;
73
+ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
74
+ async function acquireCrossProcessLock(path) {
75
+ const lockPath = `${path}.lock`;
76
+ for (;;) {
77
+ try {
78
+ await mkdir(lockPath);
79
+ return async () => {
80
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
81
+ };
82
+ }
83
+ catch (error) {
84
+ const err = error;
85
+ if (err?.code !== "EEXIST")
86
+ throw err;
87
+ try {
88
+ const info = await stat(lockPath);
89
+ if (Date.now() - info.mtimeMs > CROSS_PROCESS_LOCK_STALE_MS) {
90
+ await rm(lockPath, { recursive: true, force: true });
91
+ continue;
92
+ }
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ await new Promise((resolve) => setTimeout(resolve, CROSS_PROCESS_LOCK_RETRY_MS));
98
+ }
99
+ }
100
+ }
35
101
  function withWriteLock(path, fn) {
36
102
  const prev = writeLocks.get(path) ?? Promise.resolve();
37
103
  let release;
38
104
  const next = new Promise((resolve) => { release = resolve; });
39
- writeLocks.set(path, prev.then(() => next));
40
- return prev.then(fn).finally(() => {
105
+ const tail = prev.then(() => next);
106
+ writeLocks.set(path, tail);
107
+ return prev.then(async () => {
108
+ const releaseCrossProcess = await acquireCrossProcessLock(path);
109
+ try {
110
+ return await fn();
111
+ }
112
+ finally {
113
+ await releaseCrossProcess();
114
+ }
115
+ }).finally(() => {
41
116
  release();
42
- if (writeLocks.get(path) === prev.then(() => next))
117
+ if (writeLocks.get(path) === tail)
43
118
  writeLocks.delete(path);
44
119
  });
45
120
  }
@@ -47,21 +122,35 @@ export function withFeatureLock(featureId, fn) {
47
122
  const prev = featureLocks.get(featureId) ?? Promise.resolve();
48
123
  let release;
49
124
  const next = new Promise((resolve) => { release = resolve; });
50
- featureLocks.set(featureId, prev.then(() => next));
125
+ const tail = prev.then(() => next);
126
+ featureLocks.set(featureId, tail);
51
127
  return prev.then(fn).finally(() => {
52
128
  release();
53
- if (featureLocks.get(featureId) === prev.then(() => next))
129
+ if (featureLocks.get(featureId) === tail)
54
130
  featureLocks.delete(featureId);
55
131
  });
56
132
  }
57
- async function atomicWriteText(path, raw) {
133
+ async function atomicWriteText(path, raw, root) {
58
134
  return withWriteLock(path, async () => {
59
135
  writeBusyHook?.(true);
60
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
136
+ const localRoot = root ? join(root, ".local") : undefined;
137
+ const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
138
+ const backupsRoot = localRoot ? join(localRoot, "backups") : dirname(path);
139
+ const rel = localRoot && path.startsWith(localRoot)
140
+ ? path.slice(localRoot.length).replace(/^\//, "")
141
+ : root && path.startsWith(root)
142
+ ? path.slice(root.length).replace(/^\//, "")
143
+ : basename(path);
144
+ const backupRel = rel ? rel + ".bak" : basename(path) + ".bak";
145
+ const backupPath = join(backupsRoot, backupRel);
146
+ const tmpName = rel ? rel.replace(/[/\\]/g, "--") + `.tmp.${process.pid}.${Date.now()}` : `${basename(path)}.tmp.${process.pid}.${Date.now()}`;
147
+ const tmp = join(tmpDir, tmpName);
61
148
  try {
149
+ await mkdir(tmpDir, { recursive: true });
62
150
  await writeFile(tmp, raw, "utf-8");
63
151
  try {
64
- await copyFile(path, `${path}.bak`);
152
+ await mkdir(dirname(backupPath), { recursive: true });
153
+ await copyFile(path, backupPath);
65
154
  }
66
155
  catch { }
67
156
  await rename(tmp, path);
@@ -76,10 +165,10 @@ async function atomicWriteText(path, raw) {
76
165
  }
77
166
  });
78
167
  }
79
- async function atomicWriteJson(path, data) {
80
- return atomicWriteText(path, JSON.stringify(data, null, 2));
168
+ async function atomicWriteJson(path, data, root) {
169
+ return atomicWriteText(path, JSON.stringify(data, null, 2), root);
81
170
  }
82
- async function atomicUpdateJson(path, schema, updater) {
171
+ async function atomicUpdateJson(path, schema, updater, root) {
83
172
  // NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
84
173
  // because those re-acquire withWriteLock(path) — and we already hold it (below).
85
174
  // Re-entrant locking is not supported, so calling them would deadlock.
@@ -88,11 +177,23 @@ async function atomicUpdateJson(path, schema, updater) {
88
177
  const updated = updater(current);
89
178
  const parsed = schema.parse(updated);
90
179
  writeBusyHook?.(true);
91
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
180
+ const localRoot = root ? join(root, ".local") : undefined;
181
+ const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
182
+ const backupsRoot = localRoot ? join(localRoot, "backups") : dirname(path);
183
+ const rel = localRoot && path.startsWith(localRoot)
184
+ ? path.slice(localRoot.length).replace(/^\//, "")
185
+ : root && path.startsWith(root)
186
+ ? path.slice(root.length).replace(/^\//, "")
187
+ : basename(path);
188
+ const backupPath = join(backupsRoot, rel + ".bak");
189
+ const tmpName = rel ? rel.replace(/[/\\]/g, "--") + `.tmp.${process.pid}.${Date.now()}` : `${basename(path)}.tmp.${process.pid}.${Date.now()}`;
190
+ const tmp = join(tmpDir, tmpName);
92
191
  try {
192
+ await mkdir(tmpDir, { recursive: true });
93
193
  await writeFile(tmp, JSON.stringify(parsed, null, 2), "utf-8");
94
194
  try {
95
- await copyFile(path, `${path}.bak`);
195
+ await mkdir(dirname(backupPath), { recursive: true });
196
+ await copyFile(path, backupPath);
96
197
  }
97
198
  catch { }
98
199
  await rename(tmp, path);
@@ -176,21 +277,150 @@ export async function migrateToUuids(store) {
176
277
  await store.writeGenerated();
177
278
  });
178
279
  }
280
+ /**
281
+ * One-time idempotent migration to GLOBAL F/P/T numbering.
282
+ *
283
+ * Legacy plans assign Phase.number per-feature and Task.number per-phase, so
284
+ * every feature has a P001 and every phase has a T001 (ambiguous in chat/handoffs).
285
+ * This renumbers ALL features/phases/tasks by `createdAt` asc (stable tiebreak by
286
+ * id) into a single project-wide 1..N sequence and sets the monotonic project
287
+ * counters (nextFeatureNumber/nextPhaseNumber/nextTaskNumber).
288
+ *
289
+ * Idempotent: if no duplicate phase/task/feature numbers exist across the
290
+ * project, the plan is already global → no renumber writes happen (only the
291
+ * counters are ensured, in case project.json predates them). MUST run before
292
+ * ensureStructureOrdering (which no longer renumbers — numbers are stable).
293
+ */
294
+ export async function migrateToGlobalSequence(store) {
295
+ return store.runBatchForMigration(async () => {
296
+ const ws = await store.loadAll();
297
+ const phases = ws.phases;
298
+ const features = ws.features.features;
299
+ const project = ws.project;
300
+ const allTasks = [];
301
+ for (const phase of phases)
302
+ for (const task of phase.tasks)
303
+ allTasks.push({ phase, task });
304
+ const hasDupes = (nums) => new Set(nums).size !== nums.length;
305
+ const phaseDupes = hasDupes(phases.map((p) => p.number));
306
+ const taskDupes = hasDupes(allTasks.map((x) => x.task.number));
307
+ const featureDupes = hasDupes(features.map((f) => f.number));
308
+ const maxP = phases.reduce((m, p) => Math.max(m, p.number), 0);
309
+ const maxT = allTasks.reduce((m, x) => Math.max(m, x.task.number), 0);
310
+ const maxF = features.reduce((m, f) => Math.max(m, f.number), 0);
311
+ if (!phaseDupes && !taskDupes && !featureDupes) {
312
+ // Already global. Ensure counters are set (project.json may predate them).
313
+ let changed = false;
314
+ if (project.nextPhaseNumber <= maxP) {
315
+ project.nextPhaseNumber = maxP + 1;
316
+ changed = true;
317
+ }
318
+ if (project.nextTaskNumber <= maxT) {
319
+ project.nextTaskNumber = maxT + 1;
320
+ changed = true;
321
+ }
322
+ if (project.nextFeatureNumber <= maxF) {
323
+ project.nextFeatureNumber = maxF + 1;
324
+ changed = true;
325
+ }
326
+ if (changed)
327
+ await store.saveProject(project);
328
+ return { migrated: false, phases: phases.length, tasks: allTasks.length, features: features.length };
329
+ }
330
+ const renumber = (arr) => arr
331
+ .slice()
332
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
333
+ .map((x, i) => ({ ...x, number: i + 1 }));
334
+ const newFeatures = renumber(features);
335
+ const newPhases = renumber(phases);
336
+ const numberedTasks = renumber(allTasks.map((x) => x.task));
337
+ // Reassemble renumbered tasks into their phases, keyed by the task's OWN
338
+ // phaseId (source of truth). NOTE: do NOT pair `numberedTasks[i]` with
339
+ // `allTasks[i]!.phase.id` — the two arrays are in DIFFERENT orders
340
+ // (numberedTasks is sorted by createdAt, allTasks is in phase-iteration
341
+ // order), so a positional index would file each task under the wrong phase.
342
+ const phaseIdByTaskId = new Map(allTasks.map((x) => [x.task.id, x.phase.id]));
343
+ const newPhaseIds = new Set(newPhases.map((p) => p.id));
344
+ const tasksByPhase = new Map();
345
+ for (const t of numberedTasks) {
346
+ // Prefer the task's own phaseId when it points to a real phase; otherwise
347
+ // fall back to the phase the task was loaded from (handles legacy tasks
348
+ // with empty/stale phaseId without losing them).
349
+ const pid = (t.phaseId && newPhaseIds.has(t.phaseId)) ? t.phaseId : (phaseIdByTaskId.get(t.id) ?? "");
350
+ const bucket = tasksByPhase.get(pid) ?? [];
351
+ bucket.push(t);
352
+ tasksByPhase.set(pid, bucket);
353
+ }
354
+ const finalPhases = newPhases.map((p) => {
355
+ const tasks = tasksByPhase.get(p.id) ?? [];
356
+ const order = new Map(tasks.map((t) => [t.id, t]));
357
+ const ordered = p.taskIds.map((id) => order.get(id)).filter((t) => Boolean(t));
358
+ for (const t of tasks.sort((a, b) => a.number - b.number))
359
+ if (!ordered.includes(t))
360
+ ordered.push(t);
361
+ return { ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) };
362
+ });
363
+ await store.saveFeatures({ features: newFeatures });
364
+ for (const p of finalPhases)
365
+ await store.savePhase(p);
366
+ project.nextFeatureNumber = newFeatures.length + 1;
367
+ project.nextPhaseNumber = newPhases.length + 1;
368
+ project.nextTaskNumber = numberedTasks.length + 1;
369
+ await store.saveProject(project);
370
+ await store.writeGenerated();
371
+ return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
372
+ });
373
+ }
179
374
  async function readJson(path, schema) {
375
+ let backupTried = false;
376
+ let backupFailed = false;
377
+ let rawPreview;
180
378
  try {
181
379
  const raw = await readFile(path, "utf-8");
380
+ rawPreview = raw.slice(0, 240);
182
381
  return schema.parse(JSON.parse(raw));
183
382
  }
184
383
  catch (cause) {
185
384
  // Try the .bak backup before giving up (recover from external-write corruption).
385
+ backupTried = true;
186
386
  try {
187
387
  const bak = await readFile(`${path}.bak`, "utf-8");
388
+ rawPreview = bak.slice(0, 240);
188
389
  return schema.parse(JSON.parse(bak));
189
390
  }
190
391
  catch {
392
+ backupFailed = true;
191
393
  // fall through to original error
192
394
  }
193
- throw new PlanStoreError(`read failed: ${path}`, cause);
395
+ const details = {
396
+ path,
397
+ operation: "readJson",
398
+ backupTried,
399
+ backupFailed,
400
+ };
401
+ if (rawPreview != null)
402
+ details.rawPreview = rawPreview;
403
+ if (cause instanceof SyntaxError) {
404
+ const match = cause.message.match(/position\s+(\d+)/i);
405
+ const position = match && match[1] ? Number.parseInt(match[1], 10) : undefined;
406
+ if (rawPreview != null && position != null && position >= 0) {
407
+ const upTo = rawPreview.slice(0, position);
408
+ const line = upTo.split("\n").length;
409
+ const lastNL = upTo.lastIndexOf("\n");
410
+ const column = position - (lastNL >= 0 ? lastNL : 0);
411
+ details.jsonParseError = { message: cause.message, line, column };
412
+ }
413
+ else {
414
+ details.jsonParseError = { message: cause.message };
415
+ }
416
+ }
417
+ else if (cause instanceof ZodError) {
418
+ details.validationErrors = cause.issues.slice(0, 8).map((issue) => ({
419
+ path: issue.path.map((p) => (typeof p === "number" ? `[${p}]` : String(p))).join("."),
420
+ message: issue.message,
421
+ }));
422
+ }
423
+ throw new PlanStoreError(`read failed: ${path}`, cause, details);
194
424
  }
195
425
  }
196
426
  export class PlanStore {
@@ -240,24 +470,13 @@ export class PlanStore {
240
470
  // save. Kept so existing save* call sites compile unchanged.
241
471
  }
242
472
  normalizeTasks(tasks) {
243
- let changed = false;
244
- const normalized = tasks.map((task, index) => {
245
- const nextNumber = index + 1;
246
- if (task.number !== nextNumber)
247
- changed = true;
248
- return { ...task, number: nextNumber };
249
- });
250
- return { tasks: normalized, changed };
473
+ // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
474
+ // Do NOT renumber here renumbering would break references after deletions.
475
+ return { tasks, changed: false };
251
476
  }
252
477
  normalizeFeaturesDocument(doc) {
253
- let changed = false;
254
- const normalized = doc.features.map((feature, index) => {
255
- const nextNumber = index + 1;
256
- if (feature.number !== nextNumber)
257
- changed = true;
258
- return { ...feature, number: nextNumber };
259
- });
260
- return { doc: { features: normalized }, changed };
478
+ // Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
479
+ return { doc, changed: false };
261
480
  }
262
481
  normalizePhaseDocument(phase) {
263
482
  const { tasks, changed } = this.normalizeTasks(phase.tasks);
@@ -278,7 +497,12 @@ export class PlanStore {
278
497
  const phasesByFeature = new Map();
279
498
  const orphanPhases = [];
280
499
  for (const phase of phases) {
281
- if (phase.featureId) {
500
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
501
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
502
+ phase.featureId = resolvedFeatureId;
503
+ changed = true;
504
+ }
505
+ if (phase.featureId && featuresDoc.features.some((feature) => feature.id === phase.featureId)) {
282
506
  const bucket = phasesByFeature.get(phase.featureId) ?? [];
283
507
  bucket.push(phase);
284
508
  phasesByFeature.set(phase.featureId, bucket);
@@ -287,10 +511,7 @@ export class PlanStore {
287
511
  orphanPhases.push(phase);
288
512
  }
289
513
  }
290
- const normalizedFeatures = featuresDoc.features.map((feature, featureIndex) => {
291
- const nextFeatureNumber = featureIndex + 1;
292
- if (feature.number !== nextFeatureNumber)
293
- changed = true;
514
+ const normalizedFeatures = featuresDoc.features.map((feature) => {
294
515
  const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
295
516
  const linkedIds = new Set(linked.map((phase) => phase.id));
296
517
  const inferred = (phasesByFeature.get(feature.id) ?? []).filter((phase) => !linkedIds.has(phase.id));
@@ -299,12 +520,7 @@ export class PlanStore {
299
520
  if (normalizedPhaseIds.length !== feature.phaseIds.length || normalizedPhaseIds.some((id, index) => id !== feature.phaseIds[index])) {
300
521
  changed = true;
301
522
  }
302
- orderedPhases.forEach((phase, index) => {
303
- const nextPhaseNumber = index + 1;
304
- if (phase.number !== nextPhaseNumber) {
305
- phase.number = nextPhaseNumber;
306
- changed = true;
307
- }
523
+ orderedPhases.forEach((phase) => {
308
524
  const normalizedPhase = this.normalizePhaseDocument(phase);
309
525
  if (normalizedPhase.changed) {
310
526
  phase.tasks = normalizedPhase.phase.tasks;
@@ -314,16 +530,10 @@ export class PlanStore {
314
530
  });
315
531
  return {
316
532
  ...feature,
317
- number: nextFeatureNumber,
318
533
  phaseIds: normalizedPhaseIds,
319
534
  };
320
535
  });
321
- orphanPhases.forEach((phase, index) => {
322
- const nextPhaseNumber = index + 1;
323
- if (phase.number !== nextPhaseNumber) {
324
- phase.number = nextPhaseNumber;
325
- changed = true;
326
- }
536
+ orphanPhases.forEach((phase) => {
327
537
  const normalizedPhase = this.normalizePhaseDocument(phase);
328
538
  if (normalizedPhase.changed) {
329
539
  phase.tasks = normalizedPhase.phase.tasks;
@@ -338,7 +548,7 @@ export class PlanStore {
338
548
  };
339
549
  }
340
550
  async ensureStructureOrdering() {
341
- return this.runAsBatch(async () => {
551
+ const result = await this.runAsBatch(async () => {
342
552
  const featuresDoc = await this.loadFeatures();
343
553
  const phases = await this.loadAllPhases();
344
554
  const normalized = this.normalizeStructureSnapshot(featuresDoc, phases);
@@ -350,6 +560,52 @@ export class PlanStore {
350
560
  }
351
561
  return { changed: true };
352
562
  });
563
+ // One-time import of a legacy file-based HANDOFF.md (pre-F004) into the
564
+ // entity-scoped phase.handoff. Idempotent — renames the file to .bak.
565
+ await this.importLegacyHandoffFile().catch(() => { });
566
+ return result;
567
+ }
568
+ /** Rebuild each phase's `tasks` + `taskIds` from the task's OWN `phaseId`
569
+ * (source of truth). Heals plans where tasks got filed into the wrong phase
570
+ * file (e.g. the migrateToGlobalSequence index-mismatch bug, @agent-plan/core
571
+ * <0.2.19-next.7). Deterministic, lossless, idempotent: groups every task by
572
+ * its phaseId, preserves each phase's existing taskIds order, appends orphan
573
+ * tasks (whose phaseId dangles or is empty) by number. Writes a phase file
574
+ * only when its task set actually changed. */
575
+ async rebuildContainment() {
576
+ return this.runAsBatch(async () => {
577
+ const phases = await this.loadAllPhases();
578
+ const phaseById = new Map(phases.map((p) => [p.id, p]));
579
+ const allTasks = [];
580
+ for (const p of phases)
581
+ for (const t of p.tasks)
582
+ allTasks.push({ task: t, fromPhaseId: p.id });
583
+ const grouped = new Map();
584
+ let orphan = 0;
585
+ for (const { task, fromPhaseId } of allTasks) {
586
+ const pid = (task.phaseId && phaseById.has(task.phaseId)) ? task.phaseId : fromPhaseId;
587
+ if (!phaseById.has(pid))
588
+ orphan++;
589
+ const bucket = grouped.get(pid) ?? [];
590
+ bucket.push(task);
591
+ grouped.set(pid, bucket);
592
+ }
593
+ let changed = 0;
594
+ for (const p of phases) {
595
+ const tasks = grouped.get(p.id) ?? [];
596
+ const byId = new Map(tasks.map((t) => [t.id, t]));
597
+ const ordered = p.taskIds.map((id) => byId.get(id)).filter((t) => Boolean(t));
598
+ for (const t of tasks.slice().sort((a, b) => a.number - b.number))
599
+ if (!ordered.some((o) => o.id === t.id))
600
+ ordered.push(t);
601
+ const same = ordered.length === p.tasks.length && ordered.every((t, i) => t.id === p.tasks[i]?.id);
602
+ if (same)
603
+ continue;
604
+ await this.savePhase({ ...p, tasks: ordered, taskIds: ordered.map((t) => t.id) });
605
+ changed++;
606
+ }
607
+ return { changed, tasks: allTasks.length, orphan };
608
+ });
353
609
  }
354
610
  // ── Path helpers ─────────────────────────────────────────────────────
355
611
  manifestPath() {
@@ -389,7 +645,7 @@ export class PlanStore {
389
645
  }
390
646
  await mkdir(this.featuresDir(), { recursive: true });
391
647
  for (const feat of legacy.features) {
392
- await atomicWriteJson(this.featurePath(feat.id), feat);
648
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
393
649
  }
394
650
  await unlink(this.featuresPath()).catch(() => { });
395
651
  }
@@ -400,19 +656,81 @@ export class PlanStore {
400
656
  return join(this.phasesDir(), `${phaseId}.json`);
401
657
  }
402
658
  generatedDir() {
403
- return join(this.root, "generated");
659
+ return join(this.localRoot(), "generated");
404
660
  }
405
661
  codebasePath() {
406
662
  return join(this.root, "codebase.json");
407
663
  }
408
664
  resumePath() {
409
- return join(this.root, "resume.json");
665
+ return join(this.localRoot(), "resume.json");
410
666
  }
411
667
  activityPath() {
412
- return join(this.root, "activity.json");
668
+ return join(this.localRoot(), "activity.json");
669
+ }
670
+ localRoot() {
671
+ return join(this.root, ".local");
672
+ }
673
+ timestampPath() {
674
+ return join(this.localRoot(), "timestamp.json");
675
+ }
676
+ backupsDir() {
677
+ return join(this.localRoot(), "backups");
678
+ }
679
+ tmpDir() {
680
+ return join(this.localRoot(), "tmp");
681
+ }
682
+ handoffArchiveDir() {
683
+ return join(this.localRoot(), "handoff-archive");
684
+ }
685
+ /** One-time migration for plans created before .planner/.local/ existed.
686
+ * Moves a legacy root-level file into .local/ if the legacy file exists and
687
+ * the .local/ counterpart does not. Safe to call on every load. */
688
+ async migrateLegacyLocalFile(oldPath, newPath) {
689
+ try {
690
+ await access(oldPath);
691
+ }
692
+ catch {
693
+ return;
694
+ }
695
+ try {
696
+ await access(newPath);
697
+ return;
698
+ }
699
+ catch { }
700
+ await mkdir(dirname(newPath), { recursive: true });
701
+ await rename(oldPath, newPath);
702
+ }
703
+ async migrateLegacyGeneratedDir() {
704
+ const oldDir = join(this.root, "generated");
705
+ const newDir = this.generatedDir();
706
+ try {
707
+ await access(oldDir);
708
+ }
709
+ catch {
710
+ return;
711
+ }
712
+ try {
713
+ await access(newDir);
714
+ return;
715
+ }
716
+ catch { }
717
+ await rename(oldDir, newDir);
413
718
  }
414
- handoffPath() {
415
- return join(this.root, "HANDOFF.md");
719
+ async migrateLegacyHandoffArchive() {
720
+ const oldDir = join(this.root, "handoff-archive");
721
+ const newDir = this.handoffArchiveDir();
722
+ try {
723
+ await access(oldDir);
724
+ }
725
+ catch {
726
+ return;
727
+ }
728
+ try {
729
+ await access(newDir);
730
+ return;
731
+ }
732
+ catch { }
733
+ await rename(oldDir, newDir);
416
734
  }
417
735
  // ── Init ─────────────────────────────────────────────────────────────
418
736
  async init(projectName) {
@@ -422,7 +740,11 @@ export class PlanStore {
422
740
  await mkdir(this.root, { recursive: true });
423
741
  await mkdir(this.phasesDir(), { recursive: true });
424
742
  await mkdir(this.featuresDir(), { recursive: true });
743
+ await mkdir(this.localRoot(), { recursive: true });
744
+ await mkdir(this.backupsDir(), { recursive: true });
745
+ await mkdir(this.tmpDir(), { recursive: true });
425
746
  await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
747
+ await mkdir(this.handoffArchiveDir(), { recursive: true });
426
748
  await mkdir(join(this.root, "schema"), { recursive: true });
427
749
  await mkdir(join(this.root, "adapters"), { recursive: true });
428
750
  const manifest = {
@@ -432,7 +754,8 @@ export class PlanStore {
432
754
  createdAt: nowISO(),
433
755
  updatedAt: nowISO(),
434
756
  };
435
- await atomicWriteJson(this.manifestPath(), manifest);
757
+ await atomicWriteJson(this.manifestPath(), manifest, this.root);
758
+ await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
436
759
  await this.saveProject({
437
760
  name: projectName,
438
761
  goal: "",
@@ -452,6 +775,9 @@ export class PlanStore {
452
775
  beforeTaskStart: [],
453
776
  afterPhaseComplete: [],
454
777
  },
778
+ nextFeatureNumber: 1,
779
+ nextPhaseNumber: 1,
780
+ nextTaskNumber: 1,
455
781
  });
456
782
  await this.saveRequirements({ requirements: [] });
457
783
  await this.saveFeatures({ features: [] });
@@ -460,6 +786,7 @@ export class PlanStore {
460
786
  currentPhaseId: "",
461
787
  inProgressTaskIds: [],
462
788
  nextSteps: ["Run /planner project discuss to bootstrap discovery"],
789
+ nextStepsUpdatedAt: nowISO(),
463
790
  blockers: [],
464
791
  notes: "Project initialized. Awaiting discovery.",
465
792
  lastSessionSummary: "",
@@ -478,7 +805,7 @@ export class PlanStore {
478
805
  "- `project.json` — scope, rules, stack, tools",
479
806
  "- `requirements.json` — requirements and macro-tasks",
480
807
  "- `phases/` — one JSON file per phase",
481
- "- `generated/` — auto-generated markdown views",
808
+ "- `generated/` — auto-generated markdown views (under `.local/`)",
482
809
  "- `schema/plan.schema.json` — JSON Schema for tooling",
483
810
  ].join("\n");
484
811
  await writeFile(join(this.root, "README.md"), readme, "utf-8");
@@ -488,15 +815,29 @@ export class PlanStore {
488
815
  // - resume.json: per-session resume focus + the machine-local guard-bypass
489
816
  // timestamp (guardBypassUntil must NOT leak into git/other clones)
490
817
  // - generated/: auto-regenerated markdown views (derived from JSON; churn)
491
- await writeFile(join(this.root, ".gitignore"), [
492
- "# Agent Plan transient/derived files — do not track",
493
- "*.bak",
494
- "*.tmp.*",
495
- "resume.json",
496
- "resume.*.json",
497
- "generated/",
498
- "",
499
- ].join("\n"), "utf-8");
818
+ await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
819
+ }
820
+ /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
821
+ * canonical transient/derived patterns). Projects initialized before the
822
+ * `.local/` move either have no `.planner/.gitignore` or one with stale
823
+ * root-level patterns. This upgrades them safely on load and on repair.
824
+ * Returns true if the file was (re)written. Safe to call on every load. */
825
+ async ensureGitignore() {
826
+ const gi = join(this.root, ".gitignore");
827
+ try {
828
+ const existing = await readFile(gi, "utf8").catch(() => null);
829
+ // Up to date iff it contains all canonical patterns (P042 spec):
830
+ // .local/ (transients), *.bak (crash backups), *.tmp.* (atomic-write
831
+ // temp files), generated/ (legacy dir).
832
+ if (existing != null && existing.includes(".local/") && existing.includes("*.bak") && existing.includes("*.tmp.*") && existing.includes("generated/")) {
833
+ return false;
834
+ }
835
+ await writeFile(gi, PLANNER_GITIGNORE, "utf-8");
836
+ return true;
837
+ }
838
+ catch {
839
+ return false;
840
+ }
500
841
  }
501
842
  async exists() {
502
843
  try {
@@ -509,11 +850,79 @@ export class PlanStore {
509
850
  }
510
851
  // ── Loaders ──────────────────────────────────────────────────────────
511
852
  async loadManifest() {
512
- return readJson(this.manifestPath(), ManifestSchema);
853
+ // Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
854
+ // upgrades plans initialized before the .local/ move. Runs on every load.
855
+ await this.ensureGitignore().catch(() => { });
856
+ const manifest = await readJson(this.manifestPath(), ManifestSchema);
857
+ try {
858
+ const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema }));
859
+ manifest.updatedAt = timestamp.updatedAt;
860
+ }
861
+ catch {
862
+ // Legacy plan without .local/timestamp.json: bootstrap from manifest.updatedAt
863
+ // and create the timestamp file so subsequent writes stay in .local/.
864
+ try {
865
+ await mkdir(this.localRoot(), { recursive: true });
866
+ await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
867
+ }
868
+ catch {
869
+ // ignore bootstrap failure
870
+ }
871
+ }
872
+ return manifest;
513
873
  }
514
874
  async loadProject() {
515
875
  return readJson(this.projectPath(), ProjectSchema);
516
876
  }
877
+ /**
878
+ * Allocate the next global sequence number for a feature/phase/task.
879
+ * Reads the monotonic counter from project.json, increments it, persists,
880
+ * and returns the allocated number. MUST be called within withFeatureLock
881
+ * (adapters create entities inside a lock) so the counter is race-free.
882
+ * The counter never reuses a number — deletions leave gaps (by design:
883
+ * stable references survive deletion).
884
+ */
885
+ async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber", "feature"); }
886
+ async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber", "phase"); }
887
+ async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber", "task"); }
888
+ /** Allocate a globally-unique sequence number. `atomicUpdateJson` already
889
+ * serializes concurrent calls via `withWriteLock` on the project file, so
890
+ * the read-modify-write is race-free in-process. The collision guard
891
+ * additionally skips any candidate that already exists in the data (safety
892
+ * net for cross-process races or manual edits) and persists the corrected
893
+ * counter. */
894
+ async allocSeqNumber(key, kind) {
895
+ // Load already-used numbers for this kind (best-effort read; the
896
+ // atomicUpdateJson below is the authoritative write).
897
+ const used = new Set();
898
+ if (kind === "task") {
899
+ const phases = await this.loadAllPhases();
900
+ for (const p of phases)
901
+ for (const t of p.tasks)
902
+ used.add(t.number);
903
+ }
904
+ else if (kind === "phase") {
905
+ const phases = await this.loadAllPhases();
906
+ for (const p of phases)
907
+ used.add(p.number);
908
+ }
909
+ else {
910
+ const feats = await this.loadRawFeatures();
911
+ for (const f of feats)
912
+ used.add(f.number);
913
+ }
914
+ let allocated = 0;
915
+ await this.updateProject((project) => {
916
+ let candidate = project[key];
917
+ // Collision guard: skip any candidate that already exists.
918
+ while (used.has(candidate))
919
+ candidate++;
920
+ allocated = candidate;
921
+ project[key] = candidate + 1;
922
+ return project;
923
+ });
924
+ return allocated;
925
+ }
517
926
  async loadPhase(phaseId) {
518
927
  const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
519
928
  const normalized = this.normalizePhaseDocument(raw).phase;
@@ -565,7 +974,7 @@ export class PlanStore {
565
974
  const raws = await this.loadRawFeatures();
566
975
  const phases = await this.loadAllPhases();
567
976
  const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
568
- return this.normalizeFeaturesDocument({ features }).doc;
977
+ return this.normalizeStructureSnapshot({ features }, phases).features;
569
978
  }
570
979
  async loadCodebaseProfile() {
571
980
  try {
@@ -577,10 +986,11 @@ export class PlanStore {
577
986
  }
578
987
  async saveCodebaseProfile(profile) {
579
988
  const parsed = CodebaseProfileSchema.parse(profile);
580
- await atomicWriteJson(this.codebasePath(), parsed);
581
- await this.touchManifest();
989
+ await atomicWriteJson(this.codebasePath(), parsed, this.root);
990
+ await this.touchTimestamp();
582
991
  }
583
992
  async loadResume() {
993
+ await this.migrateLegacyLocalFile(join(this.root, "resume.json"), this.resumePath());
584
994
  try {
585
995
  return await readJson(this.resumePath(), ResumeFocusSchema);
586
996
  }
@@ -589,9 +999,20 @@ export class PlanStore {
589
999
  }
590
1000
  }
591
1001
  async saveResume(resume) {
592
- const parsed = ResumeFocusSchema.parse(resume);
593
- await atomicWriteJson(this.resumePath(), parsed);
594
- await this.touchManifest();
1002
+ // Track when `nextSteps` actually change (free-text can go stale; the recap
1003
+ // surfaces nextStepsUpdatedAt so staleness is visible). Preserved when
1004
+ // refreshResume keeps existing nextSteps; bumped only on a real change.
1005
+ const existing = await this.loadResume().catch(() => null);
1006
+ const nextStepsChanged = JSON.stringify(existing?.nextSteps ?? []) !== JSON.stringify(resume.nextSteps ?? []);
1007
+ const withTs = {
1008
+ ...resume,
1009
+ nextStepsUpdatedAt: nextStepsChanged
1010
+ ? nowISO()
1011
+ : (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
1012
+ };
1013
+ const parsed = ResumeFocusSchema.parse(withTs);
1014
+ await atomicWriteJson(this.resumePath(), parsed, this.root);
1015
+ await this.touchTimestamp();
595
1016
  }
596
1017
  /**
597
1018
  * Authorize a temporary guard bypass so edit/write tools may proceed even
@@ -605,6 +1026,7 @@ export class PlanStore {
605
1026
  currentPhaseId: "",
606
1027
  inProgressTaskIds: [],
607
1028
  nextSteps: [],
1029
+ nextStepsUpdatedAt: "",
608
1030
  blockers: [],
609
1031
  notes: "",
610
1032
  lastSessionSummary: "",
@@ -636,6 +1058,7 @@ export class PlanStore {
636
1058
  return until > Date.now();
637
1059
  }
638
1060
  async loadActivityLog() {
1061
+ await this.migrateLegacyLocalFile(join(this.root, "activity.json"), this.activityPath());
639
1062
  try {
640
1063
  return await readJson(this.activityPath(), ActivityLogSchema);
641
1064
  }
@@ -643,44 +1066,6 @@ export class PlanStore {
643
1066
  return { entries: [] };
644
1067
  }
645
1068
  }
646
- async handoffExists() {
647
- try {
648
- await access(this.handoffPath());
649
- return true;
650
- }
651
- catch {
652
- return false;
653
- }
654
- }
655
- async loadHandoff() {
656
- try {
657
- const [content, info] = await Promise.all([
658
- readFile(this.handoffPath(), "utf-8"),
659
- stat(this.handoffPath()),
660
- ]);
661
- const createdAt = content.match(/^Created at:\s*(.+)$/m)?.[1]?.trim() ?? info.birthtime.toISOString();
662
- const updatedAt = content.match(/^Updated at:\s*(.+)$/m)?.[1]?.trim() ?? info.mtime.toISOString();
663
- return {
664
- content,
665
- createdAt,
666
- updatedAt,
667
- };
668
- }
669
- catch {
670
- return null;
671
- }
672
- }
673
- async saveHandoff(content) {
674
- await atomicWriteText(this.handoffPath(), content);
675
- await this.touchManifest();
676
- }
677
- async deleteHandoff() {
678
- try {
679
- await unlink(this.handoffPath());
680
- }
681
- catch { }
682
- await this.touchManifest();
683
- }
684
1069
  async appendActivity(type, ref, summary) {
685
1070
  const log = await this.loadActivityLog();
686
1071
  const id = `act-${log.entries.length + 1}-${type}`;
@@ -689,8 +1074,8 @@ export class PlanStore {
689
1074
  // Cap to last 200 entries
690
1075
  if (log.entries.length > 200)
691
1076
  log.entries = log.entries.slice(-200);
692
- await atomicWriteJson(this.activityPath(), { entries: log.entries });
693
- await this.touchManifest();
1077
+ await atomicWriteJson(this.activityPath(), { entries: log.entries }, this.root);
1078
+ await this.touchTimestamp();
694
1079
  return entry;
695
1080
  }
696
1081
  /** Derive an up-to-date resume focus from the current workspace state. */
@@ -705,6 +1090,7 @@ export class PlanStore {
705
1090
  currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
706
1091
  inProgressTaskIds: inProgressTasks.map((t) => t.id),
707
1092
  nextSteps: existing?.nextSteps ?? [],
1093
+ nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
708
1094
  blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
709
1095
  notes: notes ?? existing?.notes ?? "",
710
1096
  lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
@@ -721,6 +1107,27 @@ export class PlanStore {
721
1107
  return { requirements: [] };
722
1108
  }
723
1109
  }
1110
+ async linkedRequirementsForPhase(phaseId) {
1111
+ const requirements = await this.loadRequirements();
1112
+ return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
1113
+ }
1114
+ async loadPhaseWithRequirements(phaseId) {
1115
+ const [phase, linkedRequirements] = await Promise.all([
1116
+ this.loadPhase(phaseId),
1117
+ this.linkedRequirementsForPhase(phaseId),
1118
+ ]);
1119
+ return { ...phase, linkedRequirements };
1120
+ }
1121
+ async loadAllPhasesWithRequirements() {
1122
+ const [phases, requirements] = await Promise.all([
1123
+ this.loadAllPhases(),
1124
+ this.loadRequirements(),
1125
+ ]);
1126
+ return phases.map((phase) => ({
1127
+ ...phase,
1128
+ linkedRequirements: requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phase.id)),
1129
+ }));
1130
+ }
724
1131
  async loadAllPhases() {
725
1132
  const { readdir } = await import("node:fs/promises");
726
1133
  let files;
@@ -751,6 +1158,22 @@ export class PlanStore {
751
1158
  return left.createdAt.localeCompare(right.createdAt);
752
1159
  });
753
1160
  }
1161
+ /** Derive the parent display snapshot for a phase from its tasks' canonical
1162
+ * statuses. Pure, non-persisting. */
1163
+ async loadPhaseDisplay(phaseId) {
1164
+ const phase = await this.loadPhase(phaseId);
1165
+ const childStatuses = phase.tasks.map((t) => fromCanonicalStatus(t.status));
1166
+ return deriveParentDisplay(childStatuses);
1167
+ }
1168
+ /** Derive the parent display snapshot for a feature from its phases' DERIVED
1169
+ * canonical statuses (each phase status is derived from its tasks at read
1170
+ * time, then mapped via fromCanonicalStatus). Pure, non-persisting. */
1171
+ async loadFeatureDisplay(featureId) {
1172
+ const phases = await this.loadAllPhases();
1173
+ const featurePhases = phases.filter((p) => p.featureId === featureId);
1174
+ const childStatuses = featurePhases.map((p) => fromCanonicalStatus(p.status));
1175
+ return deriveParentDisplay(childStatuses);
1176
+ }
754
1177
  async loadAll() {
755
1178
  const [manifest, project, requirements, phases] = await Promise.all([
756
1179
  this.loadManifest(),
@@ -760,7 +1183,8 @@ export class PlanStore {
760
1183
  ]);
761
1184
  const rawFeatures = await this.loadRawFeatures();
762
1185
  const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
763
- return { manifest, project, requirements, phases, features: { features } };
1186
+ const normalized = this.normalizeStructureSnapshot({ features }, phases);
1187
+ return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
764
1188
  }
765
1189
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
766
1190
  * dangling feature.phaseIds references. Idempotent. */
@@ -938,18 +1362,14 @@ export class PlanStore {
938
1362
  let shortIdsAssigned = 0;
939
1363
  let prioritiesAssigned = 0;
940
1364
  let featuresDirty = false;
941
- const assignPriority = (current, index) => {
942
- if (current === 0) {
943
- prioritiesAssigned += 1;
944
- return index + 1;
945
- }
946
- return current;
947
- };
1365
+ // Priority is left to reorder (midpoint-insert); ensureShortIds only
1366
+ // backfills shortIds. New items keep priority 0 until first drag reindex.
1367
+ const assignPriority = (current, _index) => current;
948
1368
  // Features: shortId + priority (project scope)
949
1369
  const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
950
1370
  sortedFeatures.forEach((f, index) => {
951
1371
  if (!f.shortId) {
952
- f.shortId = createShortId(existing);
1372
+ f.shortId = createShortId(existing, `feature:${f.number}:${f.id}`);
953
1373
  existing.add(f.shortId);
954
1374
  shortIdsAssigned += 1;
955
1375
  featuresDirty = true;
@@ -968,7 +1388,7 @@ export class PlanStore {
968
1388
  for (const phase of phases) {
969
1389
  let phaseDirty = false;
970
1390
  if (!phase.shortId) {
971
- phase.shortId = createShortId(existing);
1391
+ phase.shortId = createShortId(existing, `phase:${phase.number}:${phase.id}`);
972
1392
  existing.add(phase.shortId);
973
1393
  shortIdsAssigned += 1;
974
1394
  phaseDirty = true;
@@ -982,7 +1402,7 @@ export class PlanStore {
982
1402
  const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
983
1403
  sortedTasks.forEach((t, index) => {
984
1404
  if (!t.shortId) {
985
- t.shortId = createShortId(existing);
1405
+ t.shortId = createShortId(existing, `task:${t.number}:${t.id}`);
986
1406
  existing.add(t.shortId);
987
1407
  shortIdsAssigned += 1;
988
1408
  phaseDirty = true;
@@ -1022,13 +1442,37 @@ export class PlanStore {
1022
1442
  /** Repair dangling references and report integrity. One-shot maintenance op. */
1023
1443
  async repair() {
1024
1444
  return this.runAsBatch(async () => {
1445
+ // Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
1446
+ // upgrades plans initialized before the .local/ move.
1447
+ await this.ensureGitignore().catch(() => { });
1025
1448
  const migrated = await this.migratePhaseIds();
1449
+ await this.repairPhaseFeatureRefs();
1026
1450
  const backfill = await this.ensureShortIdsAndPriority();
1451
+ // Rebuild phase containment from each task's own phaseId. Heals plans
1452
+ // corrupted by the migrateToGlobalSequence index-mismatch bug (core
1453
+ // <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
1454
+ const containment = await this.rebuildContainment();
1027
1455
  const integrity = await this.validateIntegrity();
1028
1456
  await this.writeGenerated();
1029
- return { migrated, backfill, integrity };
1457
+ return { migrated, backfill, containment, integrity };
1030
1458
  });
1031
1459
  }
1460
+ async repairPhaseFeatureRefs() {
1461
+ const features = await this.loadRawFeatures();
1462
+ const phases = await this.loadAllPhases();
1463
+ let changed = 0;
1464
+ for (const phase of phases) {
1465
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1466
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
1467
+ await this.savePhase({ ...phase, featureId: resolvedFeatureId });
1468
+ changed += 1;
1469
+ }
1470
+ }
1471
+ if (changed > 0) {
1472
+ await this.updateFeatures((doc) => doc);
1473
+ }
1474
+ return changed;
1475
+ }
1032
1476
  /** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
1033
1477
  async validateIntegrity() {
1034
1478
  const phases = await this.loadAllPhases();
@@ -1074,18 +1518,28 @@ export class PlanStore {
1074
1518
  return "rejected";
1075
1519
  if (meaningful.every((s) => s === "done"))
1076
1520
  return "done";
1077
- // Lifecycle truth: any progress (active work OR partial completion)
1078
- // in-progress, until fully done. This is what prevents a single
1079
- // blocked/waiting/deferred task from poisoning the parent when there is
1080
- // substantial done or in-progress work (the long-standing rollup bug).
1081
- if (meaningful.some((s) => s === "in-progress") || meaningful.some((s) => s === "done"))
1521
+ const hasDone = meaningful.some((s) => s === "done");
1522
+ const hasActive = meaningful.some((s) => s === "in-progress");
1523
+ const hasPlanned = meaningful.some((s) => s === "planned");
1524
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1525
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1526
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1527
+ if (hasActive)
1528
+ return "in-progress";
1529
+ // If completed work exists and the ONLY remaining meaningful work is deferred,
1530
+ // surface deferred instead of implying active execution.
1531
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1532
+ return "deferred";
1533
+ // Partial completion with remaining planned/blocked/waiting work still means
1534
+ // the phase has genuinely started and is not terminal yet.
1535
+ if (hasDone)
1082
1536
  return "in-progress";
1083
1537
  // No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
1084
- if (meaningful.some((s) => s === "blocked"))
1538
+ if (hasBlocked)
1085
1539
  return "blocked";
1086
- if (meaningful.some((s) => s === "waiting"))
1540
+ if (hasWaiting)
1087
1541
  return "waiting";
1088
- if (meaningful.some((s) => s === "deferred"))
1542
+ if (hasDeferred)
1089
1543
  return "deferred";
1090
1544
  return "planned";
1091
1545
  }
@@ -1100,17 +1554,25 @@ export class PlanStore {
1100
1554
  return "rejected";
1101
1555
  if (meaningful.every((s) => s === "done"))
1102
1556
  return "done";
1103
- // Any progress (an active phase, or a partially-complete done phase)
1104
- // in-progress. Prevents a single stalled phase from poisoning the feature
1105
- // when other phases have done/in-progress work.
1106
- if (meaningful.some((s) => s === "discovery" || s === "in-progress") || meaningful.some((s) => s === "done"))
1557
+ const hasDone = meaningful.some((s) => s === "done");
1558
+ const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
1559
+ const hasPlanned = meaningful.some((s) => s === "planned");
1560
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1561
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1562
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1563
+ if (hasActive)
1564
+ return "in-progress";
1565
+ // Same rule as phases: done + deferred-only remainder is deferred, not active.
1566
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1567
+ return "deferred";
1568
+ if (hasDone)
1107
1569
  return "in-progress";
1108
1570
  // No progress at all ⇒ surface the stall / not-started state.
1109
- if (meaningful.some((s) => s === "blocked"))
1571
+ if (hasBlocked)
1110
1572
  return "blocked";
1111
- if (meaningful.some((s) => s === "waiting"))
1573
+ if (hasWaiting)
1112
1574
  return "waiting";
1113
- if (meaningful.some((s) => s === "deferred"))
1575
+ if (hasDeferred)
1114
1576
  return "deferred";
1115
1577
  return "planned";
1116
1578
  }
@@ -1128,17 +1590,70 @@ export class PlanStore {
1128
1590
  const phase = await this.loadPhase(phaseId);
1129
1591
  let cleared = null;
1130
1592
  if (phase.status === "done" && phase.handoff !== "") {
1131
- await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffUpdatedAt: nowISO() }));
1593
+ await this.clearPhaseHandoff(phaseId, "phase-done");
1132
1594
  const features = await this.loadFeatures();
1133
1595
  const feature = features.features.find((f) => f.id === phase.featureId);
1134
1596
  cleared = formatPhaseRef(phase.number, feature?.number);
1135
1597
  }
1598
+ // Append a statusLog entry to the phase when its DERIVED status changed
1599
+ // (audit trail; status itself is NOT persisted). Idempotent: only appends
1600
+ // when the new derived status differs from the last recorded toStatus.
1601
+ await this.#appendPhaseStatusLog(phaseId);
1602
+ // Roll up to the parent feature's statusLog too.
1603
+ if (phase.featureId)
1604
+ await this.#appendFeatureStatusLog(phase.featureId);
1136
1605
  await this.refreshResume();
1137
1606
  return cleared;
1138
1607
  }
1608
+ /** Append a PhaseStatusLogEntry to the phase when its derived status changed
1609
+ * vs. the last recorded toStatus (baseline "draft" when empty, matching the
1610
+ * phase-creation literal). Idempotent across repeated reads of the same state. */
1611
+ async #appendPhaseStatusLog(phaseId) {
1612
+ await this.updatePhase(phaseId, (p) => {
1613
+ const last = p.statusLog.at(-1)?.toStatus ?? "draft";
1614
+ if (p.status !== last) {
1615
+ p.statusLog = [...p.statusLog, {
1616
+ id: createStatusLogEntryId(),
1617
+ date: nowISO(),
1618
+ fromStatus: last,
1619
+ toStatus: p.status,
1620
+ title: `${last} → ${p.status}`,
1621
+ description: "",
1622
+ }];
1623
+ }
1624
+ return p;
1625
+ });
1626
+ }
1627
+ /** Append a StatusLogEntry to the feature when its derived status changed
1628
+ * vs. the last recorded toStatus (baseline "planned" when empty, matching
1629
+ * the feature-creation/empty-phases derivation). Idempotent. */
1630
+ async #appendFeatureStatusLog(featureId) {
1631
+ const features = await this.loadFeatures();
1632
+ const feature = features.features.find((f) => f.id === featureId);
1633
+ if (!feature)
1634
+ return;
1635
+ const last = feature.statusLog.at(-1)?.toStatus ?? "planned";
1636
+ if (feature.status !== last) {
1637
+ await this.updateFeatures((doc) => {
1638
+ const target = doc.features.find((f) => f.id === featureId);
1639
+ if (target && target.statusLog.at(-1)?.toStatus !== feature.status) {
1640
+ const baseline = target.statusLog.at(-1)?.toStatus ?? "planned";
1641
+ target.statusLog = [...target.statusLog, {
1642
+ id: createStatusLogEntryId(),
1643
+ date: nowISO(),
1644
+ fromStatus: baseline,
1645
+ toStatus: feature.status,
1646
+ title: `${baseline} → ${feature.status}`,
1647
+ description: "",
1648
+ }];
1649
+ }
1650
+ return doc;
1651
+ });
1652
+ }
1653
+ }
1139
1654
  // ── Savers ───────────────────────────────────────────────────────────
1140
1655
  async updateProject(updater) {
1141
- const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
1656
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
1142
1657
  await this.maybeAutoSync();
1143
1658
  return updated;
1144
1659
  }
@@ -1154,14 +1669,14 @@ export class PlanStore {
1154
1669
  return updated;
1155
1670
  }
1156
1671
  async updateRequirements(updater) {
1157
- const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
1672
+ const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
1158
1673
  await this.maybeAutoSync();
1159
1674
  return updated;
1160
1675
  }
1161
1676
  async saveProject(project) {
1162
1677
  const parsed = ProjectSchema.parse(project);
1163
- await atomicWriteJson(this.projectPath(), parsed);
1164
- await this.touchManifest();
1678
+ await atomicWriteJson(this.projectPath(), parsed, this.root);
1679
+ await this.touchTimestamp();
1165
1680
  await this.maybeAutoSync();
1166
1681
  }
1167
1682
  async saveFeatures(features) {
@@ -1169,7 +1684,7 @@ export class PlanStore {
1169
1684
  await this.migrateLegacy();
1170
1685
  await this.saveFeaturesRaw(features);
1171
1686
  });
1172
- await this.touchManifest();
1687
+ await this.touchTimestamp();
1173
1688
  await this.maybeAutoSync();
1174
1689
  }
1175
1690
  /** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
@@ -1178,7 +1693,7 @@ export class PlanStore {
1178
1693
  await mkdir(this.featuresDir(), { recursive: true });
1179
1694
  const wantIds = new Set(parsed.features.map((f) => f.id));
1180
1695
  for (const feat of parsed.features) {
1181
- await atomicWriteJson(this.featurePath(feat.id), feat);
1696
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
1182
1697
  }
1183
1698
  // Orphan reconcile: remove feature files no longer in the document.
1184
1699
  try {
@@ -1202,27 +1717,43 @@ export class PlanStore {
1202
1717
  await this.migrateLegacy();
1203
1718
  await mkdir(this.featuresDir(), { recursive: true });
1204
1719
  const parsed = FeatureSchema.parse(feature);
1205
- await atomicWriteJson(this.featurePath(parsed.id), parsed);
1720
+ await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
1206
1721
  });
1207
- await this.touchManifest();
1722
+ await this.touchTimestamp();
1208
1723
  await this.maybeAutoSync();
1209
1724
  }
1210
1725
  async saveRequirements(reqs) {
1211
1726
  const parsed = RequirementsDocumentSchema.parse(reqs);
1212
- await atomicWriteJson(this.requirementsPath(), parsed);
1213
- await this.touchManifest();
1727
+ await atomicWriteJson(this.requirementsPath(), parsed, this.root);
1728
+ await this.touchTimestamp();
1214
1729
  }
1215
1730
  async savePhase(phase) {
1216
- const parsed = PhaseSchema.parse(this.normalizePhaseDocument(phase).phase);
1731
+ const features = await this.loadRawFeatures();
1732
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1733
+ // Referential integrity: if a featureId is present but cannot be resolved
1734
+ // to a known feature, REJECT — never persist an orphan featureId.
1735
+ // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
1736
+ // legacy migrations, repair, and feature-delete (unlink) can persist phases
1737
+ // without a feature yet. The hard "featureId required" gate lives at the
1738
+ // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
1739
+ // planner-task-add), which is where user-facing creation happens.
1740
+ if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
1741
+ 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.`);
1742
+ }
1743
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
1744
+ ? { ...phase, featureId: resolvedFeatureId }
1745
+ : phase;
1746
+ const parsed = PhaseSchema.parse(this.normalizePhaseDocument(normalizedInput).phase);
1217
1747
  await mkdir(this.phasesDir(), { recursive: true });
1218
- await atomicWriteJson(this.phasePath(parsed.id), parsed);
1219
- await this.touchManifest();
1748
+ await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
1749
+ await this.touchTimestamp();
1220
1750
  await this.maybeAutoSync();
1221
1751
  }
1222
1752
  /** Atomic read-modify-write on a single phase file. Serializes concurrent
1223
1753
  * task_create / phase_update calls on the SAME phaseId so batch operations
1224
1754
  * don't lose tasks (last-write-wins race condition). */
1225
1755
  async updatePhase(phaseId, updater) {
1756
+ const features = await this.loadRawFeatures();
1226
1757
  // Augment the raw (on-disk) phase with its DERIVED status before handing it
1227
1758
  // to the updater, so updaters that read 'phase.status' see the truth. The
1228
1759
  // returned object's 'status' is stripped by PhaseSchema.parse (status is
@@ -1230,7 +1761,15 @@ export class PlanStore {
1230
1761
  const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
1231
1762
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
1232
1763
  const next = updater(current);
1233
- return this.normalizePhaseDocument(next).phase;
1764
+ const resolvedFeatureId = resolveStoredFeatureId(features, next.featureId);
1765
+ // Referential integrity: reject orphan featureId.
1766
+ if (next.featureId && next.featureId.trim() && !resolvedFeatureId) {
1767
+ throw new PlanStoreError(`Cannot update phase: featureId "${next.featureId}" does not match any existing feature.`);
1768
+ }
1769
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== next.featureId
1770
+ ? { ...next, featureId: resolvedFeatureId }
1771
+ : next;
1772
+ return this.normalizePhaseDocument(normalizedInput).phase;
1234
1773
  });
1235
1774
  await this.maybeAutoSync();
1236
1775
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
@@ -1246,10 +1785,74 @@ export class PlanStore {
1246
1785
  const now = new Date().toISOString();
1247
1786
  await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
1248
1787
  }
1249
- /** Clear the handoff text for a phase (handoff=""). handoffUpdatedAt is left
1250
- * unchanged as an audit trail (when a handoff last existed). */
1251
- async clearPhaseHandoff(phaseId) {
1252
- await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
1788
+ /** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
1789
+ * Does NOT clear the handoff content is kept until a task starts or the
1790
+ * phase completes, so a restart between read and resume does not lose it. */
1791
+ async markHandoffRead(phaseId) {
1792
+ await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
1793
+ }
1794
+ /** One-time import of a legacy .planner/HANDOFF.md file (file-based handoff
1795
+ * from before F004) into the entity-scoped phase.handoff. Idempotent: if the
1796
+ * file is absent or empty, no-op. If it exists + non-empty + the target phase
1797
+ * has no handoff, writes the content onto the current in-progress phase (or
1798
+ * the first phase if none in-progress) with an "imported" handoffHistory entry,
1799
+ * then renames the file to HANDOFF.md.bak so it won't re-import. If the target
1800
+ * already has a handoff, the entity-scoped one wins and the file is just .bak'd. */
1801
+ async importLegacyHandoffFile() {
1802
+ const filePath = join(this.root, "HANDOFF.md");
1803
+ const content = await readFile(filePath, "utf-8").catch(() => null);
1804
+ if (content === null)
1805
+ return { imported: false };
1806
+ if (content.trim() === "") {
1807
+ await rename(filePath, filePath + ".bak").catch(() => { });
1808
+ return { imported: false };
1809
+ }
1810
+ const phases = await this.loadAllPhases();
1811
+ const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
1812
+ if (!target)
1813
+ return { imported: false }; // no phases yet — leave file for a later run
1814
+ if ((target.handoff ?? "") === "") {
1815
+ await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
1816
+ await this.updatePhase(target.id, (p) => ({
1817
+ ...p,
1818
+ handoffHistory: [{ file: "(legacy HANDOFF.md)", clearedAt: nowISO(), reason: "imported" }, ...(p.handoffHistory ?? [])].slice(0, 5),
1819
+ }));
1820
+ }
1821
+ await rename(filePath, filePath + ".bak").catch(() => { });
1822
+ const features = await this.loadFeatures();
1823
+ const feat = features.features.find((f) => f.id === target.featureId);
1824
+ return { imported: true, phaseRef: formatPhaseRef(target.number, feat?.number) };
1825
+ }
1826
+ /** Clear the handoff for a phase, archiving its content first. The handoff
1827
+ * markdown is written to .planner/handoff-archive/<phaseId>-<ISO>.md and a
1828
+ * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
1829
+ * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
1830
+ * left unchanged as an audit trail. If the handoff is empty, this is a no-op.
1831
+ * reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
1832
+ async clearPhaseHandoff(phaseId, reason = "manual") {
1833
+ await this.migrateLegacyHandoffArchive();
1834
+ const phase = await this.loadPhase(phaseId).catch(() => null);
1835
+ if (!phase || phase.handoff === "")
1836
+ return; // nothing to archive
1837
+ const clearedAt = nowISO();
1838
+ const safeTs = clearedAt.replace(/[:.]/g, "-");
1839
+ const archiveDir = this.handoffArchiveDir();
1840
+ await mkdir(archiveDir, { recursive: true }).catch(() => { });
1841
+ const fileName = `${phaseId}-${safeTs}.md`;
1842
+ const filePath = join(archiveDir, fileName);
1843
+ await atomicWriteText(filePath, phase.handoff, this.root);
1844
+ const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
1845
+ // Cap history at 5: prepend new entry, drop oldest (and delete its file).
1846
+ const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
1847
+ const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
1848
+ for (const d of dropped) {
1849
+ if (!d?.file)
1850
+ continue;
1851
+ // Legacy entries used `.planner/handoff-archive/...`; new entries use `.planner/.local/handoff-archive/...`.
1852
+ await unlink(join(this.handoffArchiveDir(), basename(d.file))).catch(() => { });
1853
+ await unlink(join(this.root, d.file)).catch(() => { });
1854
+ }
1855
+ await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
1253
1856
  }
1254
1857
  /** List all phases that have a non-empty handoff, newest first, with a
1255
1858
  * human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
@@ -1266,14 +1869,64 @@ export class PlanStore {
1266
1869
  const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
1267
1870
  out.push({
1268
1871
  phaseId: p.id,
1872
+ featureId: p.featureId,
1269
1873
  compositeRef: formatPhaseRef(p.number, fnum),
1270
1874
  updatedAt: p.handoffUpdatedAt || p.updatedAt,
1271
1875
  firstLine: handoffFirstLine(p.handoff),
1876
+ content: p.handoff,
1272
1877
  });
1273
1878
  }
1274
1879
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1275
1880
  return out;
1276
1881
  }
1882
+ async listOrphanPhases() {
1883
+ const phases = await this.loadAllPhases();
1884
+ const featuresDoc = await this.loadFeatures();
1885
+ const out = [];
1886
+ for (const phase of phases) {
1887
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
1888
+ if (resolvedFeatureId)
1889
+ continue;
1890
+ const reason = phase.featureId?.trim()
1891
+ ? `feature not found: ${phase.featureId}`
1892
+ : "missing featureId";
1893
+ out.push({
1894
+ phaseId: phase.id,
1895
+ featureId: phase.featureId,
1896
+ shortId: phase.shortId,
1897
+ compositeRef: formatPhaseRef(phase.number),
1898
+ title: phase.title,
1899
+ reason,
1900
+ });
1901
+ }
1902
+ out.sort((a, b) => a.compositeRef.localeCompare(b.compositeRef));
1903
+ return out;
1904
+ }
1905
+ async cleanupOrphanPhases() {
1906
+ return this.runAsBatch(async () => {
1907
+ const found = await this.listOrphanPhases();
1908
+ if (found.length === 0)
1909
+ return { found, removed: [] };
1910
+ const orphanIds = new Set(found.map((phase) => phase.phaseId));
1911
+ for (const orphan of found) {
1912
+ try {
1913
+ await unlink(this.phasePath(orphan.phaseId));
1914
+ }
1915
+ catch {
1916
+ // already gone
1917
+ }
1918
+ }
1919
+ await this.updateFeatures((doc) => {
1920
+ for (const feature of doc.features) {
1921
+ feature.phaseIds = feature.phaseIds.filter((id) => !orphanIds.has(id));
1922
+ }
1923
+ return doc;
1924
+ });
1925
+ await this.touchTimestamp();
1926
+ await this.writeGenerated();
1927
+ return { found, removed: found };
1928
+ });
1929
+ }
1277
1930
  async deletePhase(phaseId) {
1278
1931
  try {
1279
1932
  await unlink(this.phasePath(phaseId));
@@ -1281,7 +1934,7 @@ export class PlanStore {
1281
1934
  catch {
1282
1935
  // already gone
1283
1936
  }
1284
- await this.touchManifest();
1937
+ await this.touchTimestamp();
1285
1938
  }
1286
1939
  // ── Workspace-level operations ─────────────────────────────────────
1287
1940
  /** Load the full workspace (manifest + phases + project + requirements + features) */
@@ -1294,8 +1947,10 @@ export class PlanStore {
1294
1947
  return { manifest, phases, project, features, requirements };
1295
1948
  }
1296
1949
  // ── Markdown generation ────────────────────────────────────────────
1297
- /** Load all data, render markdown, and write into generated/. */
1950
+ /** Load all data, render markdown, and write into generated/. Skips files
1951
+ * whose content is unchanged to avoid unnecessary backup churn. */
1298
1952
  async writeGenerated() {
1953
+ await this.migrateLegacyGeneratedDir();
1299
1954
  const { PlanRenderer } = await import("./renderer.js");
1300
1955
  const plan = await this.loadAll();
1301
1956
  const renderer = new PlanRenderer();
@@ -1311,21 +1966,27 @@ export class PlanStore {
1311
1966
  if (dir !== genDir) {
1312
1967
  await mkdir(dir, { recursive: true });
1313
1968
  }
1969
+ try {
1970
+ const existing = await readFile(fullPath, "utf-8");
1971
+ if (existing === content)
1972
+ continue;
1973
+ }
1974
+ catch {
1975
+ // file does not exist yet — write it
1976
+ }
1314
1977
  await writeFile(fullPath, content, "utf-8");
1315
1978
  written.push(relPath);
1316
1979
  }
1317
1980
  return written;
1318
1981
  }
1319
1982
  // ── Touch ────────────────────────────────────────────────────────────
1320
- /** Update manifest.updatedAt to reflect a change. */
1321
- async touchManifest() {
1983
+ /** Update .local/timestamp.json to reflect a change. */
1984
+ async touchTimestamp() {
1322
1985
  try {
1323
- const m = await this.loadManifest();
1324
- m.updatedAt = nowISO();
1325
- await atomicWriteJson(this.manifestPath(), m);
1986
+ await atomicWriteJson(this.timestampPath(), { updatedAt: nowISO() }, this.root);
1326
1987
  }
1327
1988
  catch {
1328
- // if manifest doesn't exist yet, skip
1989
+ // if .local/ doesn't exist yet, skip
1329
1990
  }
1330
1991
  }
1331
1992
  }