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

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 {
@@ -361,7 +543,12 @@ export class PlanStore {
361
543
  const phasesByFeature = new Map();
362
544
  const orphanPhases = [];
363
545
  for (const phase of phases) {
364
- if (phase.featureId) {
546
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
547
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
548
+ phase.featureId = resolvedFeatureId;
549
+ changed = true;
550
+ }
551
+ if (phase.featureId && featuresDoc.features.some((feature) => feature.id === phase.featureId)) {
365
552
  const bucket = phasesByFeature.get(phase.featureId) ?? [];
366
553
  bucket.push(phase);
367
554
  phasesByFeature.set(phase.featureId, bucket);
@@ -504,7 +691,7 @@ export class PlanStore {
504
691
  }
505
692
  await mkdir(this.featuresDir(), { recursive: true });
506
693
  for (const feat of legacy.features) {
507
- await atomicWriteJson(this.featurePath(feat.id), feat);
694
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
508
695
  }
509
696
  await unlink(this.featuresPath()).catch(() => { });
510
697
  }
@@ -515,16 +702,81 @@ export class PlanStore {
515
702
  return join(this.phasesDir(), `${phaseId}.json`);
516
703
  }
517
704
  generatedDir() {
518
- return join(this.root, "generated");
705
+ return join(this.localRoot(), "generated");
519
706
  }
520
707
  codebasePath() {
521
708
  return join(this.root, "codebase.json");
522
709
  }
523
710
  resumePath() {
524
- return join(this.root, "resume.json");
711
+ return join(this.localRoot(), "resume.json");
525
712
  }
526
713
  activityPath() {
527
- return join(this.root, "activity.json");
714
+ return join(this.localRoot(), "activity.json");
715
+ }
716
+ localRoot() {
717
+ return join(this.root, ".local");
718
+ }
719
+ timestampPath() {
720
+ return join(this.localRoot(), "timestamp.json");
721
+ }
722
+ backupsDir() {
723
+ return join(this.localRoot(), "backups");
724
+ }
725
+ tmpDir() {
726
+ return join(this.localRoot(), "tmp");
727
+ }
728
+ handoffArchiveDir() {
729
+ return join(this.localRoot(), "handoff-archive");
730
+ }
731
+ /** One-time migration for plans created before .planner/.local/ existed.
732
+ * Moves a legacy root-level file into .local/ if the legacy file exists and
733
+ * the .local/ counterpart does not. Safe to call on every load. */
734
+ async migrateLegacyLocalFile(oldPath, newPath) {
735
+ try {
736
+ await access(oldPath);
737
+ }
738
+ catch {
739
+ return;
740
+ }
741
+ try {
742
+ await access(newPath);
743
+ return;
744
+ }
745
+ catch { }
746
+ await mkdir(dirname(newPath), { recursive: true });
747
+ await rename(oldPath, newPath);
748
+ }
749
+ async migrateLegacyGeneratedDir() {
750
+ const oldDir = join(this.root, "generated");
751
+ const newDir = this.generatedDir();
752
+ try {
753
+ await access(oldDir);
754
+ }
755
+ catch {
756
+ return;
757
+ }
758
+ try {
759
+ await access(newDir);
760
+ return;
761
+ }
762
+ catch { }
763
+ await rename(oldDir, newDir);
764
+ }
765
+ async migrateLegacyHandoffArchive() {
766
+ const oldDir = join(this.root, "handoff-archive");
767
+ const newDir = this.handoffArchiveDir();
768
+ try {
769
+ await access(oldDir);
770
+ }
771
+ catch {
772
+ return;
773
+ }
774
+ try {
775
+ await access(newDir);
776
+ return;
777
+ }
778
+ catch { }
779
+ await rename(oldDir, newDir);
528
780
  }
529
781
  // ── Init ─────────────────────────────────────────────────────────────
530
782
  async init(projectName) {
@@ -534,7 +786,11 @@ export class PlanStore {
534
786
  await mkdir(this.root, { recursive: true });
535
787
  await mkdir(this.phasesDir(), { recursive: true });
536
788
  await mkdir(this.featuresDir(), { recursive: true });
789
+ await mkdir(this.localRoot(), { recursive: true });
790
+ await mkdir(this.backupsDir(), { recursive: true });
791
+ await mkdir(this.tmpDir(), { recursive: true });
537
792
  await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
793
+ await mkdir(this.handoffArchiveDir(), { recursive: true });
538
794
  await mkdir(join(this.root, "schema"), { recursive: true });
539
795
  await mkdir(join(this.root, "adapters"), { recursive: true });
540
796
  const manifest = {
@@ -544,7 +800,8 @@ export class PlanStore {
544
800
  createdAt: nowISO(),
545
801
  updatedAt: nowISO(),
546
802
  };
547
- await atomicWriteJson(this.manifestPath(), manifest);
803
+ await atomicWriteJson(this.manifestPath(), manifest, this.root);
804
+ await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
548
805
  await this.saveProject({
549
806
  name: projectName,
550
807
  goal: "",
@@ -567,6 +824,7 @@ export class PlanStore {
567
824
  nextFeatureNumber: 1,
568
825
  nextPhaseNumber: 1,
569
826
  nextTaskNumber: 1,
827
+ workDeviations: [],
570
828
  });
571
829
  await this.saveRequirements({ requirements: [] });
572
830
  await this.saveFeatures({ features: [] });
@@ -594,7 +852,7 @@ export class PlanStore {
594
852
  "- `project.json` — scope, rules, stack, tools",
595
853
  "- `requirements.json` — requirements and macro-tasks",
596
854
  "- `phases/` — one JSON file per phase",
597
- "- `generated/` — auto-generated markdown views",
855
+ "- `generated/` — auto-generated markdown views (under `.local/`)",
598
856
  "- `schema/plan.schema.json` — JSON Schema for tooling",
599
857
  ].join("\n");
600
858
  await writeFile(join(this.root, "README.md"), readme, "utf-8");
@@ -604,16 +862,29 @@ export class PlanStore {
604
862
  // - resume.json: per-session resume focus + the machine-local guard-bypass
605
863
  // timestamp (guardBypassUntil must NOT leak into git/other clones)
606
864
  // - 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");
865
+ await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
866
+ }
867
+ /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
868
+ * canonical transient/derived patterns). Projects initialized before the
869
+ * `.local/` move either have no `.planner/.gitignore` or one with stale
870
+ * root-level patterns. This upgrades them safely on load and on repair.
871
+ * Returns true if the file was (re)written. Safe to call on every load. */
872
+ async ensureGitignore() {
873
+ const gi = join(this.root, ".gitignore");
874
+ try {
875
+ const existing = await readFile(gi, "utf8").catch(() => null);
876
+ // Up to date iff it contains all canonical patterns (P042 spec):
877
+ // .local/ (transients), *.bak (crash backups), *.tmp.* (atomic-write
878
+ // temp files), generated/ (legacy dir).
879
+ if (existing != null && existing.includes(".local/") && existing.includes("*.bak") && existing.includes("*.tmp.*") && existing.includes("generated/")) {
880
+ return false;
881
+ }
882
+ await writeFile(gi, PLANNER_GITIGNORE, "utf-8");
883
+ return true;
884
+ }
885
+ catch {
886
+ return false;
887
+ }
617
888
  }
618
889
  async exists() {
619
890
  try {
@@ -625,29 +896,71 @@ export class PlanStore {
625
896
  }
626
897
  }
627
898
  // ── Loaders ──────────────────────────────────────────────────────────
899
+ /** Read-only manifest load. Upgrading legacy `.local` state is explicit
900
+ * maintenance (`repair`), never an incidental side effect of opening a plan. */
628
901
  async loadManifest() {
629
- return readJson(this.manifestPath(), ManifestSchema);
902
+ const manifest = await readJson(this.manifestPath(), ManifestSchema);
903
+ const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
904
+ return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
630
905
  }
631
906
  async loadProject() {
632
907
  return readJson(this.projectPath(), ProjectSchema);
633
908
  }
634
909
  /**
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).
910
+ * Reserve immutable human identifiers without touching tracked `project.json`.
911
+ * Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
912
+ * the same cross-process lock used for atomic writes. The registry reserves
913
+ * numbers/short IDs before an entity file is written, so parallel branches
914
+ * cannot allocate the same F/P/T or shortId. Existing entities are never
915
+ * rewritten; cross-clone coordination requires a shared allocator service.
641
916
  */
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) {
917
+ async allocateEntityIdentity(kind, entityId) {
646
918
  const project = await this.loadProject();
647
- const n = project[key];
648
- project[key] = n + 1;
649
- await this.saveProject(project);
650
- return n;
919
+ const manifest = await this.loadManifest();
920
+ const commonGitDir = await gitCommonDirFor(this.root);
921
+ const registryPath = commonGitDir
922
+ ? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
923
+ : join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
924
+ return withWriteLock(registryPath, async () => {
925
+ const registry = AllocationRegistrySchema.parse(await readFile(registryPath, "utf8")
926
+ .then(JSON.parse)
927
+ .catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
928
+ if (registry.projectId !== manifest.projectId)
929
+ throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
930
+ const prior = registry.allocations.find((entry) => entry.kind === kind && entry.entityId === entityId);
931
+ if (prior)
932
+ return { number: prior.number, shortId: prior.shortId };
933
+ const phases = await this.loadAllPhases();
934
+ const features = (await this.loadFeatures()).features;
935
+ const canonical = kind === "feature"
936
+ ? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
937
+ : kind === "phase"
938
+ ? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
939
+ : phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })));
940
+ const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
941
+ const counter = kind === "feature" ? project.nextFeatureNumber : kind === "phase" ? project.nextPhaseNumber : project.nextTaskNumber;
942
+ let number = Math.max(1, counter);
943
+ while (usedNumbers.has(number))
944
+ number += 1;
945
+ const allShortIds = new Set([
946
+ ...features.map((feature) => feature.shortId),
947
+ ...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
948
+ ...registry.allocations.map((entry) => entry.shortId),
949
+ ].filter(Boolean));
950
+ const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
951
+ registry.allocations.push(allocation);
952
+ await writeRegistry(registryPath, registry);
953
+ return { number: allocation.number, shortId: allocation.shortId };
954
+ });
955
+ }
956
+ /** Compatibility helpers. New callers should allocate the number and shortId
957
+ * together with allocateEntityIdentity so reservation cannot be split. */
958
+ async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
959
+ async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
960
+ async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
961
+ async allocateLegacyNumber(kind) {
962
+ const id = `legacy-${kind}-${randomUUID()}`;
963
+ return (await this.allocateEntityIdentity(kind, id)).number;
651
964
  }
652
965
  async loadPhase(phaseId) {
653
966
  const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
@@ -700,7 +1013,7 @@ export class PlanStore {
700
1013
  const raws = await this.loadRawFeatures();
701
1014
  const phases = await this.loadAllPhases();
702
1015
  const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
703
- return this.normalizeFeaturesDocument({ features }).doc;
1016
+ return this.normalizeStructureSnapshot({ features }, phases).features;
704
1017
  }
705
1018
  async loadCodebaseProfile() {
706
1019
  try {
@@ -712,10 +1025,11 @@ export class PlanStore {
712
1025
  }
713
1026
  async saveCodebaseProfile(profile) {
714
1027
  const parsed = CodebaseProfileSchema.parse(profile);
715
- await atomicWriteJson(this.codebasePath(), parsed);
716
- await this.touchManifest();
1028
+ await atomicWriteJson(this.codebasePath(), parsed, this.root);
1029
+ await this.touchTimestamp();
717
1030
  }
718
1031
  async loadResume() {
1032
+ await this.migrateLegacyLocalFile(join(this.root, "resume.json"), this.resumePath());
719
1033
  try {
720
1034
  return await readJson(this.resumePath(), ResumeFocusSchema);
721
1035
  }
@@ -736,8 +1050,8 @@ export class PlanStore {
736
1050
  : (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
737
1051
  };
738
1052
  const parsed = ResumeFocusSchema.parse(withTs);
739
- await atomicWriteJson(this.resumePath(), parsed);
740
- await this.touchManifest();
1053
+ await atomicWriteJson(this.resumePath(), parsed, this.root);
1054
+ await this.touchTimestamp();
741
1055
  }
742
1056
  /**
743
1057
  * Authorize a temporary guard bypass so edit/write tools may proceed even
@@ -783,6 +1097,7 @@ export class PlanStore {
783
1097
  return until > Date.now();
784
1098
  }
785
1099
  async loadActivityLog() {
1100
+ await this.migrateLegacyLocalFile(join(this.root, "activity.json"), this.activityPath());
786
1101
  try {
787
1102
  return await readJson(this.activityPath(), ActivityLogSchema);
788
1103
  }
@@ -798,8 +1113,8 @@ export class PlanStore {
798
1113
  // Cap to last 200 entries
799
1114
  if (log.entries.length > 200)
800
1115
  log.entries = log.entries.slice(-200);
801
- await atomicWriteJson(this.activityPath(), { entries: log.entries });
802
- await this.touchManifest();
1116
+ await atomicWriteJson(this.activityPath(), { entries: log.entries }, this.root);
1117
+ await this.touchTimestamp();
803
1118
  return entry;
804
1119
  }
805
1120
  /** Derive an up-to-date resume focus from the current workspace state. */
@@ -831,6 +1146,33 @@ export class PlanStore {
831
1146
  return { requirements: [] };
832
1147
  }
833
1148
  }
1149
+ async linkedRequirementsForPhase(phaseId) {
1150
+ const requirements = await this.loadRequirements();
1151
+ return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
1152
+ }
1153
+ /** Requirements linked to any phase belonging to a feature, deduplicated by ID. */
1154
+ async linkedRequirementsForFeature(featureId) {
1155
+ const [phases, requirements] = await Promise.all([this.loadAllPhases(), this.loadRequirements()]);
1156
+ const phaseIds = new Set(phases.filter((phase) => phase.featureId === featureId).map((phase) => phase.id));
1157
+ return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.some((phaseId) => phaseIds.has(phaseId)));
1158
+ }
1159
+ async loadPhaseWithRequirements(phaseId) {
1160
+ const [phase, linkedRequirements] = await Promise.all([
1161
+ this.loadPhase(phaseId),
1162
+ this.linkedRequirementsForPhase(phaseId),
1163
+ ]);
1164
+ return { ...phase, linkedRequirements };
1165
+ }
1166
+ async loadAllPhasesWithRequirements() {
1167
+ const [phases, requirements] = await Promise.all([
1168
+ this.loadAllPhases(),
1169
+ this.loadRequirements(),
1170
+ ]);
1171
+ return phases.map((phase) => ({
1172
+ ...phase,
1173
+ linkedRequirements: requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phase.id)),
1174
+ }));
1175
+ }
834
1176
  async loadAllPhases() {
835
1177
  const { readdir } = await import("node:fs/promises");
836
1178
  let files;
@@ -861,6 +1203,22 @@ export class PlanStore {
861
1203
  return left.createdAt.localeCompare(right.createdAt);
862
1204
  });
863
1205
  }
1206
+ /** Derive the parent display snapshot for a phase from its tasks' canonical
1207
+ * statuses. Pure, non-persisting. */
1208
+ async loadPhaseDisplay(phaseId) {
1209
+ const phase = await this.loadPhase(phaseId);
1210
+ const childStatuses = phase.tasks.map((t) => fromCanonicalStatus(t.status));
1211
+ return deriveParentDisplay(childStatuses);
1212
+ }
1213
+ /** Derive the parent display snapshot for a feature from its phases' DERIVED
1214
+ * canonical statuses (each phase status is derived from its tasks at read
1215
+ * time, then mapped via fromCanonicalStatus). Pure, non-persisting. */
1216
+ async loadFeatureDisplay(featureId) {
1217
+ const phases = await this.loadAllPhases();
1218
+ const featurePhases = phases.filter((p) => p.featureId === featureId);
1219
+ const childStatuses = featurePhases.map((p) => fromCanonicalStatus(p.status));
1220
+ return deriveParentDisplay(childStatuses);
1221
+ }
864
1222
  async loadAll() {
865
1223
  const [manifest, project, requirements, phases] = await Promise.all([
866
1224
  this.loadManifest(),
@@ -870,7 +1228,8 @@ export class PlanStore {
870
1228
  ]);
871
1229
  const rawFeatures = await this.loadRawFeatures();
872
1230
  const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
873
- return { manifest, project, requirements, phases, features: { features } };
1231
+ const normalized = this.normalizeStructureSnapshot({ features }, phases);
1232
+ return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
874
1233
  }
875
1234
  /** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
876
1235
  * dangling feature.phaseIds references. Idempotent. */
@@ -910,10 +1269,7 @@ export class PlanStore {
910
1269
  task.phaseId = newId;
911
1270
  }
912
1271
  await this.savePhase(phase);
913
- try {
914
- await unlink(this.phasePath(oldId));
915
- }
916
- catch { }
1272
+ await this.unlinkPhaseFiles(oldId);
917
1273
  renamed += 1;
918
1274
  }
919
1275
  // Repair feature.phaseIds: replace legacy refs with new ids, drop dangling ones.
@@ -1055,7 +1411,7 @@ export class PlanStore {
1055
1411
  const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
1056
1412
  sortedFeatures.forEach((f, index) => {
1057
1413
  if (!f.shortId) {
1058
- f.shortId = createShortId(existing);
1414
+ f.shortId = createShortId(existing, `feature:${f.number}:${f.id}`);
1059
1415
  existing.add(f.shortId);
1060
1416
  shortIdsAssigned += 1;
1061
1417
  featuresDirty = true;
@@ -1074,7 +1430,7 @@ export class PlanStore {
1074
1430
  for (const phase of phases) {
1075
1431
  let phaseDirty = false;
1076
1432
  if (!phase.shortId) {
1077
- phase.shortId = createShortId(existing);
1433
+ phase.shortId = createShortId(existing, `phase:${phase.number}:${phase.id}`);
1078
1434
  existing.add(phase.shortId);
1079
1435
  shortIdsAssigned += 1;
1080
1436
  phaseDirty = true;
@@ -1088,7 +1444,7 @@ export class PlanStore {
1088
1444
  const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
1089
1445
  sortedTasks.forEach((t, index) => {
1090
1446
  if (!t.shortId) {
1091
- t.shortId = createShortId(existing);
1447
+ t.shortId = createShortId(existing, `task:${t.number}:${t.id}`);
1092
1448
  existing.add(t.shortId);
1093
1449
  shortIdsAssigned += 1;
1094
1450
  phaseDirty = true;
@@ -1128,17 +1484,38 @@ export class PlanStore {
1128
1484
  /** Repair dangling references and report integrity. One-shot maintenance op. */
1129
1485
  async repair() {
1130
1486
  return this.runAsBatch(async () => {
1487
+ // Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
1488
+ // upgrades plans initialized before the .local/ move.
1489
+ await this.ensureGitignore().catch(() => { });
1131
1490
  const migrated = await this.migratePhaseIds();
1491
+ await this.repairPhaseFeatureRefs();
1132
1492
  const backfill = await this.ensureShortIdsAndPriority();
1133
1493
  // Rebuild phase containment from each task's own phaseId. Heals plans
1134
1494
  // corrupted by the migrateToGlobalSequence index-mismatch bug (core
1135
1495
  // <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
1136
1496
  const containment = await this.rebuildContainment();
1497
+ const handoffs = { archived: await this.archiveStaleHandoffs() };
1137
1498
  const integrity = await this.validateIntegrity();
1138
1499
  await this.writeGenerated();
1139
- return { migrated, backfill, containment, integrity };
1500
+ return { migrated, backfill, containment, handoffs, integrity };
1140
1501
  });
1141
1502
  }
1503
+ async repairPhaseFeatureRefs() {
1504
+ const features = await this.loadRawFeatures();
1505
+ const phases = await this.loadAllPhases();
1506
+ let changed = 0;
1507
+ for (const phase of phases) {
1508
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1509
+ if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
1510
+ await this.savePhase({ ...phase, featureId: resolvedFeatureId });
1511
+ changed += 1;
1512
+ }
1513
+ }
1514
+ if (changed > 0) {
1515
+ await this.updateFeatures((doc) => doc);
1516
+ }
1517
+ return changed;
1518
+ }
1142
1519
  /** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
1143
1520
  async validateIntegrity() {
1144
1521
  const phases = await this.loadAllPhases();
@@ -1174,6 +1551,12 @@ export class PlanStore {
1174
1551
  const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
1175
1552
  return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
1176
1553
  }
1554
+ /** A handoff remains active while any task needs work. Its automatic end-of-
1555
+ * phase lifecycle is deliberately narrower than canonical display status:
1556
+ * every task must be done or canceled (not merely derived "rejected"). */
1557
+ hasCompletedHandoffLifecycle(tasks) {
1558
+ return tasks.length > 0 && tasks.every((task) => task.status === "done" || task.status === "canceled");
1559
+ }
1177
1560
  derivePhaseStatus(tasks) {
1178
1561
  if (tasks.length === 0)
1179
1562
  return "draft";
@@ -1184,18 +1567,28 @@ export class PlanStore {
1184
1567
  return "rejected";
1185
1568
  if (meaningful.every((s) => s === "done"))
1186
1569
  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"))
1570
+ const hasDone = meaningful.some((s) => s === "done");
1571
+ const hasActive = meaningful.some((s) => s === "in-progress");
1572
+ const hasPlanned = meaningful.some((s) => s === "planned");
1573
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1574
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1575
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1576
+ if (hasActive)
1577
+ return "in-progress";
1578
+ // If completed work exists and the ONLY remaining meaningful work is deferred,
1579
+ // surface deferred instead of implying active execution.
1580
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1581
+ return "deferred";
1582
+ // Partial completion with remaining planned/blocked/waiting work still means
1583
+ // the phase has genuinely started and is not terminal yet.
1584
+ if (hasDone)
1192
1585
  return "in-progress";
1193
1586
  // No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
1194
- if (meaningful.some((s) => s === "blocked"))
1587
+ if (hasBlocked)
1195
1588
  return "blocked";
1196
- if (meaningful.some((s) => s === "waiting"))
1589
+ if (hasWaiting)
1197
1590
  return "waiting";
1198
- if (meaningful.some((s) => s === "deferred"))
1591
+ if (hasDeferred)
1199
1592
  return "deferred";
1200
1593
  return "planned";
1201
1594
  }
@@ -1210,17 +1603,25 @@ export class PlanStore {
1210
1603
  return "rejected";
1211
1604
  if (meaningful.every((s) => s === "done"))
1212
1605
  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"))
1606
+ const hasDone = meaningful.some((s) => s === "done");
1607
+ const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
1608
+ const hasPlanned = meaningful.some((s) => s === "planned");
1609
+ const hasBlocked = meaningful.some((s) => s === "blocked");
1610
+ const hasWaiting = meaningful.some((s) => s === "waiting");
1611
+ const hasDeferred = meaningful.some((s) => s === "deferred");
1612
+ if (hasActive)
1613
+ return "in-progress";
1614
+ // Same rule as phases: done + deferred-only remainder is deferred, not active.
1615
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1616
+ return "deferred";
1617
+ if (hasDone)
1217
1618
  return "in-progress";
1218
1619
  // No progress at all ⇒ surface the stall / not-started state.
1219
- if (meaningful.some((s) => s === "blocked"))
1620
+ if (hasBlocked)
1220
1621
  return "blocked";
1221
- if (meaningful.some((s) => s === "waiting"))
1622
+ if (hasWaiting)
1222
1623
  return "waiting";
1223
- if (meaningful.some((s) => s === "deferred"))
1624
+ if (hasDeferred)
1224
1625
  return "deferred";
1225
1626
  return "planned";
1226
1627
  }
@@ -1230,28 +1631,99 @@ export class PlanStore {
1230
1631
  // (serve.ts, adapters) that invoke it after mutations.
1231
1632
  return [];
1232
1633
  }
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. */
1634
+ /** Auto-clear a phase's handoff only when every task is terminal as done or
1635
+ * canceled. This covers an all-canceled phase too, whose legacy canonical
1636
+ * derived status is "rejected". Returns the composite ref when cleared. */
1237
1637
  async syncTaskStatusRollup(phaseId) {
1238
1638
  const phase = await this.loadPhase(phaseId);
1239
1639
  let cleared = null;
1240
- if (phase.status === "done" && phase.handoff !== "") {
1640
+ if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff !== "") {
1241
1641
  await this.clearPhaseHandoff(phaseId, "phase-done");
1242
1642
  const features = await this.loadFeatures();
1243
1643
  const feature = features.features.find((f) => f.id === phase.featureId);
1244
1644
  cleared = formatPhaseRef(phase.number, feature?.number);
1245
1645
  }
1646
+ // Append a statusLog entry to the phase when its DERIVED status changed
1647
+ // (audit trail; status itself is NOT persisted). Idempotent: only appends
1648
+ // when the new derived status differs from the last recorded toStatus.
1649
+ await this.#appendPhaseStatusLog(phaseId);
1650
+ // Roll up to the parent feature's statusLog too.
1651
+ if (phase.featureId)
1652
+ await this.#appendFeatureStatusLog(phase.featureId);
1246
1653
  await this.refreshResume();
1247
1654
  return cleared;
1248
1655
  }
1656
+ /** Append a PhaseStatusLogEntry to the phase when its derived status changed
1657
+ * vs. the last recorded toStatus (baseline "draft" when empty, matching the
1658
+ * phase-creation literal). Idempotent across repeated reads of the same state. */
1659
+ async #appendPhaseStatusLog(phaseId) {
1660
+ await this.updatePhase(phaseId, (p) => {
1661
+ const last = p.statusLog.at(-1)?.toStatus ?? "draft";
1662
+ if (p.status !== last) {
1663
+ p.statusLog = [...p.statusLog, {
1664
+ id: createStatusLogEntryId(),
1665
+ date: nowISO(),
1666
+ fromStatus: last,
1667
+ toStatus: p.status,
1668
+ title: `${last} → ${p.status}`,
1669
+ description: "",
1670
+ }];
1671
+ }
1672
+ return p;
1673
+ });
1674
+ }
1675
+ /** Append a StatusLogEntry to the feature when its derived status changed
1676
+ * vs. the last recorded toStatus (baseline "planned" when empty, matching
1677
+ * the feature-creation/empty-phases derivation). Idempotent. */
1678
+ async #appendFeatureStatusLog(featureId) {
1679
+ const features = await this.loadFeatures();
1680
+ const feature = features.features.find((f) => f.id === featureId);
1681
+ if (!feature)
1682
+ return;
1683
+ const last = feature.statusLog.at(-1)?.toStatus ?? "planned";
1684
+ if (feature.status !== last) {
1685
+ await this.updateFeatures((doc) => {
1686
+ const target = doc.features.find((f) => f.id === featureId);
1687
+ if (target && target.statusLog.at(-1)?.toStatus !== feature.status) {
1688
+ const baseline = target.statusLog.at(-1)?.toStatus ?? "planned";
1689
+ target.statusLog = [...target.statusLog, {
1690
+ id: createStatusLogEntryId(),
1691
+ date: nowISO(),
1692
+ fromStatus: baseline,
1693
+ toStatus: feature.status,
1694
+ title: `${baseline} → ${feature.status}`,
1695
+ description: "",
1696
+ }];
1697
+ }
1698
+ return doc;
1699
+ });
1700
+ }
1701
+ }
1249
1702
  // ── Savers ───────────────────────────────────────────────────────────
1250
1703
  async updateProject(updater) {
1251
- const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
1704
+ const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
1252
1705
  await this.maybeAutoSync();
1253
1706
  return updated;
1254
1707
  }
1708
+ /** Persist an explicitly approved work deviation without coupling it to a harness. */
1709
+ async addWorkDeviation(deviation) {
1710
+ return this.updateProject((project) => ({
1711
+ ...project,
1712
+ workDeviations: [...project.workDeviations, deviation],
1713
+ }));
1714
+ }
1715
+ /** Mark an approved/active deviation as resolved or canceled while retaining its audit record. */
1716
+ async setWorkDeviationState(id, state, timestamp = nowISO()) {
1717
+ return this.updateProject((project) => ({
1718
+ ...project,
1719
+ workDeviations: project.workDeviations.map((deviation) => deviation.id !== id ? deviation : {
1720
+ ...deviation,
1721
+ state,
1722
+ activatedAt: state === "active" ? timestamp : deviation.activatedAt,
1723
+ resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
1724
+ }),
1725
+ }));
1726
+ }
1255
1727
  async updateFeatures(updater) {
1256
1728
  const updated = await this.withFeaturesLock(async () => {
1257
1729
  await this.migrateLegacy();
@@ -1264,14 +1736,14 @@ export class PlanStore {
1264
1736
  return updated;
1265
1737
  }
1266
1738
  async updateRequirements(updater) {
1267
- const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
1739
+ const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
1268
1740
  await this.maybeAutoSync();
1269
1741
  return updated;
1270
1742
  }
1271
1743
  async saveProject(project) {
1272
1744
  const parsed = ProjectSchema.parse(project);
1273
- await atomicWriteJson(this.projectPath(), parsed);
1274
- await this.touchManifest();
1745
+ await atomicWriteJson(this.projectPath(), parsed, this.root);
1746
+ await this.touchTimestamp();
1275
1747
  await this.maybeAutoSync();
1276
1748
  }
1277
1749
  async saveFeatures(features) {
@@ -1279,7 +1751,7 @@ export class PlanStore {
1279
1751
  await this.migrateLegacy();
1280
1752
  await this.saveFeaturesRaw(features);
1281
1753
  });
1282
- await this.touchManifest();
1754
+ await this.touchTimestamp();
1283
1755
  await this.maybeAutoSync();
1284
1756
  }
1285
1757
  /** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
@@ -1288,7 +1760,7 @@ export class PlanStore {
1288
1760
  await mkdir(this.featuresDir(), { recursive: true });
1289
1761
  const wantIds = new Set(parsed.features.map((f) => f.id));
1290
1762
  for (const feat of parsed.features) {
1291
- await atomicWriteJson(this.featurePath(feat.id), feat);
1763
+ await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
1292
1764
  }
1293
1765
  // Orphan reconcile: remove feature files no longer in the document.
1294
1766
  try {
@@ -1312,27 +1784,43 @@ export class PlanStore {
1312
1784
  await this.migrateLegacy();
1313
1785
  await mkdir(this.featuresDir(), { recursive: true });
1314
1786
  const parsed = FeatureSchema.parse(feature);
1315
- await atomicWriteJson(this.featurePath(parsed.id), parsed);
1787
+ await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
1316
1788
  });
1317
- await this.touchManifest();
1789
+ await this.touchTimestamp();
1318
1790
  await this.maybeAutoSync();
1319
1791
  }
1320
1792
  async saveRequirements(reqs) {
1321
1793
  const parsed = RequirementsDocumentSchema.parse(reqs);
1322
- await atomicWriteJson(this.requirementsPath(), parsed);
1323
- await this.touchManifest();
1794
+ await atomicWriteJson(this.requirementsPath(), parsed, this.root);
1795
+ await this.touchTimestamp();
1324
1796
  }
1325
1797
  async savePhase(phase) {
1326
- const parsed = PhaseSchema.parse(this.normalizePhaseDocument(phase).phase);
1798
+ const features = await this.loadRawFeatures();
1799
+ const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
1800
+ // Referential integrity: if a featureId is present but cannot be resolved
1801
+ // to a known feature, REJECT — never persist an orphan featureId.
1802
+ // NOTE: a missing/empty featureId is intentionally ALLOWED here so that
1803
+ // legacy migrations, repair, and feature-delete (unlink) can persist phases
1804
+ // without a feature yet. The hard "featureId required" gate lives at the
1805
+ // adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
1806
+ // planner-task-add), which is where user-facing creation happens.
1807
+ if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
1808
+ 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.`);
1809
+ }
1810
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
1811
+ ? { ...phase, featureId: resolvedFeatureId }
1812
+ : phase;
1813
+ const parsed = PhaseSchema.parse(this.normalizePhaseDocument(normalizedInput).phase);
1327
1814
  await mkdir(this.phasesDir(), { recursive: true });
1328
- await atomicWriteJson(this.phasePath(parsed.id), parsed);
1329
- await this.touchManifest();
1815
+ await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
1816
+ await this.touchTimestamp();
1330
1817
  await this.maybeAutoSync();
1331
1818
  }
1332
1819
  /** Atomic read-modify-write on a single phase file. Serializes concurrent
1333
1820
  * task_create / phase_update calls on the SAME phaseId so batch operations
1334
1821
  * don't lose tasks (last-write-wins race condition). */
1335
1822
  async updatePhase(phaseId, updater) {
1823
+ const features = await this.loadRawFeatures();
1336
1824
  // Augment the raw (on-disk) phase with its DERIVED status before handing it
1337
1825
  // to the updater, so updaters that read 'phase.status' see the truth. The
1338
1826
  // returned object's 'status' is stripped by PhaseSchema.parse (status is
@@ -1340,7 +1828,15 @@ export class PlanStore {
1340
1828
  const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
1341
1829
  const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
1342
1830
  const next = updater(current);
1343
- return this.normalizePhaseDocument(next).phase;
1831
+ const resolvedFeatureId = resolveStoredFeatureId(features, next.featureId);
1832
+ // Referential integrity: reject orphan featureId.
1833
+ if (next.featureId && next.featureId.trim() && !resolvedFeatureId) {
1834
+ throw new PlanStoreError(`Cannot update phase: featureId "${next.featureId}" does not match any existing feature.`);
1835
+ }
1836
+ const normalizedInput = resolvedFeatureId && resolvedFeatureId !== next.featureId
1837
+ ? { ...next, featureId: resolvedFeatureId }
1838
+ : next;
1839
+ return this.normalizePhaseDocument(normalizedInput).phase;
1344
1840
  });
1345
1841
  await this.maybeAutoSync();
1346
1842
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
@@ -1350,21 +1846,23 @@ export class PlanStore {
1350
1846
  async getPhaseHandoff(phaseId) {
1351
1847
  return (await this.loadPhase(phaseId)).handoff;
1352
1848
  }
1353
- /** Set the handoff text for a phase + stamp handoffUpdatedAt. Atomic per-file
1354
- * update via updatePhase (so the web UI refreshes via maybeAutoSync). */
1849
+ /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
1850
+ * canceled phase cannot receive a new operational handoff. Replacing an
1851
+ * existing handoff archives the previous content as `superseded` first. */
1355
1852
  async setPhaseHandoff(phaseId, text) {
1853
+ const phase = await this.loadPhase(phaseId);
1854
+ const normalized = text.trim();
1855
+ if (phase.status === "done" || phase.status === "canceled") {
1856
+ throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; completed phases have no pending handoff.`);
1857
+ }
1858
+ if (phase.handoff && normalized && phase.handoff !== normalized) {
1859
+ await this.clearPhaseHandoff(phaseId, "superseded");
1860
+ }
1356
1861
  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");
1862
+ await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
1364
1863
  }
1365
1864
  /** 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. */
1865
+ * Does NOT clear it: read/load/show are non-mutating resume operations. */
1368
1866
  async markHandoffRead(phaseId) {
1369
1867
  await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
1370
1868
  }
@@ -1385,9 +1883,11 @@ export class PlanStore {
1385
1883
  return { imported: false };
1386
1884
  }
1387
1885
  const phases = await this.loadAllPhases();
1388
- const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
1886
+ const target = phases.find((p) => p.status === "in-progress")
1887
+ ?? phases.find((p) => p.status !== "done" && p.status !== "canceled")
1888
+ ?? null;
1389
1889
  if (!target)
1390
- return { imported: false }; // no phases yet — leave file for a later run
1890
+ return { imported: false }; // no non-completed phase — leave file for a later run
1391
1891
  if ((target.handoff ?? "") === "") {
1392
1892
  await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
1393
1893
  await this.updatePhase(target.id, (p) => ({
@@ -1405,8 +1905,9 @@ export class PlanStore {
1405
1905
  * metadata entry { file, clearedAt, reason } is prepended to handoffHistory
1406
1906
  * (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
1407
1907
  * 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". */
1908
+ * reason: "phase-done" | "manual" | "superseded" | "imported". */
1409
1909
  async clearPhaseHandoff(phaseId, reason = "manual") {
1910
+ await this.migrateLegacyHandoffArchive();
1410
1911
  const phase = await this.loadPhase(phaseId).catch(() => null);
1411
1912
  if (!phase || phase.handoff === "")
1412
1913
  return; // nothing to archive
@@ -1416,28 +1917,52 @@ export class PlanStore {
1416
1917
  await mkdir(archiveDir, { recursive: true }).catch(() => { });
1417
1918
  const fileName = `${phaseId}-${safeTs}.md`;
1418
1919
  const filePath = join(archiveDir, fileName);
1419
- await atomicWriteText(filePath, phase.handoff);
1920
+ await atomicWriteText(filePath, phase.handoff, this.root);
1420
1921
  const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
1421
1922
  // Cap history at 5: prepend new entry, drop oldest (and delete its file).
1422
1923
  const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
1423
1924
  const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
1424
1925
  for (const d of dropped) {
1425
- if (d?.file)
1426
- await unlink(join(this.root, d.file)).catch(() => { });
1926
+ if (!d?.file)
1927
+ continue;
1928
+ // Legacy entries used `.planner/handoff-archive/...`; new entries use `.planner/.local/handoff-archive/...`.
1929
+ await unlink(join(this.handoffArchiveDir(), basename(d.file))).catch(() => { });
1930
+ await unlink(join(this.root, d.file)).catch(() => { });
1427
1931
  }
1428
1932
  await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
1429
1933
  }
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. */
1934
+ /** Archive stale handoffs only after every task in their phase is done or
1935
+ * canceled. Idempotent: only non-empty phase.handoff values are moved. */
1936
+ async archiveStaleHandoffs() {
1937
+ const phases = await this.loadAllPhases();
1938
+ let archived = 0;
1939
+ for (const phase of phases) {
1940
+ if (this.hasCompletedHandoffLifecycle(phase.tasks) && phase.handoff) {
1941
+ await this.clearPhaseHandoff(phase.id, "phase-done");
1942
+ archived += 1;
1943
+ }
1944
+ }
1945
+ return archived;
1946
+ }
1947
+ /** Public maintenance operation for retroactively archiving stale handoffs. */
1948
+ async cleanupStaleHandoffs() {
1949
+ return this.runAsBatch(() => this.archiveStaleHandoffs());
1950
+ }
1951
+ /** List only active/pending phase handoffs, newest first. Handoffs from
1952
+ * phases where every task is done/canceled are archived before returning. */
1432
1953
  async listHandoffs() {
1954
+ await this.archiveStaleHandoffs();
1433
1955
  const phases = await this.loadAllPhases();
1434
1956
  const features = await this.loadFeatures();
1957
+ const featureIds = new Set(features.features.map((f) => f.id));
1435
1958
  const featureNumber = new Map();
1436
1959
  for (const f of features.features)
1437
1960
  featureNumber.set(f.id, f.number);
1438
1961
  const out = [];
1439
1962
  for (const p of phases) {
1440
- if (!p.handoff)
1963
+ if (!p.handoff || p.status === "done" || p.status === "canceled")
1964
+ continue;
1965
+ if (p.featureId && !featureIds.has(p.featureId))
1441
1966
  continue;
1442
1967
  const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
1443
1968
  out.push({
@@ -1452,14 +1977,96 @@ export class PlanStore {
1452
1977
  out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1453
1978
  return out;
1454
1979
  }
1455
- async deletePhase(phaseId) {
1456
- try {
1457
- await unlink(this.phasePath(phaseId));
1980
+ /** List recoverable archived handoffs from phase.handoffHistory. Archived
1981
+ * entries are never returned by listHandoffs() and are safe to show on a
1982
+ * dedicated history page. */
1983
+ async listArchivedHandoffs() {
1984
+ const phases = await this.loadAllPhases();
1985
+ const features = await this.loadFeatures();
1986
+ const featureNumber = new Map();
1987
+ for (const f of features.features)
1988
+ featureNumber.set(f.id, f.number);
1989
+ const out = [];
1990
+ for (const phase of phases) {
1991
+ const fnum = phase.featureId ? featureNumber.get(phase.featureId) : undefined;
1992
+ for (const entry of phase.handoffHistory ?? []) {
1993
+ if (!entry.file)
1994
+ continue;
1995
+ const localPath = join(this.localRoot(), entry.file);
1996
+ const legacyPath = join(this.root, entry.file);
1997
+ const content = await readFile(localPath, "utf-8").catch(() => readFile(legacyPath, "utf-8").catch(() => ""));
1998
+ out.push({
1999
+ phaseId: phase.id,
2000
+ featureId: phase.featureId,
2001
+ compositeRef: formatPhaseRef(phase.number, fnum),
2002
+ file: entry.file,
2003
+ archivedAt: entry.clearedAt,
2004
+ reason: entry.reason,
2005
+ firstLine: handoffFirstLine(content),
2006
+ content,
2007
+ });
2008
+ }
1458
2009
  }
1459
- catch {
1460
- // already gone
2010
+ out.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
2011
+ return out;
2012
+ }
2013
+ async listOrphanPhases() {
2014
+ const phases = await this.loadAllPhases();
2015
+ const featuresDoc = await this.loadFeatures();
2016
+ const out = [];
2017
+ for (const phase of phases) {
2018
+ const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
2019
+ if (resolvedFeatureId)
2020
+ continue;
2021
+ const reason = phase.featureId?.trim()
2022
+ ? `feature not found: ${phase.featureId}`
2023
+ : "missing featureId";
2024
+ out.push({
2025
+ phaseId: phase.id,
2026
+ featureId: phase.featureId,
2027
+ shortId: phase.shortId,
2028
+ compositeRef: formatPhaseRef(phase.number),
2029
+ title: phase.title,
2030
+ reason,
2031
+ });
1461
2032
  }
1462
- await this.touchManifest();
2033
+ out.sort((a, b) => a.compositeRef.localeCompare(b.compositeRef));
2034
+ return out;
2035
+ }
2036
+ async cleanupOrphanPhases() {
2037
+ return this.runAsBatch(async () => {
2038
+ const found = await this.listOrphanPhases();
2039
+ if (found.length === 0)
2040
+ return { found, removed: [] };
2041
+ const orphanIds = new Set(found.map((phase) => phase.phaseId));
2042
+ for (const orphan of found) {
2043
+ await this.unlinkPhaseFiles(orphan.phaseId);
2044
+ }
2045
+ await this.updateFeatures((doc) => {
2046
+ for (const feature of doc.features) {
2047
+ feature.phaseIds = feature.phaseIds.filter((id) => !orphanIds.has(id));
2048
+ }
2049
+ return doc;
2050
+ });
2051
+ await this.touchTimestamp();
2052
+ await this.writeGenerated();
2053
+ return { found, removed: found };
2054
+ });
2055
+ }
2056
+ async deletePhase(phaseId) {
2057
+ await this.unlinkPhaseFiles(phaseId);
2058
+ await this.touchTimestamp();
2059
+ }
2060
+ /** Remove a phase file AND its inline .bak backup. atomicUpdateJson (used by
2061
+ * updatePhase without root) writes the backup inline at phases/<id>.json.bak,
2062
+ * and readJson falls back to `${path}.bak` on a missing main file — so a
2063
+ * delete that leaves the .bak behind would RESURRECT the deleted phase on
2064
+ * the next read. Feature backups (written with root) live under
2065
+ * .local/backups/ and are never read by readJson, so only the inline .bak
2066
+ * needs removing here. */
2067
+ async unlinkPhaseFiles(phaseId) {
2068
+ await unlink(this.phasePath(phaseId)).catch(() => { });
2069
+ await unlink(`${this.phasePath(phaseId)}.bak`).catch(() => { });
1463
2070
  }
1464
2071
  // ── Workspace-level operations ─────────────────────────────────────
1465
2072
  /** Load the full workspace (manifest + phases + project + requirements + features) */
@@ -1472,8 +2079,10 @@ export class PlanStore {
1472
2079
  return { manifest, phases, project, features, requirements };
1473
2080
  }
1474
2081
  // ── Markdown generation ────────────────────────────────────────────
1475
- /** Load all data, render markdown, and write into generated/. */
2082
+ /** Load all data, render markdown, and write into generated/. Skips files
2083
+ * whose content is unchanged to avoid unnecessary backup churn. */
1476
2084
  async writeGenerated() {
2085
+ await this.migrateLegacyGeneratedDir();
1477
2086
  const { PlanRenderer } = await import("./renderer.js");
1478
2087
  const plan = await this.loadAll();
1479
2088
  const renderer = new PlanRenderer();
@@ -1489,21 +2098,27 @@ export class PlanStore {
1489
2098
  if (dir !== genDir) {
1490
2099
  await mkdir(dir, { recursive: true });
1491
2100
  }
2101
+ try {
2102
+ const existing = await readFile(fullPath, "utf-8");
2103
+ if (existing === content)
2104
+ continue;
2105
+ }
2106
+ catch {
2107
+ // file does not exist yet — write it
2108
+ }
1492
2109
  await writeFile(fullPath, content, "utf-8");
1493
2110
  written.push(relPath);
1494
2111
  }
1495
2112
  return written;
1496
2113
  }
1497
2114
  // ── Touch ────────────────────────────────────────────────────────────
1498
- /** Update manifest.updatedAt to reflect a change. */
1499
- async touchManifest() {
2115
+ /** Update .local/timestamp.json to reflect a change. */
2116
+ async touchTimestamp() {
1500
2117
  try {
1501
- const m = await this.loadManifest();
1502
- m.updatedAt = nowISO();
1503
- await atomicWriteJson(this.manifestPath(), m);
2118
+ await atomicWriteJson(this.timestampPath(), { updatedAt: nowISO() }, this.root);
1504
2119
  }
1505
2120
  catch {
1506
- // if manifest doesn't exist yet, skip
2121
+ // if .local/ doesn't exist yet, skip
1507
2122
  }
1508
2123
  }
1509
2124
  }