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