@agent-plan/core 0.2.19-next.2 → 0.2.19-next.21
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.
- package/dist/checklist.d.ts +16 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +52 -0
- package/dist/display-status.d.ts +99 -0
- package/dist/display-status.d.ts.map +1 -0
- package/dist/display-status.js +176 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/naming.d.ts +21 -3
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +45 -3
- package/dist/plan-store.d.ts +205 -22
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +940 -187
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +35 -9
- package/dist/refs.d.ts +23 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +81 -0
- package/dist/schema.d.ts +806 -128
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +73 -4
- package/dist/task-context.d.ts +16 -0
- package/dist/task-context.d.ts.map +1 -0
- package/dist/task-context.js +65 -0
- package/dist/task-selection.d.ts +23 -0
- package/dist/task-selection.d.ts.map +1 -0
- package/dist/task-selection.js +66 -0
- package/package.json +4 -1
package/dist/plan-store.js
CHANGED
|
@@ -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 {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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) ===
|
|
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
|
-
|
|
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) ===
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
665
|
+
return join(this.localRoot(), "resume.json");
|
|
410
666
|
}
|
|
411
667
|
activityPath() {
|
|
412
|
-
return join(this.
|
|
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);
|
|
413
702
|
}
|
|
414
|
-
|
|
415
|
-
|
|
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);
|
|
718
|
+
}
|
|
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,10 @@ export class PlanStore {
|
|
|
452
775
|
beforeTaskStart: [],
|
|
453
776
|
afterPhaseComplete: [],
|
|
454
777
|
},
|
|
778
|
+
nextFeatureNumber: 1,
|
|
779
|
+
nextPhaseNumber: 1,
|
|
780
|
+
nextTaskNumber: 1,
|
|
781
|
+
workDeviations: [],
|
|
455
782
|
});
|
|
456
783
|
await this.saveRequirements({ requirements: [] });
|
|
457
784
|
await this.saveFeatures({ features: [] });
|
|
@@ -460,6 +787,7 @@ export class PlanStore {
|
|
|
460
787
|
currentPhaseId: "",
|
|
461
788
|
inProgressTaskIds: [],
|
|
462
789
|
nextSteps: ["Run /planner project discuss to bootstrap discovery"],
|
|
790
|
+
nextStepsUpdatedAt: nowISO(),
|
|
463
791
|
blockers: [],
|
|
464
792
|
notes: "Project initialized. Awaiting discovery.",
|
|
465
793
|
lastSessionSummary: "",
|
|
@@ -478,7 +806,7 @@ export class PlanStore {
|
|
|
478
806
|
"- `project.json` — scope, rules, stack, tools",
|
|
479
807
|
"- `requirements.json` — requirements and macro-tasks",
|
|
480
808
|
"- `phases/` — one JSON file per phase",
|
|
481
|
-
"- `generated/` — auto-generated markdown views",
|
|
809
|
+
"- `generated/` — auto-generated markdown views (under `.local/`)",
|
|
482
810
|
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
483
811
|
].join("\n");
|
|
484
812
|
await writeFile(join(this.root, "README.md"), readme, "utf-8");
|
|
@@ -488,15 +816,29 @@ export class PlanStore {
|
|
|
488
816
|
// - resume.json: per-session resume focus + the machine-local guard-bypass
|
|
489
817
|
// timestamp (guardBypassUntil must NOT leak into git/other clones)
|
|
490
818
|
// - generated/: auto-regenerated markdown views (derived from JSON; churn)
|
|
491
|
-
await writeFile(join(this.root, ".gitignore"),
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
819
|
+
await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
|
|
820
|
+
}
|
|
821
|
+
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
822
|
+
* canonical transient/derived patterns). Projects initialized before the
|
|
823
|
+
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
824
|
+
* root-level patterns. This upgrades them safely on load and on repair.
|
|
825
|
+
* Returns true if the file was (re)written. Safe to call on every load. */
|
|
826
|
+
async ensureGitignore() {
|
|
827
|
+
const gi = join(this.root, ".gitignore");
|
|
828
|
+
try {
|
|
829
|
+
const existing = await readFile(gi, "utf8").catch(() => null);
|
|
830
|
+
// Up to date iff it contains all canonical patterns (P042 spec):
|
|
831
|
+
// .local/ (transients), *.bak (crash backups), *.tmp.* (atomic-write
|
|
832
|
+
// temp files), generated/ (legacy dir).
|
|
833
|
+
if (existing != null && existing.includes(".local/") && existing.includes("*.bak") && existing.includes("*.tmp.*") && existing.includes("generated/")) {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
await writeFile(gi, PLANNER_GITIGNORE, "utf-8");
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
500
842
|
}
|
|
501
843
|
async exists() {
|
|
502
844
|
try {
|
|
@@ -509,11 +851,79 @@ export class PlanStore {
|
|
|
509
851
|
}
|
|
510
852
|
// ── Loaders ──────────────────────────────────────────────────────────
|
|
511
853
|
async loadManifest() {
|
|
512
|
-
|
|
854
|
+
// Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
|
|
855
|
+
// upgrades plans initialized before the .local/ move. Runs on every load.
|
|
856
|
+
await this.ensureGitignore().catch(() => { });
|
|
857
|
+
const manifest = await readJson(this.manifestPath(), ManifestSchema);
|
|
858
|
+
try {
|
|
859
|
+
const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema }));
|
|
860
|
+
manifest.updatedAt = timestamp.updatedAt;
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
// Legacy plan without .local/timestamp.json: bootstrap from manifest.updatedAt
|
|
864
|
+
// and create the timestamp file so subsequent writes stay in .local/.
|
|
865
|
+
try {
|
|
866
|
+
await mkdir(this.localRoot(), { recursive: true });
|
|
867
|
+
await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// ignore bootstrap failure
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return manifest;
|
|
513
874
|
}
|
|
514
875
|
async loadProject() {
|
|
515
876
|
return readJson(this.projectPath(), ProjectSchema);
|
|
516
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Allocate the next global sequence number for a feature/phase/task.
|
|
880
|
+
* Reads the monotonic counter from project.json, increments it, persists,
|
|
881
|
+
* and returns the allocated number. MUST be called within withFeatureLock
|
|
882
|
+
* (adapters create entities inside a lock) so the counter is race-free.
|
|
883
|
+
* The counter never reuses a number — deletions leave gaps (by design:
|
|
884
|
+
* stable references survive deletion).
|
|
885
|
+
*/
|
|
886
|
+
async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber", "feature"); }
|
|
887
|
+
async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber", "phase"); }
|
|
888
|
+
async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber", "task"); }
|
|
889
|
+
/** Allocate a globally-unique sequence number. `atomicUpdateJson` already
|
|
890
|
+
* serializes concurrent calls via `withWriteLock` on the project file, so
|
|
891
|
+
* the read-modify-write is race-free in-process. The collision guard
|
|
892
|
+
* additionally skips any candidate that already exists in the data (safety
|
|
893
|
+
* net for cross-process races or manual edits) and persists the corrected
|
|
894
|
+
* counter. */
|
|
895
|
+
async allocSeqNumber(key, kind) {
|
|
896
|
+
// Load already-used numbers for this kind (best-effort read; the
|
|
897
|
+
// atomicUpdateJson below is the authoritative write).
|
|
898
|
+
const used = new Set();
|
|
899
|
+
if (kind === "task") {
|
|
900
|
+
const phases = await this.loadAllPhases();
|
|
901
|
+
for (const p of phases)
|
|
902
|
+
for (const t of p.tasks)
|
|
903
|
+
used.add(t.number);
|
|
904
|
+
}
|
|
905
|
+
else if (kind === "phase") {
|
|
906
|
+
const phases = await this.loadAllPhases();
|
|
907
|
+
for (const p of phases)
|
|
908
|
+
used.add(p.number);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
const feats = await this.loadRawFeatures();
|
|
912
|
+
for (const f of feats)
|
|
913
|
+
used.add(f.number);
|
|
914
|
+
}
|
|
915
|
+
let allocated = 0;
|
|
916
|
+
await this.updateProject((project) => {
|
|
917
|
+
let candidate = project[key];
|
|
918
|
+
// Collision guard: skip any candidate that already exists.
|
|
919
|
+
while (used.has(candidate))
|
|
920
|
+
candidate++;
|
|
921
|
+
allocated = candidate;
|
|
922
|
+
project[key] = candidate + 1;
|
|
923
|
+
return project;
|
|
924
|
+
});
|
|
925
|
+
return allocated;
|
|
926
|
+
}
|
|
517
927
|
async loadPhase(phaseId) {
|
|
518
928
|
const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
|
|
519
929
|
const normalized = this.normalizePhaseDocument(raw).phase;
|
|
@@ -565,7 +975,7 @@ export class PlanStore {
|
|
|
565
975
|
const raws = await this.loadRawFeatures();
|
|
566
976
|
const phases = await this.loadAllPhases();
|
|
567
977
|
const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
568
|
-
return this.
|
|
978
|
+
return this.normalizeStructureSnapshot({ features }, phases).features;
|
|
569
979
|
}
|
|
570
980
|
async loadCodebaseProfile() {
|
|
571
981
|
try {
|
|
@@ -577,10 +987,11 @@ export class PlanStore {
|
|
|
577
987
|
}
|
|
578
988
|
async saveCodebaseProfile(profile) {
|
|
579
989
|
const parsed = CodebaseProfileSchema.parse(profile);
|
|
580
|
-
await atomicWriteJson(this.codebasePath(), parsed);
|
|
581
|
-
await this.
|
|
990
|
+
await atomicWriteJson(this.codebasePath(), parsed, this.root);
|
|
991
|
+
await this.touchTimestamp();
|
|
582
992
|
}
|
|
583
993
|
async loadResume() {
|
|
994
|
+
await this.migrateLegacyLocalFile(join(this.root, "resume.json"), this.resumePath());
|
|
584
995
|
try {
|
|
585
996
|
return await readJson(this.resumePath(), ResumeFocusSchema);
|
|
586
997
|
}
|
|
@@ -589,9 +1000,20 @@ export class PlanStore {
|
|
|
589
1000
|
}
|
|
590
1001
|
}
|
|
591
1002
|
async saveResume(resume) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1003
|
+
// Track when `nextSteps` actually change (free-text can go stale; the recap
|
|
1004
|
+
// surfaces nextStepsUpdatedAt so staleness is visible). Preserved when
|
|
1005
|
+
// refreshResume keeps existing nextSteps; bumped only on a real change.
|
|
1006
|
+
const existing = await this.loadResume().catch(() => null);
|
|
1007
|
+
const nextStepsChanged = JSON.stringify(existing?.nextSteps ?? []) !== JSON.stringify(resume.nextSteps ?? []);
|
|
1008
|
+
const withTs = {
|
|
1009
|
+
...resume,
|
|
1010
|
+
nextStepsUpdatedAt: nextStepsChanged
|
|
1011
|
+
? nowISO()
|
|
1012
|
+
: (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
|
|
1013
|
+
};
|
|
1014
|
+
const parsed = ResumeFocusSchema.parse(withTs);
|
|
1015
|
+
await atomicWriteJson(this.resumePath(), parsed, this.root);
|
|
1016
|
+
await this.touchTimestamp();
|
|
595
1017
|
}
|
|
596
1018
|
/**
|
|
597
1019
|
* Authorize a temporary guard bypass so edit/write tools may proceed even
|
|
@@ -605,6 +1027,7 @@ export class PlanStore {
|
|
|
605
1027
|
currentPhaseId: "",
|
|
606
1028
|
inProgressTaskIds: [],
|
|
607
1029
|
nextSteps: [],
|
|
1030
|
+
nextStepsUpdatedAt: "",
|
|
608
1031
|
blockers: [],
|
|
609
1032
|
notes: "",
|
|
610
1033
|
lastSessionSummary: "",
|
|
@@ -636,6 +1059,7 @@ export class PlanStore {
|
|
|
636
1059
|
return until > Date.now();
|
|
637
1060
|
}
|
|
638
1061
|
async loadActivityLog() {
|
|
1062
|
+
await this.migrateLegacyLocalFile(join(this.root, "activity.json"), this.activityPath());
|
|
639
1063
|
try {
|
|
640
1064
|
return await readJson(this.activityPath(), ActivityLogSchema);
|
|
641
1065
|
}
|
|
@@ -643,44 +1067,6 @@ export class PlanStore {
|
|
|
643
1067
|
return { entries: [] };
|
|
644
1068
|
}
|
|
645
1069
|
}
|
|
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
1070
|
async appendActivity(type, ref, summary) {
|
|
685
1071
|
const log = await this.loadActivityLog();
|
|
686
1072
|
const id = `act-${log.entries.length + 1}-${type}`;
|
|
@@ -689,8 +1075,8 @@ export class PlanStore {
|
|
|
689
1075
|
// Cap to last 200 entries
|
|
690
1076
|
if (log.entries.length > 200)
|
|
691
1077
|
log.entries = log.entries.slice(-200);
|
|
692
|
-
await atomicWriteJson(this.activityPath(), { entries: log.entries });
|
|
693
|
-
await this.
|
|
1078
|
+
await atomicWriteJson(this.activityPath(), { entries: log.entries }, this.root);
|
|
1079
|
+
await this.touchTimestamp();
|
|
694
1080
|
return entry;
|
|
695
1081
|
}
|
|
696
1082
|
/** Derive an up-to-date resume focus from the current workspace state. */
|
|
@@ -705,6 +1091,7 @@ export class PlanStore {
|
|
|
705
1091
|
currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
|
|
706
1092
|
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
707
1093
|
nextSteps: existing?.nextSteps ?? [],
|
|
1094
|
+
nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
|
|
708
1095
|
blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
|
|
709
1096
|
notes: notes ?? existing?.notes ?? "",
|
|
710
1097
|
lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
|
|
@@ -721,6 +1108,33 @@ export class PlanStore {
|
|
|
721
1108
|
return { requirements: [] };
|
|
722
1109
|
}
|
|
723
1110
|
}
|
|
1111
|
+
async linkedRequirementsForPhase(phaseId) {
|
|
1112
|
+
const requirements = await this.loadRequirements();
|
|
1113
|
+
return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
|
|
1114
|
+
}
|
|
1115
|
+
/** Requirements linked to any phase belonging to a feature, deduplicated by ID. */
|
|
1116
|
+
async linkedRequirementsForFeature(featureId) {
|
|
1117
|
+
const [phases, requirements] = await Promise.all([this.loadAllPhases(), this.loadRequirements()]);
|
|
1118
|
+
const phaseIds = new Set(phases.filter((phase) => phase.featureId === featureId).map((phase) => phase.id));
|
|
1119
|
+
return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.some((phaseId) => phaseIds.has(phaseId)));
|
|
1120
|
+
}
|
|
1121
|
+
async loadPhaseWithRequirements(phaseId) {
|
|
1122
|
+
const [phase, linkedRequirements] = await Promise.all([
|
|
1123
|
+
this.loadPhase(phaseId),
|
|
1124
|
+
this.linkedRequirementsForPhase(phaseId),
|
|
1125
|
+
]);
|
|
1126
|
+
return { ...phase, linkedRequirements };
|
|
1127
|
+
}
|
|
1128
|
+
async loadAllPhasesWithRequirements() {
|
|
1129
|
+
const [phases, requirements] = await Promise.all([
|
|
1130
|
+
this.loadAllPhases(),
|
|
1131
|
+
this.loadRequirements(),
|
|
1132
|
+
]);
|
|
1133
|
+
return phases.map((phase) => ({
|
|
1134
|
+
...phase,
|
|
1135
|
+
linkedRequirements: requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phase.id)),
|
|
1136
|
+
}));
|
|
1137
|
+
}
|
|
724
1138
|
async loadAllPhases() {
|
|
725
1139
|
const { readdir } = await import("node:fs/promises");
|
|
726
1140
|
let files;
|
|
@@ -751,6 +1165,22 @@ export class PlanStore {
|
|
|
751
1165
|
return left.createdAt.localeCompare(right.createdAt);
|
|
752
1166
|
});
|
|
753
1167
|
}
|
|
1168
|
+
/** Derive the parent display snapshot for a phase from its tasks' canonical
|
|
1169
|
+
* statuses. Pure, non-persisting. */
|
|
1170
|
+
async loadPhaseDisplay(phaseId) {
|
|
1171
|
+
const phase = await this.loadPhase(phaseId);
|
|
1172
|
+
const childStatuses = phase.tasks.map((t) => fromCanonicalStatus(t.status));
|
|
1173
|
+
return deriveParentDisplay(childStatuses);
|
|
1174
|
+
}
|
|
1175
|
+
/** Derive the parent display snapshot for a feature from its phases' DERIVED
|
|
1176
|
+
* canonical statuses (each phase status is derived from its tasks at read
|
|
1177
|
+
* time, then mapped via fromCanonicalStatus). Pure, non-persisting. */
|
|
1178
|
+
async loadFeatureDisplay(featureId) {
|
|
1179
|
+
const phases = await this.loadAllPhases();
|
|
1180
|
+
const featurePhases = phases.filter((p) => p.featureId === featureId);
|
|
1181
|
+
const childStatuses = featurePhases.map((p) => fromCanonicalStatus(p.status));
|
|
1182
|
+
return deriveParentDisplay(childStatuses);
|
|
1183
|
+
}
|
|
754
1184
|
async loadAll() {
|
|
755
1185
|
const [manifest, project, requirements, phases] = await Promise.all([
|
|
756
1186
|
this.loadManifest(),
|
|
@@ -760,7 +1190,8 @@ export class PlanStore {
|
|
|
760
1190
|
]);
|
|
761
1191
|
const rawFeatures = await this.loadRawFeatures();
|
|
762
1192
|
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
763
|
-
|
|
1193
|
+
const normalized = this.normalizeStructureSnapshot({ features }, phases);
|
|
1194
|
+
return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
|
|
764
1195
|
}
|
|
765
1196
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
766
1197
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -800,10 +1231,7 @@ export class PlanStore {
|
|
|
800
1231
|
task.phaseId = newId;
|
|
801
1232
|
}
|
|
802
1233
|
await this.savePhase(phase);
|
|
803
|
-
|
|
804
|
-
await unlink(this.phasePath(oldId));
|
|
805
|
-
}
|
|
806
|
-
catch { }
|
|
1234
|
+
await this.unlinkPhaseFiles(oldId);
|
|
807
1235
|
renamed += 1;
|
|
808
1236
|
}
|
|
809
1237
|
// Repair feature.phaseIds: replace legacy refs with new ids, drop dangling ones.
|
|
@@ -938,18 +1366,14 @@ export class PlanStore {
|
|
|
938
1366
|
let shortIdsAssigned = 0;
|
|
939
1367
|
let prioritiesAssigned = 0;
|
|
940
1368
|
let featuresDirty = false;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
return index + 1;
|
|
945
|
-
}
|
|
946
|
-
return current;
|
|
947
|
-
};
|
|
1369
|
+
// Priority is left to reorder (midpoint-insert); ensureShortIds only
|
|
1370
|
+
// backfills shortIds. New items keep priority 0 until first drag reindex.
|
|
1371
|
+
const assignPriority = (current, _index) => current;
|
|
948
1372
|
// Features: shortId + priority (project scope)
|
|
949
1373
|
const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
950
1374
|
sortedFeatures.forEach((f, index) => {
|
|
951
1375
|
if (!f.shortId) {
|
|
952
|
-
f.shortId = createShortId(existing);
|
|
1376
|
+
f.shortId = createShortId(existing, `feature:${f.number}:${f.id}`);
|
|
953
1377
|
existing.add(f.shortId);
|
|
954
1378
|
shortIdsAssigned += 1;
|
|
955
1379
|
featuresDirty = true;
|
|
@@ -968,7 +1392,7 @@ export class PlanStore {
|
|
|
968
1392
|
for (const phase of phases) {
|
|
969
1393
|
let phaseDirty = false;
|
|
970
1394
|
if (!phase.shortId) {
|
|
971
|
-
phase.shortId = createShortId(existing);
|
|
1395
|
+
phase.shortId = createShortId(existing, `phase:${phase.number}:${phase.id}`);
|
|
972
1396
|
existing.add(phase.shortId);
|
|
973
1397
|
shortIdsAssigned += 1;
|
|
974
1398
|
phaseDirty = true;
|
|
@@ -982,7 +1406,7 @@ export class PlanStore {
|
|
|
982
1406
|
const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
983
1407
|
sortedTasks.forEach((t, index) => {
|
|
984
1408
|
if (!t.shortId) {
|
|
985
|
-
t.shortId = createShortId(existing);
|
|
1409
|
+
t.shortId = createShortId(existing, `task:${t.number}:${t.id}`);
|
|
986
1410
|
existing.add(t.shortId);
|
|
987
1411
|
shortIdsAssigned += 1;
|
|
988
1412
|
phaseDirty = true;
|
|
@@ -1022,13 +1446,38 @@ export class PlanStore {
|
|
|
1022
1446
|
/** Repair dangling references and report integrity. One-shot maintenance op. */
|
|
1023
1447
|
async repair() {
|
|
1024
1448
|
return this.runAsBatch(async () => {
|
|
1449
|
+
// Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
|
|
1450
|
+
// upgrades plans initialized before the .local/ move.
|
|
1451
|
+
await this.ensureGitignore().catch(() => { });
|
|
1025
1452
|
const migrated = await this.migratePhaseIds();
|
|
1453
|
+
await this.repairPhaseFeatureRefs();
|
|
1026
1454
|
const backfill = await this.ensureShortIdsAndPriority();
|
|
1455
|
+
// Rebuild phase containment from each task's own phaseId. Heals plans
|
|
1456
|
+
// corrupted by the migrateToGlobalSequence index-mismatch bug (core
|
|
1457
|
+
// <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
|
|
1458
|
+
const containment = await this.rebuildContainment();
|
|
1459
|
+
const handoffs = { archived: await this.archiveStaleHandoffs() };
|
|
1027
1460
|
const integrity = await this.validateIntegrity();
|
|
1028
1461
|
await this.writeGenerated();
|
|
1029
|
-
return { migrated, backfill, integrity };
|
|
1462
|
+
return { migrated, backfill, containment, handoffs, integrity };
|
|
1030
1463
|
});
|
|
1031
1464
|
}
|
|
1465
|
+
async repairPhaseFeatureRefs() {
|
|
1466
|
+
const features = await this.loadRawFeatures();
|
|
1467
|
+
const phases = await this.loadAllPhases();
|
|
1468
|
+
let changed = 0;
|
|
1469
|
+
for (const phase of phases) {
|
|
1470
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
|
|
1471
|
+
if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
|
|
1472
|
+
await this.savePhase({ ...phase, featureId: resolvedFeatureId });
|
|
1473
|
+
changed += 1;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (changed > 0) {
|
|
1477
|
+
await this.updateFeatures((doc) => doc);
|
|
1478
|
+
}
|
|
1479
|
+
return changed;
|
|
1480
|
+
}
|
|
1032
1481
|
/** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
|
|
1033
1482
|
async validateIntegrity() {
|
|
1034
1483
|
const phases = await this.loadAllPhases();
|
|
@@ -1074,18 +1523,28 @@ export class PlanStore {
|
|
|
1074
1523
|
return "rejected";
|
|
1075
1524
|
if (meaningful.every((s) => s === "done"))
|
|
1076
1525
|
return "done";
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1526
|
+
const hasDone = meaningful.some((s) => s === "done");
|
|
1527
|
+
const hasActive = meaningful.some((s) => s === "in-progress");
|
|
1528
|
+
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1529
|
+
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1530
|
+
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1531
|
+
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1532
|
+
if (hasActive)
|
|
1533
|
+
return "in-progress";
|
|
1534
|
+
// If completed work exists and the ONLY remaining meaningful work is deferred,
|
|
1535
|
+
// surface deferred instead of implying active execution.
|
|
1536
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1537
|
+
return "deferred";
|
|
1538
|
+
// Partial completion with remaining planned/blocked/waiting work still means
|
|
1539
|
+
// the phase has genuinely started and is not terminal yet.
|
|
1540
|
+
if (hasDone)
|
|
1082
1541
|
return "in-progress";
|
|
1083
1542
|
// No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
|
|
1084
|
-
if (
|
|
1543
|
+
if (hasBlocked)
|
|
1085
1544
|
return "blocked";
|
|
1086
|
-
if (
|
|
1545
|
+
if (hasWaiting)
|
|
1087
1546
|
return "waiting";
|
|
1088
|
-
if (
|
|
1547
|
+
if (hasDeferred)
|
|
1089
1548
|
return "deferred";
|
|
1090
1549
|
return "planned";
|
|
1091
1550
|
}
|
|
@@ -1100,17 +1559,25 @@ export class PlanStore {
|
|
|
1100
1559
|
return "rejected";
|
|
1101
1560
|
if (meaningful.every((s) => s === "done"))
|
|
1102
1561
|
return "done";
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1562
|
+
const hasDone = meaningful.some((s) => s === "done");
|
|
1563
|
+
const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
|
|
1564
|
+
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1565
|
+
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1566
|
+
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1567
|
+
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1568
|
+
if (hasActive)
|
|
1569
|
+
return "in-progress";
|
|
1570
|
+
// Same rule as phases: done + deferred-only remainder is deferred, not active.
|
|
1571
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1572
|
+
return "deferred";
|
|
1573
|
+
if (hasDone)
|
|
1107
1574
|
return "in-progress";
|
|
1108
1575
|
// No progress at all ⇒ surface the stall / not-started state.
|
|
1109
|
-
if (
|
|
1576
|
+
if (hasBlocked)
|
|
1110
1577
|
return "blocked";
|
|
1111
|
-
if (
|
|
1578
|
+
if (hasWaiting)
|
|
1112
1579
|
return "waiting";
|
|
1113
|
-
if (
|
|
1580
|
+
if (hasDeferred)
|
|
1114
1581
|
return "deferred";
|
|
1115
1582
|
return "planned";
|
|
1116
1583
|
}
|
|
@@ -1128,20 +1595,92 @@ export class PlanStore {
|
|
|
1128
1595
|
const phase = await this.loadPhase(phaseId);
|
|
1129
1596
|
let cleared = null;
|
|
1130
1597
|
if (phase.status === "done" && phase.handoff !== "") {
|
|
1131
|
-
await this.
|
|
1598
|
+
await this.clearPhaseHandoff(phaseId, "phase-done");
|
|
1132
1599
|
const features = await this.loadFeatures();
|
|
1133
1600
|
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1134
1601
|
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
1135
1602
|
}
|
|
1603
|
+
// Append a statusLog entry to the phase when its DERIVED status changed
|
|
1604
|
+
// (audit trail; status itself is NOT persisted). Idempotent: only appends
|
|
1605
|
+
// when the new derived status differs from the last recorded toStatus.
|
|
1606
|
+
await this.#appendPhaseStatusLog(phaseId);
|
|
1607
|
+
// Roll up to the parent feature's statusLog too.
|
|
1608
|
+
if (phase.featureId)
|
|
1609
|
+
await this.#appendFeatureStatusLog(phase.featureId);
|
|
1136
1610
|
await this.refreshResume();
|
|
1137
1611
|
return cleared;
|
|
1138
1612
|
}
|
|
1613
|
+
/** Append a PhaseStatusLogEntry to the phase when its derived status changed
|
|
1614
|
+
* vs. the last recorded toStatus (baseline "draft" when empty, matching the
|
|
1615
|
+
* phase-creation literal). Idempotent across repeated reads of the same state. */
|
|
1616
|
+
async #appendPhaseStatusLog(phaseId) {
|
|
1617
|
+
await this.updatePhase(phaseId, (p) => {
|
|
1618
|
+
const last = p.statusLog.at(-1)?.toStatus ?? "draft";
|
|
1619
|
+
if (p.status !== last) {
|
|
1620
|
+
p.statusLog = [...p.statusLog, {
|
|
1621
|
+
id: createStatusLogEntryId(),
|
|
1622
|
+
date: nowISO(),
|
|
1623
|
+
fromStatus: last,
|
|
1624
|
+
toStatus: p.status,
|
|
1625
|
+
title: `${last} → ${p.status}`,
|
|
1626
|
+
description: "",
|
|
1627
|
+
}];
|
|
1628
|
+
}
|
|
1629
|
+
return p;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
/** Append a StatusLogEntry to the feature when its derived status changed
|
|
1633
|
+
* vs. the last recorded toStatus (baseline "planned" when empty, matching
|
|
1634
|
+
* the feature-creation/empty-phases derivation). Idempotent. */
|
|
1635
|
+
async #appendFeatureStatusLog(featureId) {
|
|
1636
|
+
const features = await this.loadFeatures();
|
|
1637
|
+
const feature = features.features.find((f) => f.id === featureId);
|
|
1638
|
+
if (!feature)
|
|
1639
|
+
return;
|
|
1640
|
+
const last = feature.statusLog.at(-1)?.toStatus ?? "planned";
|
|
1641
|
+
if (feature.status !== last) {
|
|
1642
|
+
await this.updateFeatures((doc) => {
|
|
1643
|
+
const target = doc.features.find((f) => f.id === featureId);
|
|
1644
|
+
if (target && target.statusLog.at(-1)?.toStatus !== feature.status) {
|
|
1645
|
+
const baseline = target.statusLog.at(-1)?.toStatus ?? "planned";
|
|
1646
|
+
target.statusLog = [...target.statusLog, {
|
|
1647
|
+
id: createStatusLogEntryId(),
|
|
1648
|
+
date: nowISO(),
|
|
1649
|
+
fromStatus: baseline,
|
|
1650
|
+
toStatus: feature.status,
|
|
1651
|
+
title: `${baseline} → ${feature.status}`,
|
|
1652
|
+
description: "",
|
|
1653
|
+
}];
|
|
1654
|
+
}
|
|
1655
|
+
return doc;
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1139
1659
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
1140
1660
|
async updateProject(updater) {
|
|
1141
|
-
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
|
|
1661
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
|
|
1142
1662
|
await this.maybeAutoSync();
|
|
1143
1663
|
return updated;
|
|
1144
1664
|
}
|
|
1665
|
+
/** Persist an explicitly approved work deviation without coupling it to a harness. */
|
|
1666
|
+
async addWorkDeviation(deviation) {
|
|
1667
|
+
return this.updateProject((project) => ({
|
|
1668
|
+
...project,
|
|
1669
|
+
workDeviations: [...project.workDeviations, deviation],
|
|
1670
|
+
}));
|
|
1671
|
+
}
|
|
1672
|
+
/** Mark an approved/active deviation as resolved or canceled while retaining its audit record. */
|
|
1673
|
+
async setWorkDeviationState(id, state, timestamp = nowISO()) {
|
|
1674
|
+
return this.updateProject((project) => ({
|
|
1675
|
+
...project,
|
|
1676
|
+
workDeviations: project.workDeviations.map((deviation) => deviation.id !== id ? deviation : {
|
|
1677
|
+
...deviation,
|
|
1678
|
+
state,
|
|
1679
|
+
activatedAt: state === "active" ? timestamp : deviation.activatedAt,
|
|
1680
|
+
resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
|
|
1681
|
+
}),
|
|
1682
|
+
}));
|
|
1683
|
+
}
|
|
1145
1684
|
async updateFeatures(updater) {
|
|
1146
1685
|
const updated = await this.withFeaturesLock(async () => {
|
|
1147
1686
|
await this.migrateLegacy();
|
|
@@ -1154,14 +1693,14 @@ export class PlanStore {
|
|
|
1154
1693
|
return updated;
|
|
1155
1694
|
}
|
|
1156
1695
|
async updateRequirements(updater) {
|
|
1157
|
-
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
|
|
1696
|
+
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
|
|
1158
1697
|
await this.maybeAutoSync();
|
|
1159
1698
|
return updated;
|
|
1160
1699
|
}
|
|
1161
1700
|
async saveProject(project) {
|
|
1162
1701
|
const parsed = ProjectSchema.parse(project);
|
|
1163
|
-
await atomicWriteJson(this.projectPath(), parsed);
|
|
1164
|
-
await this.
|
|
1702
|
+
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
1703
|
+
await this.touchTimestamp();
|
|
1165
1704
|
await this.maybeAutoSync();
|
|
1166
1705
|
}
|
|
1167
1706
|
async saveFeatures(features) {
|
|
@@ -1169,7 +1708,7 @@ export class PlanStore {
|
|
|
1169
1708
|
await this.migrateLegacy();
|
|
1170
1709
|
await this.saveFeaturesRaw(features);
|
|
1171
1710
|
});
|
|
1172
|
-
await this.
|
|
1711
|
+
await this.touchTimestamp();
|
|
1173
1712
|
await this.maybeAutoSync();
|
|
1174
1713
|
}
|
|
1175
1714
|
/** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
|
|
@@ -1178,7 +1717,7 @@ export class PlanStore {
|
|
|
1178
1717
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
1179
1718
|
const wantIds = new Set(parsed.features.map((f) => f.id));
|
|
1180
1719
|
for (const feat of parsed.features) {
|
|
1181
|
-
await atomicWriteJson(this.featurePath(feat.id), feat);
|
|
1720
|
+
await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
|
|
1182
1721
|
}
|
|
1183
1722
|
// Orphan reconcile: remove feature files no longer in the document.
|
|
1184
1723
|
try {
|
|
@@ -1202,27 +1741,43 @@ export class PlanStore {
|
|
|
1202
1741
|
await this.migrateLegacy();
|
|
1203
1742
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
1204
1743
|
const parsed = FeatureSchema.parse(feature);
|
|
1205
|
-
await atomicWriteJson(this.featurePath(parsed.id), parsed);
|
|
1744
|
+
await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
|
|
1206
1745
|
});
|
|
1207
|
-
await this.
|
|
1746
|
+
await this.touchTimestamp();
|
|
1208
1747
|
await this.maybeAutoSync();
|
|
1209
1748
|
}
|
|
1210
1749
|
async saveRequirements(reqs) {
|
|
1211
1750
|
const parsed = RequirementsDocumentSchema.parse(reqs);
|
|
1212
|
-
await atomicWriteJson(this.requirementsPath(), parsed);
|
|
1213
|
-
await this.
|
|
1751
|
+
await atomicWriteJson(this.requirementsPath(), parsed, this.root);
|
|
1752
|
+
await this.touchTimestamp();
|
|
1214
1753
|
}
|
|
1215
1754
|
async savePhase(phase) {
|
|
1216
|
-
const
|
|
1755
|
+
const features = await this.loadRawFeatures();
|
|
1756
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
|
|
1757
|
+
// Referential integrity: if a featureId is present but cannot be resolved
|
|
1758
|
+
// to a known feature, REJECT — never persist an orphan featureId.
|
|
1759
|
+
// NOTE: a missing/empty featureId is intentionally ALLOWED here so that
|
|
1760
|
+
// legacy migrations, repair, and feature-delete (unlink) can persist phases
|
|
1761
|
+
// without a feature yet. The hard "featureId required" gate lives at the
|
|
1762
|
+
// adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
|
|
1763
|
+
// planner-task-add), which is where user-facing creation happens.
|
|
1764
|
+
if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
|
|
1765
|
+
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.`);
|
|
1766
|
+
}
|
|
1767
|
+
const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
|
|
1768
|
+
? { ...phase, featureId: resolvedFeatureId }
|
|
1769
|
+
: phase;
|
|
1770
|
+
const parsed = PhaseSchema.parse(this.normalizePhaseDocument(normalizedInput).phase);
|
|
1217
1771
|
await mkdir(this.phasesDir(), { recursive: true });
|
|
1218
|
-
await atomicWriteJson(this.phasePath(parsed.id), parsed);
|
|
1219
|
-
await this.
|
|
1772
|
+
await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
|
|
1773
|
+
await this.touchTimestamp();
|
|
1220
1774
|
await this.maybeAutoSync();
|
|
1221
1775
|
}
|
|
1222
1776
|
/** Atomic read-modify-write on a single phase file. Serializes concurrent
|
|
1223
1777
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
1224
1778
|
* don't lose tasks (last-write-wins race condition). */
|
|
1225
1779
|
async updatePhase(phaseId, updater) {
|
|
1780
|
+
const features = await this.loadRawFeatures();
|
|
1226
1781
|
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
1227
1782
|
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
1228
1783
|
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
@@ -1230,7 +1785,15 @@ export class PlanStore {
|
|
|
1230
1785
|
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
1231
1786
|
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
1232
1787
|
const next = updater(current);
|
|
1233
|
-
|
|
1788
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, next.featureId);
|
|
1789
|
+
// Referential integrity: reject orphan featureId.
|
|
1790
|
+
if (next.featureId && next.featureId.trim() && !resolvedFeatureId) {
|
|
1791
|
+
throw new PlanStoreError(`Cannot update phase: featureId "${next.featureId}" does not match any existing feature.`);
|
|
1792
|
+
}
|
|
1793
|
+
const normalizedInput = resolvedFeatureId && resolvedFeatureId !== next.featureId
|
|
1794
|
+
? { ...next, featureId: resolvedFeatureId }
|
|
1795
|
+
: next;
|
|
1796
|
+
return this.normalizePhaseDocument(normalizedInput).phase;
|
|
1234
1797
|
});
|
|
1235
1798
|
await this.maybeAutoSync();
|
|
1236
1799
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
@@ -1240,48 +1803,230 @@ export class PlanStore {
|
|
|
1240
1803
|
async getPhaseHandoff(phaseId) {
|
|
1241
1804
|
return (await this.loadPhase(phaseId)).handoff;
|
|
1242
1805
|
}
|
|
1243
|
-
/** Set the handoff text for a phase + stamp handoffUpdatedAt.
|
|
1244
|
-
*
|
|
1806
|
+
/** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
|
|
1807
|
+
* canceled phase cannot receive a new operational handoff. Replacing an
|
|
1808
|
+
* existing handoff archives the previous content as `superseded` first. */
|
|
1245
1809
|
async setPhaseHandoff(phaseId, text) {
|
|
1810
|
+
const phase = await this.loadPhase(phaseId);
|
|
1811
|
+
const normalized = text.trim();
|
|
1812
|
+
if (phase.status === "done" || phase.status === "canceled") {
|
|
1813
|
+
throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; completed phases have no pending handoff.`);
|
|
1814
|
+
}
|
|
1815
|
+
if (phase.handoff && normalized && phase.handoff !== normalized) {
|
|
1816
|
+
await this.clearPhaseHandoff(phaseId, "superseded");
|
|
1817
|
+
}
|
|
1246
1818
|
const now = new Date().toISOString();
|
|
1247
|
-
await this.updatePhase(phaseId, (
|
|
1819
|
+
await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
|
|
1820
|
+
}
|
|
1821
|
+
/** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
|
|
1822
|
+
* Does NOT clear the handoff — content is kept until a task starts or the
|
|
1823
|
+
* phase completes, so a restart between read and resume does not lose it. */
|
|
1824
|
+
async markHandoffRead(phaseId) {
|
|
1825
|
+
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
|
|
1826
|
+
}
|
|
1827
|
+
/** One-time import of a legacy .planner/HANDOFF.md file (file-based handoff
|
|
1828
|
+
* from before F004) into the entity-scoped phase.handoff. Idempotent: if the
|
|
1829
|
+
* file is absent or empty, no-op. If it exists + non-empty + the target phase
|
|
1830
|
+
* has no handoff, writes the content onto the current in-progress phase (or
|
|
1831
|
+
* the first phase if none in-progress) with an "imported" handoffHistory entry,
|
|
1832
|
+
* then renames the file to HANDOFF.md.bak so it won't re-import. If the target
|
|
1833
|
+
* already has a handoff, the entity-scoped one wins and the file is just .bak'd. */
|
|
1834
|
+
async importLegacyHandoffFile() {
|
|
1835
|
+
const filePath = join(this.root, "HANDOFF.md");
|
|
1836
|
+
const content = await readFile(filePath, "utf-8").catch(() => null);
|
|
1837
|
+
if (content === null)
|
|
1838
|
+
return { imported: false };
|
|
1839
|
+
if (content.trim() === "") {
|
|
1840
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1841
|
+
return { imported: false };
|
|
1842
|
+
}
|
|
1843
|
+
const phases = await this.loadAllPhases();
|
|
1844
|
+
const target = phases.find((p) => p.status === "in-progress")
|
|
1845
|
+
?? phases.find((p) => p.status !== "done" && p.status !== "canceled")
|
|
1846
|
+
?? null;
|
|
1847
|
+
if (!target)
|
|
1848
|
+
return { imported: false }; // no non-completed phase — leave file for a later run
|
|
1849
|
+
if ((target.handoff ?? "") === "") {
|
|
1850
|
+
await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
|
|
1851
|
+
await this.updatePhase(target.id, (p) => ({
|
|
1852
|
+
...p,
|
|
1853
|
+
handoffHistory: [{ file: "(legacy HANDOFF.md)", clearedAt: nowISO(), reason: "imported" }, ...(p.handoffHistory ?? [])].slice(0, 5),
|
|
1854
|
+
}));
|
|
1855
|
+
}
|
|
1856
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1857
|
+
const features = await this.loadFeatures();
|
|
1858
|
+
const feat = features.features.find((f) => f.id === target.featureId);
|
|
1859
|
+
return { imported: true, phaseRef: formatPhaseRef(target.number, feat?.number) };
|
|
1860
|
+
}
|
|
1861
|
+
/** Clear the handoff for a phase, archiving its content first. The handoff
|
|
1862
|
+
* markdown is written to .planner/handoff-archive/<phaseId>-<ISO>.md and a
|
|
1863
|
+
* metadata entry { file, clearedAt, reason } is prepended to handoffHistory
|
|
1864
|
+
* (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
|
|
1865
|
+
* left unchanged as an audit trail. If the handoff is empty, this is a no-op.
|
|
1866
|
+
* reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
|
|
1867
|
+
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
1868
|
+
await this.migrateLegacyHandoffArchive();
|
|
1869
|
+
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
1870
|
+
if (!phase || phase.handoff === "")
|
|
1871
|
+
return; // nothing to archive
|
|
1872
|
+
const clearedAt = nowISO();
|
|
1873
|
+
const safeTs = clearedAt.replace(/[:.]/g, "-");
|
|
1874
|
+
const archiveDir = this.handoffArchiveDir();
|
|
1875
|
+
await mkdir(archiveDir, { recursive: true }).catch(() => { });
|
|
1876
|
+
const fileName = `${phaseId}-${safeTs}.md`;
|
|
1877
|
+
const filePath = join(archiveDir, fileName);
|
|
1878
|
+
await atomicWriteText(filePath, phase.handoff, this.root);
|
|
1879
|
+
const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
|
|
1880
|
+
// Cap history at 5: prepend new entry, drop oldest (and delete its file).
|
|
1881
|
+
const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
|
|
1882
|
+
const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
|
|
1883
|
+
for (const d of dropped) {
|
|
1884
|
+
if (!d?.file)
|
|
1885
|
+
continue;
|
|
1886
|
+
// Legacy entries used `.planner/handoff-archive/...`; new entries use `.planner/.local/handoff-archive/...`.
|
|
1887
|
+
await unlink(join(this.handoffArchiveDir(), basename(d.file))).catch(() => { });
|
|
1888
|
+
await unlink(join(this.root, d.file)).catch(() => { });
|
|
1889
|
+
}
|
|
1890
|
+
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
|
|
1891
|
+
}
|
|
1892
|
+
/** Archive stale handoffs left on phases that are already completed/canceled.
|
|
1893
|
+
* Idempotent: only non-empty phase.handoff values are moved. */
|
|
1894
|
+
async archiveStaleHandoffs() {
|
|
1895
|
+
const phases = await this.loadAllPhases();
|
|
1896
|
+
let archived = 0;
|
|
1897
|
+
for (const phase of phases) {
|
|
1898
|
+
if ((phase.status === "done" || phase.status === "canceled") && phase.handoff) {
|
|
1899
|
+
await this.clearPhaseHandoff(phase.id, "phase-done");
|
|
1900
|
+
archived += 1;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return archived;
|
|
1248
1904
|
}
|
|
1249
|
-
/**
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: "" }));
|
|
1905
|
+
/** Public maintenance operation for retroactively archiving stale handoffs. */
|
|
1906
|
+
async cleanupStaleHandoffs() {
|
|
1907
|
+
return this.runAsBatch(() => this.archiveStaleHandoffs());
|
|
1253
1908
|
}
|
|
1254
|
-
/** List
|
|
1255
|
-
*
|
|
1909
|
+
/** List only active/pending phase handoffs, newest first. Completed,
|
|
1910
|
+
* canceled, and orphaned phases are intentionally excluded. Any legacy stale
|
|
1911
|
+
* handoff found on a completed/canceled phase is archived before returning,
|
|
1912
|
+
* so callers (including /handoff) also repair old plans automatically. */
|
|
1256
1913
|
async listHandoffs() {
|
|
1914
|
+
await this.archiveStaleHandoffs();
|
|
1257
1915
|
const phases = await this.loadAllPhases();
|
|
1258
1916
|
const features = await this.loadFeatures();
|
|
1917
|
+
const featureIds = new Set(features.features.map((f) => f.id));
|
|
1259
1918
|
const featureNumber = new Map();
|
|
1260
1919
|
for (const f of features.features)
|
|
1261
1920
|
featureNumber.set(f.id, f.number);
|
|
1262
1921
|
const out = [];
|
|
1263
1922
|
for (const p of phases) {
|
|
1264
|
-
if (!p.handoff)
|
|
1923
|
+
if (!p.handoff || p.status === "done" || p.status === "canceled")
|
|
1924
|
+
continue;
|
|
1925
|
+
if (p.featureId && !featureIds.has(p.featureId))
|
|
1265
1926
|
continue;
|
|
1266
1927
|
const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
|
|
1267
1928
|
out.push({
|
|
1268
1929
|
phaseId: p.id,
|
|
1930
|
+
featureId: p.featureId,
|
|
1269
1931
|
compositeRef: formatPhaseRef(p.number, fnum),
|
|
1270
1932
|
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
1271
1933
|
firstLine: handoffFirstLine(p.handoff),
|
|
1934
|
+
content: p.handoff,
|
|
1272
1935
|
});
|
|
1273
1936
|
}
|
|
1274
1937
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1275
1938
|
return out;
|
|
1276
1939
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1940
|
+
/** List recoverable archived handoffs from phase.handoffHistory. Archived
|
|
1941
|
+
* entries are never returned by listHandoffs() and are safe to show on a
|
|
1942
|
+
* dedicated history page. */
|
|
1943
|
+
async listArchivedHandoffs() {
|
|
1944
|
+
const phases = await this.loadAllPhases();
|
|
1945
|
+
const features = await this.loadFeatures();
|
|
1946
|
+
const featureNumber = new Map();
|
|
1947
|
+
for (const f of features.features)
|
|
1948
|
+
featureNumber.set(f.id, f.number);
|
|
1949
|
+
const out = [];
|
|
1950
|
+
for (const phase of phases) {
|
|
1951
|
+
const fnum = phase.featureId ? featureNumber.get(phase.featureId) : undefined;
|
|
1952
|
+
for (const entry of phase.handoffHistory ?? []) {
|
|
1953
|
+
if (!entry.file)
|
|
1954
|
+
continue;
|
|
1955
|
+
const localPath = join(this.localRoot(), entry.file);
|
|
1956
|
+
const legacyPath = join(this.root, entry.file);
|
|
1957
|
+
const content = await readFile(localPath, "utf-8").catch(() => readFile(legacyPath, "utf-8").catch(() => ""));
|
|
1958
|
+
out.push({
|
|
1959
|
+
phaseId: phase.id,
|
|
1960
|
+
featureId: phase.featureId,
|
|
1961
|
+
compositeRef: formatPhaseRef(phase.number, fnum),
|
|
1962
|
+
file: entry.file,
|
|
1963
|
+
archivedAt: entry.clearedAt,
|
|
1964
|
+
reason: entry.reason,
|
|
1965
|
+
firstLine: handoffFirstLine(content),
|
|
1966
|
+
content,
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1280
1969
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1970
|
+
out.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
|
|
1971
|
+
return out;
|
|
1972
|
+
}
|
|
1973
|
+
async listOrphanPhases() {
|
|
1974
|
+
const phases = await this.loadAllPhases();
|
|
1975
|
+
const featuresDoc = await this.loadFeatures();
|
|
1976
|
+
const out = [];
|
|
1977
|
+
for (const phase of phases) {
|
|
1978
|
+
const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
|
|
1979
|
+
if (resolvedFeatureId)
|
|
1980
|
+
continue;
|
|
1981
|
+
const reason = phase.featureId?.trim()
|
|
1982
|
+
? `feature not found: ${phase.featureId}`
|
|
1983
|
+
: "missing featureId";
|
|
1984
|
+
out.push({
|
|
1985
|
+
phaseId: phase.id,
|
|
1986
|
+
featureId: phase.featureId,
|
|
1987
|
+
shortId: phase.shortId,
|
|
1988
|
+
compositeRef: formatPhaseRef(phase.number),
|
|
1989
|
+
title: phase.title,
|
|
1990
|
+
reason,
|
|
1991
|
+
});
|
|
1283
1992
|
}
|
|
1284
|
-
|
|
1993
|
+
out.sort((a, b) => a.compositeRef.localeCompare(b.compositeRef));
|
|
1994
|
+
return out;
|
|
1995
|
+
}
|
|
1996
|
+
async cleanupOrphanPhases() {
|
|
1997
|
+
return this.runAsBatch(async () => {
|
|
1998
|
+
const found = await this.listOrphanPhases();
|
|
1999
|
+
if (found.length === 0)
|
|
2000
|
+
return { found, removed: [] };
|
|
2001
|
+
const orphanIds = new Set(found.map((phase) => phase.phaseId));
|
|
2002
|
+
for (const orphan of found) {
|
|
2003
|
+
await this.unlinkPhaseFiles(orphan.phaseId);
|
|
2004
|
+
}
|
|
2005
|
+
await this.updateFeatures((doc) => {
|
|
2006
|
+
for (const feature of doc.features) {
|
|
2007
|
+
feature.phaseIds = feature.phaseIds.filter((id) => !orphanIds.has(id));
|
|
2008
|
+
}
|
|
2009
|
+
return doc;
|
|
2010
|
+
});
|
|
2011
|
+
await this.touchTimestamp();
|
|
2012
|
+
await this.writeGenerated();
|
|
2013
|
+
return { found, removed: found };
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
async deletePhase(phaseId) {
|
|
2017
|
+
await this.unlinkPhaseFiles(phaseId);
|
|
2018
|
+
await this.touchTimestamp();
|
|
2019
|
+
}
|
|
2020
|
+
/** Remove a phase file AND its inline .bak backup. atomicUpdateJson (used by
|
|
2021
|
+
* updatePhase without root) writes the backup inline at phases/<id>.json.bak,
|
|
2022
|
+
* and readJson falls back to `${path}.bak` on a missing main file — so a
|
|
2023
|
+
* delete that leaves the .bak behind would RESURRECT the deleted phase on
|
|
2024
|
+
* the next read. Feature backups (written with root) live under
|
|
2025
|
+
* .local/backups/ and are never read by readJson, so only the inline .bak
|
|
2026
|
+
* needs removing here. */
|
|
2027
|
+
async unlinkPhaseFiles(phaseId) {
|
|
2028
|
+
await unlink(this.phasePath(phaseId)).catch(() => { });
|
|
2029
|
+
await unlink(`${this.phasePath(phaseId)}.bak`).catch(() => { });
|
|
1285
2030
|
}
|
|
1286
2031
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
1287
2032
|
/** Load the full workspace (manifest + phases + project + requirements + features) */
|
|
@@ -1294,8 +2039,10 @@ export class PlanStore {
|
|
|
1294
2039
|
return { manifest, phases, project, features, requirements };
|
|
1295
2040
|
}
|
|
1296
2041
|
// ── Markdown generation ────────────────────────────────────────────
|
|
1297
|
-
/** Load all data, render markdown, and write into generated/.
|
|
2042
|
+
/** Load all data, render markdown, and write into generated/. Skips files
|
|
2043
|
+
* whose content is unchanged to avoid unnecessary backup churn. */
|
|
1298
2044
|
async writeGenerated() {
|
|
2045
|
+
await this.migrateLegacyGeneratedDir();
|
|
1299
2046
|
const { PlanRenderer } = await import("./renderer.js");
|
|
1300
2047
|
const plan = await this.loadAll();
|
|
1301
2048
|
const renderer = new PlanRenderer();
|
|
@@ -1311,21 +2058,27 @@ export class PlanStore {
|
|
|
1311
2058
|
if (dir !== genDir) {
|
|
1312
2059
|
await mkdir(dir, { recursive: true });
|
|
1313
2060
|
}
|
|
2061
|
+
try {
|
|
2062
|
+
const existing = await readFile(fullPath, "utf-8");
|
|
2063
|
+
if (existing === content)
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
catch {
|
|
2067
|
+
// file does not exist yet — write it
|
|
2068
|
+
}
|
|
1314
2069
|
await writeFile(fullPath, content, "utf-8");
|
|
1315
2070
|
written.push(relPath);
|
|
1316
2071
|
}
|
|
1317
2072
|
return written;
|
|
1318
2073
|
}
|
|
1319
2074
|
// ── Touch ────────────────────────────────────────────────────────────
|
|
1320
|
-
/** Update
|
|
1321
|
-
async
|
|
2075
|
+
/** Update .local/timestamp.json to reflect a change. */
|
|
2076
|
+
async touchTimestamp() {
|
|
1322
2077
|
try {
|
|
1323
|
-
|
|
1324
|
-
m.updatedAt = nowISO();
|
|
1325
|
-
await atomicWriteJson(this.manifestPath(), m);
|
|
2078
|
+
await atomicWriteJson(this.timestampPath(), { updatedAt: nowISO() }, this.root);
|
|
1326
2079
|
}
|
|
1327
2080
|
catch {
|
|
1328
|
-
// if
|
|
2081
|
+
// if .local/ doesn't exist yet, skip
|
|
1329
2082
|
}
|
|
1330
2083
|
}
|
|
1331
2084
|
}
|