@agent-plan/core 0.2.19-next.2 → 0.2.19-next.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +157 -17
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +833 -172
- 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 +477 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +55 -4
- package/dist/task-context.d.ts +16 -0
- package/dist/task-context.d.ts.map +1 -0
- package/dist/task-context.js +48 -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);
|
|
702
|
+
}
|
|
703
|
+
async migrateLegacyGeneratedDir() {
|
|
704
|
+
const oldDir = join(this.root, "generated");
|
|
705
|
+
const newDir = this.generatedDir();
|
|
706
|
+
try {
|
|
707
|
+
await access(oldDir);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
await access(newDir);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
catch { }
|
|
717
|
+
await rename(oldDir, newDir);
|
|
413
718
|
}
|
|
414
|
-
|
|
415
|
-
|
|
719
|
+
async migrateLegacyHandoffArchive() {
|
|
720
|
+
const oldDir = join(this.root, "handoff-archive");
|
|
721
|
+
const newDir = this.handoffArchiveDir();
|
|
722
|
+
try {
|
|
723
|
+
await access(oldDir);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
await access(newDir);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
catch { }
|
|
733
|
+
await rename(oldDir, newDir);
|
|
416
734
|
}
|
|
417
735
|
// ── Init ─────────────────────────────────────────────────────────────
|
|
418
736
|
async init(projectName) {
|
|
@@ -422,7 +740,11 @@ export class PlanStore {
|
|
|
422
740
|
await mkdir(this.root, { recursive: true });
|
|
423
741
|
await mkdir(this.phasesDir(), { recursive: true });
|
|
424
742
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
743
|
+
await mkdir(this.localRoot(), { recursive: true });
|
|
744
|
+
await mkdir(this.backupsDir(), { recursive: true });
|
|
745
|
+
await mkdir(this.tmpDir(), { recursive: true });
|
|
425
746
|
await mkdir(join(this.generatedDir(), "phases"), { recursive: true });
|
|
747
|
+
await mkdir(this.handoffArchiveDir(), { recursive: true });
|
|
426
748
|
await mkdir(join(this.root, "schema"), { recursive: true });
|
|
427
749
|
await mkdir(join(this.root, "adapters"), { recursive: true });
|
|
428
750
|
const manifest = {
|
|
@@ -432,7 +754,8 @@ export class PlanStore {
|
|
|
432
754
|
createdAt: nowISO(),
|
|
433
755
|
updatedAt: nowISO(),
|
|
434
756
|
};
|
|
435
|
-
await atomicWriteJson(this.manifestPath(), manifest);
|
|
757
|
+
await atomicWriteJson(this.manifestPath(), manifest, this.root);
|
|
758
|
+
await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
|
|
436
759
|
await this.saveProject({
|
|
437
760
|
name: projectName,
|
|
438
761
|
goal: "",
|
|
@@ -452,6 +775,9 @@ export class PlanStore {
|
|
|
452
775
|
beforeTaskStart: [],
|
|
453
776
|
afterPhaseComplete: [],
|
|
454
777
|
},
|
|
778
|
+
nextFeatureNumber: 1,
|
|
779
|
+
nextPhaseNumber: 1,
|
|
780
|
+
nextTaskNumber: 1,
|
|
455
781
|
});
|
|
456
782
|
await this.saveRequirements({ requirements: [] });
|
|
457
783
|
await this.saveFeatures({ features: [] });
|
|
@@ -460,6 +786,7 @@ export class PlanStore {
|
|
|
460
786
|
currentPhaseId: "",
|
|
461
787
|
inProgressTaskIds: [],
|
|
462
788
|
nextSteps: ["Run /planner project discuss to bootstrap discovery"],
|
|
789
|
+
nextStepsUpdatedAt: nowISO(),
|
|
463
790
|
blockers: [],
|
|
464
791
|
notes: "Project initialized. Awaiting discovery.",
|
|
465
792
|
lastSessionSummary: "",
|
|
@@ -478,7 +805,7 @@ export class PlanStore {
|
|
|
478
805
|
"- `project.json` — scope, rules, stack, tools",
|
|
479
806
|
"- `requirements.json` — requirements and macro-tasks",
|
|
480
807
|
"- `phases/` — one JSON file per phase",
|
|
481
|
-
"- `generated/` — auto-generated markdown views",
|
|
808
|
+
"- `generated/` — auto-generated markdown views (under `.local/`)",
|
|
482
809
|
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
483
810
|
].join("\n");
|
|
484
811
|
await writeFile(join(this.root, "README.md"), readme, "utf-8");
|
|
@@ -488,15 +815,29 @@ export class PlanStore {
|
|
|
488
815
|
// - resume.json: per-session resume focus + the machine-local guard-bypass
|
|
489
816
|
// timestamp (guardBypassUntil must NOT leak into git/other clones)
|
|
490
817
|
// - generated/: auto-regenerated markdown views (derived from JSON; churn)
|
|
491
|
-
await writeFile(join(this.root, ".gitignore"),
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
818
|
+
await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
|
|
819
|
+
}
|
|
820
|
+
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
821
|
+
* canonical transient/derived patterns). Projects initialized before the
|
|
822
|
+
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
823
|
+
* root-level patterns. This upgrades them safely on load and on repair.
|
|
824
|
+
* Returns true if the file was (re)written. Safe to call on every load. */
|
|
825
|
+
async ensureGitignore() {
|
|
826
|
+
const gi = join(this.root, ".gitignore");
|
|
827
|
+
try {
|
|
828
|
+
const existing = await readFile(gi, "utf8").catch(() => null);
|
|
829
|
+
// Up to date iff it contains all canonical patterns (P042 spec):
|
|
830
|
+
// .local/ (transients), *.bak (crash backups), *.tmp.* (atomic-write
|
|
831
|
+
// temp files), generated/ (legacy dir).
|
|
832
|
+
if (existing != null && existing.includes(".local/") && existing.includes("*.bak") && existing.includes("*.tmp.*") && existing.includes("generated/")) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
await writeFile(gi, PLANNER_GITIGNORE, "utf-8");
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
500
841
|
}
|
|
501
842
|
async exists() {
|
|
502
843
|
try {
|
|
@@ -509,11 +850,79 @@ export class PlanStore {
|
|
|
509
850
|
}
|
|
510
851
|
// ── Loaders ──────────────────────────────────────────────────────────
|
|
511
852
|
async loadManifest() {
|
|
512
|
-
|
|
853
|
+
// Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
|
|
854
|
+
// upgrades plans initialized before the .local/ move. Runs on every load.
|
|
855
|
+
await this.ensureGitignore().catch(() => { });
|
|
856
|
+
const manifest = await readJson(this.manifestPath(), ManifestSchema);
|
|
857
|
+
try {
|
|
858
|
+
const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema }));
|
|
859
|
+
manifest.updatedAt = timestamp.updatedAt;
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// Legacy plan without .local/timestamp.json: bootstrap from manifest.updatedAt
|
|
863
|
+
// and create the timestamp file so subsequent writes stay in .local/.
|
|
864
|
+
try {
|
|
865
|
+
await mkdir(this.localRoot(), { recursive: true });
|
|
866
|
+
await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// ignore bootstrap failure
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return manifest;
|
|
513
873
|
}
|
|
514
874
|
async loadProject() {
|
|
515
875
|
return readJson(this.projectPath(), ProjectSchema);
|
|
516
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* Allocate the next global sequence number for a feature/phase/task.
|
|
879
|
+
* Reads the monotonic counter from project.json, increments it, persists,
|
|
880
|
+
* and returns the allocated number. MUST be called within withFeatureLock
|
|
881
|
+
* (adapters create entities inside a lock) so the counter is race-free.
|
|
882
|
+
* The counter never reuses a number — deletions leave gaps (by design:
|
|
883
|
+
* stable references survive deletion).
|
|
884
|
+
*/
|
|
885
|
+
async allocFeatureNumber() { return this.allocSeqNumber("nextFeatureNumber", "feature"); }
|
|
886
|
+
async allocPhaseNumber() { return this.allocSeqNumber("nextPhaseNumber", "phase"); }
|
|
887
|
+
async allocTaskNumber() { return this.allocSeqNumber("nextTaskNumber", "task"); }
|
|
888
|
+
/** Allocate a globally-unique sequence number. `atomicUpdateJson` already
|
|
889
|
+
* serializes concurrent calls via `withWriteLock` on the project file, so
|
|
890
|
+
* the read-modify-write is race-free in-process. The collision guard
|
|
891
|
+
* additionally skips any candidate that already exists in the data (safety
|
|
892
|
+
* net for cross-process races or manual edits) and persists the corrected
|
|
893
|
+
* counter. */
|
|
894
|
+
async allocSeqNumber(key, kind) {
|
|
895
|
+
// Load already-used numbers for this kind (best-effort read; the
|
|
896
|
+
// atomicUpdateJson below is the authoritative write).
|
|
897
|
+
const used = new Set();
|
|
898
|
+
if (kind === "task") {
|
|
899
|
+
const phases = await this.loadAllPhases();
|
|
900
|
+
for (const p of phases)
|
|
901
|
+
for (const t of p.tasks)
|
|
902
|
+
used.add(t.number);
|
|
903
|
+
}
|
|
904
|
+
else if (kind === "phase") {
|
|
905
|
+
const phases = await this.loadAllPhases();
|
|
906
|
+
for (const p of phases)
|
|
907
|
+
used.add(p.number);
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
const feats = await this.loadRawFeatures();
|
|
911
|
+
for (const f of feats)
|
|
912
|
+
used.add(f.number);
|
|
913
|
+
}
|
|
914
|
+
let allocated = 0;
|
|
915
|
+
await this.updateProject((project) => {
|
|
916
|
+
let candidate = project[key];
|
|
917
|
+
// Collision guard: skip any candidate that already exists.
|
|
918
|
+
while (used.has(candidate))
|
|
919
|
+
candidate++;
|
|
920
|
+
allocated = candidate;
|
|
921
|
+
project[key] = candidate + 1;
|
|
922
|
+
return project;
|
|
923
|
+
});
|
|
924
|
+
return allocated;
|
|
925
|
+
}
|
|
517
926
|
async loadPhase(phaseId) {
|
|
518
927
|
const raw = await readJson(this.phasePath(phaseId), PhaseSchema);
|
|
519
928
|
const normalized = this.normalizePhaseDocument(raw).phase;
|
|
@@ -565,7 +974,7 @@ export class PlanStore {
|
|
|
565
974
|
const raws = await this.loadRawFeatures();
|
|
566
975
|
const phases = await this.loadAllPhases();
|
|
567
976
|
const features = raws.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
568
|
-
return this.
|
|
977
|
+
return this.normalizeStructureSnapshot({ features }, phases).features;
|
|
569
978
|
}
|
|
570
979
|
async loadCodebaseProfile() {
|
|
571
980
|
try {
|
|
@@ -577,10 +986,11 @@ export class PlanStore {
|
|
|
577
986
|
}
|
|
578
987
|
async saveCodebaseProfile(profile) {
|
|
579
988
|
const parsed = CodebaseProfileSchema.parse(profile);
|
|
580
|
-
await atomicWriteJson(this.codebasePath(), parsed);
|
|
581
|
-
await this.
|
|
989
|
+
await atomicWriteJson(this.codebasePath(), parsed, this.root);
|
|
990
|
+
await this.touchTimestamp();
|
|
582
991
|
}
|
|
583
992
|
async loadResume() {
|
|
993
|
+
await this.migrateLegacyLocalFile(join(this.root, "resume.json"), this.resumePath());
|
|
584
994
|
try {
|
|
585
995
|
return await readJson(this.resumePath(), ResumeFocusSchema);
|
|
586
996
|
}
|
|
@@ -589,9 +999,20 @@ export class PlanStore {
|
|
|
589
999
|
}
|
|
590
1000
|
}
|
|
591
1001
|
async saveResume(resume) {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1002
|
+
// Track when `nextSteps` actually change (free-text can go stale; the recap
|
|
1003
|
+
// surfaces nextStepsUpdatedAt so staleness is visible). Preserved when
|
|
1004
|
+
// refreshResume keeps existing nextSteps; bumped only on a real change.
|
|
1005
|
+
const existing = await this.loadResume().catch(() => null);
|
|
1006
|
+
const nextStepsChanged = JSON.stringify(existing?.nextSteps ?? []) !== JSON.stringify(resume.nextSteps ?? []);
|
|
1007
|
+
const withTs = {
|
|
1008
|
+
...resume,
|
|
1009
|
+
nextStepsUpdatedAt: nextStepsChanged
|
|
1010
|
+
? nowISO()
|
|
1011
|
+
: (resume.nextStepsUpdatedAt || existing?.nextStepsUpdatedAt || nowISO()),
|
|
1012
|
+
};
|
|
1013
|
+
const parsed = ResumeFocusSchema.parse(withTs);
|
|
1014
|
+
await atomicWriteJson(this.resumePath(), parsed, this.root);
|
|
1015
|
+
await this.touchTimestamp();
|
|
595
1016
|
}
|
|
596
1017
|
/**
|
|
597
1018
|
* Authorize a temporary guard bypass so edit/write tools may proceed even
|
|
@@ -605,6 +1026,7 @@ export class PlanStore {
|
|
|
605
1026
|
currentPhaseId: "",
|
|
606
1027
|
inProgressTaskIds: [],
|
|
607
1028
|
nextSteps: [],
|
|
1029
|
+
nextStepsUpdatedAt: "",
|
|
608
1030
|
blockers: [],
|
|
609
1031
|
notes: "",
|
|
610
1032
|
lastSessionSummary: "",
|
|
@@ -636,6 +1058,7 @@ export class PlanStore {
|
|
|
636
1058
|
return until > Date.now();
|
|
637
1059
|
}
|
|
638
1060
|
async loadActivityLog() {
|
|
1061
|
+
await this.migrateLegacyLocalFile(join(this.root, "activity.json"), this.activityPath());
|
|
639
1062
|
try {
|
|
640
1063
|
return await readJson(this.activityPath(), ActivityLogSchema);
|
|
641
1064
|
}
|
|
@@ -643,44 +1066,6 @@ export class PlanStore {
|
|
|
643
1066
|
return { entries: [] };
|
|
644
1067
|
}
|
|
645
1068
|
}
|
|
646
|
-
async handoffExists() {
|
|
647
|
-
try {
|
|
648
|
-
await access(this.handoffPath());
|
|
649
|
-
return true;
|
|
650
|
-
}
|
|
651
|
-
catch {
|
|
652
|
-
return false;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
async loadHandoff() {
|
|
656
|
-
try {
|
|
657
|
-
const [content, info] = await Promise.all([
|
|
658
|
-
readFile(this.handoffPath(), "utf-8"),
|
|
659
|
-
stat(this.handoffPath()),
|
|
660
|
-
]);
|
|
661
|
-
const createdAt = content.match(/^Created at:\s*(.+)$/m)?.[1]?.trim() ?? info.birthtime.toISOString();
|
|
662
|
-
const updatedAt = content.match(/^Updated at:\s*(.+)$/m)?.[1]?.trim() ?? info.mtime.toISOString();
|
|
663
|
-
return {
|
|
664
|
-
content,
|
|
665
|
-
createdAt,
|
|
666
|
-
updatedAt,
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
catch {
|
|
670
|
-
return null;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
async saveHandoff(content) {
|
|
674
|
-
await atomicWriteText(this.handoffPath(), content);
|
|
675
|
-
await this.touchManifest();
|
|
676
|
-
}
|
|
677
|
-
async deleteHandoff() {
|
|
678
|
-
try {
|
|
679
|
-
await unlink(this.handoffPath());
|
|
680
|
-
}
|
|
681
|
-
catch { }
|
|
682
|
-
await this.touchManifest();
|
|
683
|
-
}
|
|
684
1069
|
async appendActivity(type, ref, summary) {
|
|
685
1070
|
const log = await this.loadActivityLog();
|
|
686
1071
|
const id = `act-${log.entries.length + 1}-${type}`;
|
|
@@ -689,8 +1074,8 @@ export class PlanStore {
|
|
|
689
1074
|
// Cap to last 200 entries
|
|
690
1075
|
if (log.entries.length > 200)
|
|
691
1076
|
log.entries = log.entries.slice(-200);
|
|
692
|
-
await atomicWriteJson(this.activityPath(), { entries: log.entries });
|
|
693
|
-
await this.
|
|
1077
|
+
await atomicWriteJson(this.activityPath(), { entries: log.entries }, this.root);
|
|
1078
|
+
await this.touchTimestamp();
|
|
694
1079
|
return entry;
|
|
695
1080
|
}
|
|
696
1081
|
/** Derive an up-to-date resume focus from the current workspace state. */
|
|
@@ -705,6 +1090,7 @@ export class PlanStore {
|
|
|
705
1090
|
currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
|
|
706
1091
|
inProgressTaskIds: inProgressTasks.map((t) => t.id),
|
|
707
1092
|
nextSteps: existing?.nextSteps ?? [],
|
|
1093
|
+
nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
|
|
708
1094
|
blockers: blockedTasks.map((t) => `${t.id}: ${t.title}`),
|
|
709
1095
|
notes: notes ?? existing?.notes ?? "",
|
|
710
1096
|
lastSessionSummary: lastSessionSummary ?? existing?.lastSessionSummary ?? "",
|
|
@@ -721,6 +1107,27 @@ export class PlanStore {
|
|
|
721
1107
|
return { requirements: [] };
|
|
722
1108
|
}
|
|
723
1109
|
}
|
|
1110
|
+
async linkedRequirementsForPhase(phaseId) {
|
|
1111
|
+
const requirements = await this.loadRequirements();
|
|
1112
|
+
return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
|
|
1113
|
+
}
|
|
1114
|
+
async loadPhaseWithRequirements(phaseId) {
|
|
1115
|
+
const [phase, linkedRequirements] = await Promise.all([
|
|
1116
|
+
this.loadPhase(phaseId),
|
|
1117
|
+
this.linkedRequirementsForPhase(phaseId),
|
|
1118
|
+
]);
|
|
1119
|
+
return { ...phase, linkedRequirements };
|
|
1120
|
+
}
|
|
1121
|
+
async loadAllPhasesWithRequirements() {
|
|
1122
|
+
const [phases, requirements] = await Promise.all([
|
|
1123
|
+
this.loadAllPhases(),
|
|
1124
|
+
this.loadRequirements(),
|
|
1125
|
+
]);
|
|
1126
|
+
return phases.map((phase) => ({
|
|
1127
|
+
...phase,
|
|
1128
|
+
linkedRequirements: requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phase.id)),
|
|
1129
|
+
}));
|
|
1130
|
+
}
|
|
724
1131
|
async loadAllPhases() {
|
|
725
1132
|
const { readdir } = await import("node:fs/promises");
|
|
726
1133
|
let files;
|
|
@@ -751,6 +1158,22 @@ export class PlanStore {
|
|
|
751
1158
|
return left.createdAt.localeCompare(right.createdAt);
|
|
752
1159
|
});
|
|
753
1160
|
}
|
|
1161
|
+
/** Derive the parent display snapshot for a phase from its tasks' canonical
|
|
1162
|
+
* statuses. Pure, non-persisting. */
|
|
1163
|
+
async loadPhaseDisplay(phaseId) {
|
|
1164
|
+
const phase = await this.loadPhase(phaseId);
|
|
1165
|
+
const childStatuses = phase.tasks.map((t) => fromCanonicalStatus(t.status));
|
|
1166
|
+
return deriveParentDisplay(childStatuses);
|
|
1167
|
+
}
|
|
1168
|
+
/** Derive the parent display snapshot for a feature from its phases' DERIVED
|
|
1169
|
+
* canonical statuses (each phase status is derived from its tasks at read
|
|
1170
|
+
* time, then mapped via fromCanonicalStatus). Pure, non-persisting. */
|
|
1171
|
+
async loadFeatureDisplay(featureId) {
|
|
1172
|
+
const phases = await this.loadAllPhases();
|
|
1173
|
+
const featurePhases = phases.filter((p) => p.featureId === featureId);
|
|
1174
|
+
const childStatuses = featurePhases.map((p) => fromCanonicalStatus(p.status));
|
|
1175
|
+
return deriveParentDisplay(childStatuses);
|
|
1176
|
+
}
|
|
754
1177
|
async loadAll() {
|
|
755
1178
|
const [manifest, project, requirements, phases] = await Promise.all([
|
|
756
1179
|
this.loadManifest(),
|
|
@@ -760,7 +1183,8 @@ export class PlanStore {
|
|
|
760
1183
|
]);
|
|
761
1184
|
const rawFeatures = await this.loadRawFeatures();
|
|
762
1185
|
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
763
|
-
|
|
1186
|
+
const normalized = this.normalizeStructureSnapshot({ features }, phases);
|
|
1187
|
+
return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
|
|
764
1188
|
}
|
|
765
1189
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
766
1190
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -938,18 +1362,14 @@ export class PlanStore {
|
|
|
938
1362
|
let shortIdsAssigned = 0;
|
|
939
1363
|
let prioritiesAssigned = 0;
|
|
940
1364
|
let featuresDirty = false;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
return index + 1;
|
|
945
|
-
}
|
|
946
|
-
return current;
|
|
947
|
-
};
|
|
1365
|
+
// Priority is left to reorder (midpoint-insert); ensureShortIds only
|
|
1366
|
+
// backfills shortIds. New items keep priority 0 until first drag reindex.
|
|
1367
|
+
const assignPriority = (current, _index) => current;
|
|
948
1368
|
// Features: shortId + priority (project scope)
|
|
949
1369
|
const sortedFeatures = [...featuresDoc.features].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
950
1370
|
sortedFeatures.forEach((f, index) => {
|
|
951
1371
|
if (!f.shortId) {
|
|
952
|
-
f.shortId = createShortId(existing);
|
|
1372
|
+
f.shortId = createShortId(existing, `feature:${f.number}:${f.id}`);
|
|
953
1373
|
existing.add(f.shortId);
|
|
954
1374
|
shortIdsAssigned += 1;
|
|
955
1375
|
featuresDirty = true;
|
|
@@ -968,7 +1388,7 @@ export class PlanStore {
|
|
|
968
1388
|
for (const phase of phases) {
|
|
969
1389
|
let phaseDirty = false;
|
|
970
1390
|
if (!phase.shortId) {
|
|
971
|
-
phase.shortId = createShortId(existing);
|
|
1391
|
+
phase.shortId = createShortId(existing, `phase:${phase.number}:${phase.id}`);
|
|
972
1392
|
existing.add(phase.shortId);
|
|
973
1393
|
shortIdsAssigned += 1;
|
|
974
1394
|
phaseDirty = true;
|
|
@@ -982,7 +1402,7 @@ export class PlanStore {
|
|
|
982
1402
|
const sortedTasks = [...phase.tasks].sort((a, b) => a.number - b.number || a.createdAt.localeCompare(b.createdAt));
|
|
983
1403
|
sortedTasks.forEach((t, index) => {
|
|
984
1404
|
if (!t.shortId) {
|
|
985
|
-
t.shortId = createShortId(existing);
|
|
1405
|
+
t.shortId = createShortId(existing, `task:${t.number}:${t.id}`);
|
|
986
1406
|
existing.add(t.shortId);
|
|
987
1407
|
shortIdsAssigned += 1;
|
|
988
1408
|
phaseDirty = true;
|
|
@@ -1022,13 +1442,37 @@ export class PlanStore {
|
|
|
1022
1442
|
/** Repair dangling references and report integrity. One-shot maintenance op. */
|
|
1023
1443
|
async repair() {
|
|
1024
1444
|
return this.runAsBatch(async () => {
|
|
1445
|
+
// Ensure the .planner/.gitignore ignores transients (P042). Idempotent;
|
|
1446
|
+
// upgrades plans initialized before the .local/ move.
|
|
1447
|
+
await this.ensureGitignore().catch(() => { });
|
|
1025
1448
|
const migrated = await this.migratePhaseIds();
|
|
1449
|
+
await this.repairPhaseFeatureRefs();
|
|
1026
1450
|
const backfill = await this.ensureShortIdsAndPriority();
|
|
1451
|
+
// Rebuild phase containment from each task's own phaseId. Heals plans
|
|
1452
|
+
// corrupted by the migrateToGlobalSequence index-mismatch bug (core
|
|
1453
|
+
// <0.2.19-next.7). Lossless + idempotent — safe to run every repair.
|
|
1454
|
+
const containment = await this.rebuildContainment();
|
|
1027
1455
|
const integrity = await this.validateIntegrity();
|
|
1028
1456
|
await this.writeGenerated();
|
|
1029
|
-
return { migrated, backfill, integrity };
|
|
1457
|
+
return { migrated, backfill, containment, integrity };
|
|
1030
1458
|
});
|
|
1031
1459
|
}
|
|
1460
|
+
async repairPhaseFeatureRefs() {
|
|
1461
|
+
const features = await this.loadRawFeatures();
|
|
1462
|
+
const phases = await this.loadAllPhases();
|
|
1463
|
+
let changed = 0;
|
|
1464
|
+
for (const phase of phases) {
|
|
1465
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
|
|
1466
|
+
if (resolvedFeatureId && resolvedFeatureId !== phase.featureId) {
|
|
1467
|
+
await this.savePhase({ ...phase, featureId: resolvedFeatureId });
|
|
1468
|
+
changed += 1;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (changed > 0) {
|
|
1472
|
+
await this.updateFeatures((doc) => doc);
|
|
1473
|
+
}
|
|
1474
|
+
return changed;
|
|
1475
|
+
}
|
|
1032
1476
|
/** Validate plan integrity: globally unique phase ids and resolvable feature.phaseIds. */
|
|
1033
1477
|
async validateIntegrity() {
|
|
1034
1478
|
const phases = await this.loadAllPhases();
|
|
@@ -1074,18 +1518,28 @@ export class PlanStore {
|
|
|
1074
1518
|
return "rejected";
|
|
1075
1519
|
if (meaningful.every((s) => s === "done"))
|
|
1076
1520
|
return "done";
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1521
|
+
const hasDone = meaningful.some((s) => s === "done");
|
|
1522
|
+
const hasActive = meaningful.some((s) => s === "in-progress");
|
|
1523
|
+
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1524
|
+
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1525
|
+
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1526
|
+
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1527
|
+
if (hasActive)
|
|
1528
|
+
return "in-progress";
|
|
1529
|
+
// If completed work exists and the ONLY remaining meaningful work is deferred,
|
|
1530
|
+
// surface deferred instead of implying active execution.
|
|
1531
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1532
|
+
return "deferred";
|
|
1533
|
+
// Partial completion with remaining planned/blocked/waiting work still means
|
|
1534
|
+
// the phase has genuinely started and is not terminal yet.
|
|
1535
|
+
if (hasDone)
|
|
1082
1536
|
return "in-progress";
|
|
1083
1537
|
// No progress at all ⇒ surface the stall / not-started state (blocked > waiting > deferred > planned).
|
|
1084
|
-
if (
|
|
1538
|
+
if (hasBlocked)
|
|
1085
1539
|
return "blocked";
|
|
1086
|
-
if (
|
|
1540
|
+
if (hasWaiting)
|
|
1087
1541
|
return "waiting";
|
|
1088
|
-
if (
|
|
1542
|
+
if (hasDeferred)
|
|
1089
1543
|
return "deferred";
|
|
1090
1544
|
return "planned";
|
|
1091
1545
|
}
|
|
@@ -1100,17 +1554,25 @@ export class PlanStore {
|
|
|
1100
1554
|
return "rejected";
|
|
1101
1555
|
if (meaningful.every((s) => s === "done"))
|
|
1102
1556
|
return "done";
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1557
|
+
const hasDone = meaningful.some((s) => s === "done");
|
|
1558
|
+
const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
|
|
1559
|
+
const hasPlanned = meaningful.some((s) => s === "planned");
|
|
1560
|
+
const hasBlocked = meaningful.some((s) => s === "blocked");
|
|
1561
|
+
const hasWaiting = meaningful.some((s) => s === "waiting");
|
|
1562
|
+
const hasDeferred = meaningful.some((s) => s === "deferred");
|
|
1563
|
+
if (hasActive)
|
|
1564
|
+
return "in-progress";
|
|
1565
|
+
// Same rule as phases: done + deferred-only remainder is deferred, not active.
|
|
1566
|
+
if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
|
|
1567
|
+
return "deferred";
|
|
1568
|
+
if (hasDone)
|
|
1107
1569
|
return "in-progress";
|
|
1108
1570
|
// No progress at all ⇒ surface the stall / not-started state.
|
|
1109
|
-
if (
|
|
1571
|
+
if (hasBlocked)
|
|
1110
1572
|
return "blocked";
|
|
1111
|
-
if (
|
|
1573
|
+
if (hasWaiting)
|
|
1112
1574
|
return "waiting";
|
|
1113
|
-
if (
|
|
1575
|
+
if (hasDeferred)
|
|
1114
1576
|
return "deferred";
|
|
1115
1577
|
return "planned";
|
|
1116
1578
|
}
|
|
@@ -1128,17 +1590,70 @@ export class PlanStore {
|
|
|
1128
1590
|
const phase = await this.loadPhase(phaseId);
|
|
1129
1591
|
let cleared = null;
|
|
1130
1592
|
if (phase.status === "done" && phase.handoff !== "") {
|
|
1131
|
-
await this.
|
|
1593
|
+
await this.clearPhaseHandoff(phaseId, "phase-done");
|
|
1132
1594
|
const features = await this.loadFeatures();
|
|
1133
1595
|
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1134
1596
|
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
1135
1597
|
}
|
|
1598
|
+
// Append a statusLog entry to the phase when its DERIVED status changed
|
|
1599
|
+
// (audit trail; status itself is NOT persisted). Idempotent: only appends
|
|
1600
|
+
// when the new derived status differs from the last recorded toStatus.
|
|
1601
|
+
await this.#appendPhaseStatusLog(phaseId);
|
|
1602
|
+
// Roll up to the parent feature's statusLog too.
|
|
1603
|
+
if (phase.featureId)
|
|
1604
|
+
await this.#appendFeatureStatusLog(phase.featureId);
|
|
1136
1605
|
await this.refreshResume();
|
|
1137
1606
|
return cleared;
|
|
1138
1607
|
}
|
|
1608
|
+
/** Append a PhaseStatusLogEntry to the phase when its derived status changed
|
|
1609
|
+
* vs. the last recorded toStatus (baseline "draft" when empty, matching the
|
|
1610
|
+
* phase-creation literal). Idempotent across repeated reads of the same state. */
|
|
1611
|
+
async #appendPhaseStatusLog(phaseId) {
|
|
1612
|
+
await this.updatePhase(phaseId, (p) => {
|
|
1613
|
+
const last = p.statusLog.at(-1)?.toStatus ?? "draft";
|
|
1614
|
+
if (p.status !== last) {
|
|
1615
|
+
p.statusLog = [...p.statusLog, {
|
|
1616
|
+
id: createStatusLogEntryId(),
|
|
1617
|
+
date: nowISO(),
|
|
1618
|
+
fromStatus: last,
|
|
1619
|
+
toStatus: p.status,
|
|
1620
|
+
title: `${last} → ${p.status}`,
|
|
1621
|
+
description: "",
|
|
1622
|
+
}];
|
|
1623
|
+
}
|
|
1624
|
+
return p;
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
/** Append a StatusLogEntry to the feature when its derived status changed
|
|
1628
|
+
* vs. the last recorded toStatus (baseline "planned" when empty, matching
|
|
1629
|
+
* the feature-creation/empty-phases derivation). Idempotent. */
|
|
1630
|
+
async #appendFeatureStatusLog(featureId) {
|
|
1631
|
+
const features = await this.loadFeatures();
|
|
1632
|
+
const feature = features.features.find((f) => f.id === featureId);
|
|
1633
|
+
if (!feature)
|
|
1634
|
+
return;
|
|
1635
|
+
const last = feature.statusLog.at(-1)?.toStatus ?? "planned";
|
|
1636
|
+
if (feature.status !== last) {
|
|
1637
|
+
await this.updateFeatures((doc) => {
|
|
1638
|
+
const target = doc.features.find((f) => f.id === featureId);
|
|
1639
|
+
if (target && target.statusLog.at(-1)?.toStatus !== feature.status) {
|
|
1640
|
+
const baseline = target.statusLog.at(-1)?.toStatus ?? "planned";
|
|
1641
|
+
target.statusLog = [...target.statusLog, {
|
|
1642
|
+
id: createStatusLogEntryId(),
|
|
1643
|
+
date: nowISO(),
|
|
1644
|
+
fromStatus: baseline,
|
|
1645
|
+
toStatus: feature.status,
|
|
1646
|
+
title: `${baseline} → ${feature.status}`,
|
|
1647
|
+
description: "",
|
|
1648
|
+
}];
|
|
1649
|
+
}
|
|
1650
|
+
return doc;
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1139
1654
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
1140
1655
|
async updateProject(updater) {
|
|
1141
|
-
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater);
|
|
1656
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, updater, this.root);
|
|
1142
1657
|
await this.maybeAutoSync();
|
|
1143
1658
|
return updated;
|
|
1144
1659
|
}
|
|
@@ -1154,14 +1669,14 @@ export class PlanStore {
|
|
|
1154
1669
|
return updated;
|
|
1155
1670
|
}
|
|
1156
1671
|
async updateRequirements(updater) {
|
|
1157
|
-
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater);
|
|
1672
|
+
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
|
|
1158
1673
|
await this.maybeAutoSync();
|
|
1159
1674
|
return updated;
|
|
1160
1675
|
}
|
|
1161
1676
|
async saveProject(project) {
|
|
1162
1677
|
const parsed = ProjectSchema.parse(project);
|
|
1163
|
-
await atomicWriteJson(this.projectPath(), parsed);
|
|
1164
|
-
await this.
|
|
1678
|
+
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
1679
|
+
await this.touchTimestamp();
|
|
1165
1680
|
await this.maybeAutoSync();
|
|
1166
1681
|
}
|
|
1167
1682
|
async saveFeatures(features) {
|
|
@@ -1169,7 +1684,7 @@ export class PlanStore {
|
|
|
1169
1684
|
await this.migrateLegacy();
|
|
1170
1685
|
await this.saveFeaturesRaw(features);
|
|
1171
1686
|
});
|
|
1172
|
-
await this.
|
|
1687
|
+
await this.touchTimestamp();
|
|
1173
1688
|
await this.maybeAutoSync();
|
|
1174
1689
|
}
|
|
1175
1690
|
/** Per-file write of all features + orphan reconcile. No lock (caller holds withFeaturesLock). */
|
|
@@ -1178,7 +1693,7 @@ export class PlanStore {
|
|
|
1178
1693
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
1179
1694
|
const wantIds = new Set(parsed.features.map((f) => f.id));
|
|
1180
1695
|
for (const feat of parsed.features) {
|
|
1181
|
-
await atomicWriteJson(this.featurePath(feat.id), feat);
|
|
1696
|
+
await atomicWriteJson(this.featurePath(feat.id), feat, this.root);
|
|
1182
1697
|
}
|
|
1183
1698
|
// Orphan reconcile: remove feature files no longer in the document.
|
|
1184
1699
|
try {
|
|
@@ -1202,27 +1717,43 @@ export class PlanStore {
|
|
|
1202
1717
|
await this.migrateLegacy();
|
|
1203
1718
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
1204
1719
|
const parsed = FeatureSchema.parse(feature);
|
|
1205
|
-
await atomicWriteJson(this.featurePath(parsed.id), parsed);
|
|
1720
|
+
await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
|
|
1206
1721
|
});
|
|
1207
|
-
await this.
|
|
1722
|
+
await this.touchTimestamp();
|
|
1208
1723
|
await this.maybeAutoSync();
|
|
1209
1724
|
}
|
|
1210
1725
|
async saveRequirements(reqs) {
|
|
1211
1726
|
const parsed = RequirementsDocumentSchema.parse(reqs);
|
|
1212
|
-
await atomicWriteJson(this.requirementsPath(), parsed);
|
|
1213
|
-
await this.
|
|
1727
|
+
await atomicWriteJson(this.requirementsPath(), parsed, this.root);
|
|
1728
|
+
await this.touchTimestamp();
|
|
1214
1729
|
}
|
|
1215
1730
|
async savePhase(phase) {
|
|
1216
|
-
const
|
|
1731
|
+
const features = await this.loadRawFeatures();
|
|
1732
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
|
|
1733
|
+
// Referential integrity: if a featureId is present but cannot be resolved
|
|
1734
|
+
// to a known feature, REJECT — never persist an orphan featureId.
|
|
1735
|
+
// NOTE: a missing/empty featureId is intentionally ALLOWED here so that
|
|
1736
|
+
// legacy migrations, repair, and feature-delete (unlink) can persist phases
|
|
1737
|
+
// without a feature yet. The hard "featureId required" gate lives at the
|
|
1738
|
+
// adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
|
|
1739
|
+
// planner-task-add), which is where user-facing creation happens.
|
|
1740
|
+
if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
|
|
1741
|
+
throw new PlanStoreError(`Cannot save phase "${phase.title}": featureId "${phase.featureId}" does not match any existing feature. Use a valid feature UUID, F00x ref, or shortId.`);
|
|
1742
|
+
}
|
|
1743
|
+
const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
|
|
1744
|
+
? { ...phase, featureId: resolvedFeatureId }
|
|
1745
|
+
: phase;
|
|
1746
|
+
const parsed = PhaseSchema.parse(this.normalizePhaseDocument(normalizedInput).phase);
|
|
1217
1747
|
await mkdir(this.phasesDir(), { recursive: true });
|
|
1218
|
-
await atomicWriteJson(this.phasePath(parsed.id), parsed);
|
|
1219
|
-
await this.
|
|
1748
|
+
await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
|
|
1749
|
+
await this.touchTimestamp();
|
|
1220
1750
|
await this.maybeAutoSync();
|
|
1221
1751
|
}
|
|
1222
1752
|
/** Atomic read-modify-write on a single phase file. Serializes concurrent
|
|
1223
1753
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
1224
1754
|
* don't lose tasks (last-write-wins race condition). */
|
|
1225
1755
|
async updatePhase(phaseId, updater) {
|
|
1756
|
+
const features = await this.loadRawFeatures();
|
|
1226
1757
|
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
1227
1758
|
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
1228
1759
|
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
@@ -1230,7 +1761,15 @@ export class PlanStore {
|
|
|
1230
1761
|
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
1231
1762
|
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
1232
1763
|
const next = updater(current);
|
|
1233
|
-
|
|
1764
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, next.featureId);
|
|
1765
|
+
// Referential integrity: reject orphan featureId.
|
|
1766
|
+
if (next.featureId && next.featureId.trim() && !resolvedFeatureId) {
|
|
1767
|
+
throw new PlanStoreError(`Cannot update phase: featureId "${next.featureId}" does not match any existing feature.`);
|
|
1768
|
+
}
|
|
1769
|
+
const normalizedInput = resolvedFeatureId && resolvedFeatureId !== next.featureId
|
|
1770
|
+
? { ...next, featureId: resolvedFeatureId }
|
|
1771
|
+
: next;
|
|
1772
|
+
return this.normalizePhaseDocument(normalizedInput).phase;
|
|
1234
1773
|
});
|
|
1235
1774
|
await this.maybeAutoSync();
|
|
1236
1775
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
@@ -1246,10 +1785,74 @@ export class PlanStore {
|
|
|
1246
1785
|
const now = new Date().toISOString();
|
|
1247
1786
|
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoff: text, handoffUpdatedAt: now }));
|
|
1248
1787
|
}
|
|
1249
|
-
/**
|
|
1250
|
-
*
|
|
1251
|
-
|
|
1252
|
-
|
|
1788
|
+
/** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
|
|
1789
|
+
* Does NOT clear the handoff — content is kept until a task starts or the
|
|
1790
|
+
* phase completes, so a restart between read and resume does not lose it. */
|
|
1791
|
+
async markHandoffRead(phaseId) {
|
|
1792
|
+
await this.updatePhase(phaseId, (phase) => ({ ...phase, handoffReadAt: nowISO() }));
|
|
1793
|
+
}
|
|
1794
|
+
/** One-time import of a legacy .planner/HANDOFF.md file (file-based handoff
|
|
1795
|
+
* from before F004) into the entity-scoped phase.handoff. Idempotent: if the
|
|
1796
|
+
* file is absent or empty, no-op. If it exists + non-empty + the target phase
|
|
1797
|
+
* has no handoff, writes the content onto the current in-progress phase (or
|
|
1798
|
+
* the first phase if none in-progress) with an "imported" handoffHistory entry,
|
|
1799
|
+
* then renames the file to HANDOFF.md.bak so it won't re-import. If the target
|
|
1800
|
+
* already has a handoff, the entity-scoped one wins and the file is just .bak'd. */
|
|
1801
|
+
async importLegacyHandoffFile() {
|
|
1802
|
+
const filePath = join(this.root, "HANDOFF.md");
|
|
1803
|
+
const content = await readFile(filePath, "utf-8").catch(() => null);
|
|
1804
|
+
if (content === null)
|
|
1805
|
+
return { imported: false };
|
|
1806
|
+
if (content.trim() === "") {
|
|
1807
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1808
|
+
return { imported: false };
|
|
1809
|
+
}
|
|
1810
|
+
const phases = await this.loadAllPhases();
|
|
1811
|
+
const target = phases.find((p) => p.status === "in-progress") ?? phases[0] ?? null;
|
|
1812
|
+
if (!target)
|
|
1813
|
+
return { imported: false }; // no phases yet — leave file for a later run
|
|
1814
|
+
if ((target.handoff ?? "") === "") {
|
|
1815
|
+
await this.setPhaseHandoff(target.id, content + "\n\n<!-- imported from legacy .planner/HANDOFF.md -->\n");
|
|
1816
|
+
await this.updatePhase(target.id, (p) => ({
|
|
1817
|
+
...p,
|
|
1818
|
+
handoffHistory: [{ file: "(legacy HANDOFF.md)", clearedAt: nowISO(), reason: "imported" }, ...(p.handoffHistory ?? [])].slice(0, 5),
|
|
1819
|
+
}));
|
|
1820
|
+
}
|
|
1821
|
+
await rename(filePath, filePath + ".bak").catch(() => { });
|
|
1822
|
+
const features = await this.loadFeatures();
|
|
1823
|
+
const feat = features.features.find((f) => f.id === target.featureId);
|
|
1824
|
+
return { imported: true, phaseRef: formatPhaseRef(target.number, feat?.number) };
|
|
1825
|
+
}
|
|
1826
|
+
/** Clear the handoff for a phase, archiving its content first. The handoff
|
|
1827
|
+
* markdown is written to .planner/handoff-archive/<phaseId>-<ISO>.md and a
|
|
1828
|
+
* metadata entry { file, clearedAt, reason } is prepended to handoffHistory
|
|
1829
|
+
* (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
|
|
1830
|
+
* left unchanged as an audit trail. If the handoff is empty, this is a no-op.
|
|
1831
|
+
* reason: "task-started" | "phase-done" | "manual" | "superseded" | "imported". */
|
|
1832
|
+
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
1833
|
+
await this.migrateLegacyHandoffArchive();
|
|
1834
|
+
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
1835
|
+
if (!phase || phase.handoff === "")
|
|
1836
|
+
return; // nothing to archive
|
|
1837
|
+
const clearedAt = nowISO();
|
|
1838
|
+
const safeTs = clearedAt.replace(/[:.]/g, "-");
|
|
1839
|
+
const archiveDir = this.handoffArchiveDir();
|
|
1840
|
+
await mkdir(archiveDir, { recursive: true }).catch(() => { });
|
|
1841
|
+
const fileName = `${phaseId}-${safeTs}.md`;
|
|
1842
|
+
const filePath = join(archiveDir, fileName);
|
|
1843
|
+
await atomicWriteText(filePath, phase.handoff, this.root);
|
|
1844
|
+
const entry = { file: `handoff-archive/${fileName}`, clearedAt, reason };
|
|
1845
|
+
// Cap history at 5: prepend new entry, drop oldest (and delete its file).
|
|
1846
|
+
const trimmed = [entry, ...(phase.handoffHistory ?? [])].slice(0, 5);
|
|
1847
|
+
const dropped = (phase.handoffHistory ?? []).slice(4); // entries beyond index 4 after prepend
|
|
1848
|
+
for (const d of dropped) {
|
|
1849
|
+
if (!d?.file)
|
|
1850
|
+
continue;
|
|
1851
|
+
// Legacy entries used `.planner/handoff-archive/...`; new entries use `.planner/.local/handoff-archive/...`.
|
|
1852
|
+
await unlink(join(this.handoffArchiveDir(), basename(d.file))).catch(() => { });
|
|
1853
|
+
await unlink(join(this.root, d.file)).catch(() => { });
|
|
1854
|
+
}
|
|
1855
|
+
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
|
|
1253
1856
|
}
|
|
1254
1857
|
/** List all phases that have a non-empty handoff, newest first, with a
|
|
1255
1858
|
* human-readable composite ref (P00x or P00x(F00x)) and a first-line excerpt. */
|
|
@@ -1266,14 +1869,64 @@ export class PlanStore {
|
|
|
1266
1869
|
const fnum = p.featureId ? featureNumber.get(p.featureId) : undefined;
|
|
1267
1870
|
out.push({
|
|
1268
1871
|
phaseId: p.id,
|
|
1872
|
+
featureId: p.featureId,
|
|
1269
1873
|
compositeRef: formatPhaseRef(p.number, fnum),
|
|
1270
1874
|
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
1271
1875
|
firstLine: handoffFirstLine(p.handoff),
|
|
1876
|
+
content: p.handoff,
|
|
1272
1877
|
});
|
|
1273
1878
|
}
|
|
1274
1879
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1275
1880
|
return out;
|
|
1276
1881
|
}
|
|
1882
|
+
async listOrphanPhases() {
|
|
1883
|
+
const phases = await this.loadAllPhases();
|
|
1884
|
+
const featuresDoc = await this.loadFeatures();
|
|
1885
|
+
const out = [];
|
|
1886
|
+
for (const phase of phases) {
|
|
1887
|
+
const resolvedFeatureId = resolveStoredFeatureId(featuresDoc.features, phase.featureId);
|
|
1888
|
+
if (resolvedFeatureId)
|
|
1889
|
+
continue;
|
|
1890
|
+
const reason = phase.featureId?.trim()
|
|
1891
|
+
? `feature not found: ${phase.featureId}`
|
|
1892
|
+
: "missing featureId";
|
|
1893
|
+
out.push({
|
|
1894
|
+
phaseId: phase.id,
|
|
1895
|
+
featureId: phase.featureId,
|
|
1896
|
+
shortId: phase.shortId,
|
|
1897
|
+
compositeRef: formatPhaseRef(phase.number),
|
|
1898
|
+
title: phase.title,
|
|
1899
|
+
reason,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
out.sort((a, b) => a.compositeRef.localeCompare(b.compositeRef));
|
|
1903
|
+
return out;
|
|
1904
|
+
}
|
|
1905
|
+
async cleanupOrphanPhases() {
|
|
1906
|
+
return this.runAsBatch(async () => {
|
|
1907
|
+
const found = await this.listOrphanPhases();
|
|
1908
|
+
if (found.length === 0)
|
|
1909
|
+
return { found, removed: [] };
|
|
1910
|
+
const orphanIds = new Set(found.map((phase) => phase.phaseId));
|
|
1911
|
+
for (const orphan of found) {
|
|
1912
|
+
try {
|
|
1913
|
+
await unlink(this.phasePath(orphan.phaseId));
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
// already gone
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
await this.updateFeatures((doc) => {
|
|
1920
|
+
for (const feature of doc.features) {
|
|
1921
|
+
feature.phaseIds = feature.phaseIds.filter((id) => !orphanIds.has(id));
|
|
1922
|
+
}
|
|
1923
|
+
return doc;
|
|
1924
|
+
});
|
|
1925
|
+
await this.touchTimestamp();
|
|
1926
|
+
await this.writeGenerated();
|
|
1927
|
+
return { found, removed: found };
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1277
1930
|
async deletePhase(phaseId) {
|
|
1278
1931
|
try {
|
|
1279
1932
|
await unlink(this.phasePath(phaseId));
|
|
@@ -1281,7 +1934,7 @@ export class PlanStore {
|
|
|
1281
1934
|
catch {
|
|
1282
1935
|
// already gone
|
|
1283
1936
|
}
|
|
1284
|
-
await this.
|
|
1937
|
+
await this.touchTimestamp();
|
|
1285
1938
|
}
|
|
1286
1939
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
1287
1940
|
/** Load the full workspace (manifest + phases + project + requirements + features) */
|
|
@@ -1294,8 +1947,10 @@ export class PlanStore {
|
|
|
1294
1947
|
return { manifest, phases, project, features, requirements };
|
|
1295
1948
|
}
|
|
1296
1949
|
// ── Markdown generation ────────────────────────────────────────────
|
|
1297
|
-
/** Load all data, render markdown, and write into generated/.
|
|
1950
|
+
/** Load all data, render markdown, and write into generated/. Skips files
|
|
1951
|
+
* whose content is unchanged to avoid unnecessary backup churn. */
|
|
1298
1952
|
async writeGenerated() {
|
|
1953
|
+
await this.migrateLegacyGeneratedDir();
|
|
1299
1954
|
const { PlanRenderer } = await import("./renderer.js");
|
|
1300
1955
|
const plan = await this.loadAll();
|
|
1301
1956
|
const renderer = new PlanRenderer();
|
|
@@ -1311,21 +1966,27 @@ export class PlanStore {
|
|
|
1311
1966
|
if (dir !== genDir) {
|
|
1312
1967
|
await mkdir(dir, { recursive: true });
|
|
1313
1968
|
}
|
|
1969
|
+
try {
|
|
1970
|
+
const existing = await readFile(fullPath, "utf-8");
|
|
1971
|
+
if (existing === content)
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
// file does not exist yet — write it
|
|
1976
|
+
}
|
|
1314
1977
|
await writeFile(fullPath, content, "utf-8");
|
|
1315
1978
|
written.push(relPath);
|
|
1316
1979
|
}
|
|
1317
1980
|
return written;
|
|
1318
1981
|
}
|
|
1319
1982
|
// ── Touch ────────────────────────────────────────────────────────────
|
|
1320
|
-
/** Update
|
|
1321
|
-
async
|
|
1983
|
+
/** Update .local/timestamp.json to reflect a change. */
|
|
1984
|
+
async touchTimestamp() {
|
|
1322
1985
|
try {
|
|
1323
|
-
|
|
1324
|
-
m.updatedAt = nowISO();
|
|
1325
|
-
await atomicWriteJson(this.manifestPath(), m);
|
|
1986
|
+
await atomicWriteJson(this.timestampPath(), { updatedAt: nowISO() }, this.root);
|
|
1326
1987
|
}
|
|
1327
1988
|
catch {
|
|
1328
|
-
// if
|
|
1989
|
+
// if .local/ doesn't exist yet, skip
|
|
1329
1990
|
}
|
|
1330
1991
|
}
|
|
1331
1992
|
}
|