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