@agent-plan/core 0.2.26 → 0.2.28
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/description-freshness.d.ts +37 -0
- package/dist/description-freshness.d.ts.map +1 -0
- package/dist/description-freshness.js +84 -0
- package/dist/handoff-context.d.ts +135 -3
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +336 -25
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.d.ts.map +1 -1
- package/dist/package-version.js +1 -1
- package/dist/payload-fallback.d.ts +1 -1
- package/dist/payload-fallback.d.ts.map +1 -1
- package/dist/payload-fallback.js +6 -0
- package/dist/plan-store.d.ts +129 -30
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +690 -115
- package/dist/planner-rules.d.ts.map +1 -1
- package/dist/planner-rules.js +6 -2
- package/dist/read-tracking.d.ts +27 -3
- package/dist/read-tracking.d.ts.map +1 -1
- package/dist/read-tracking.js +53 -4
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +33 -9
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +0 -1
- package/dist/requirement-macro-tasks.d.ts +2 -2
- package/dist/requirement-macro-tasks.d.ts.map +1 -1
- package/dist/requirement-macro-tasks.js +2 -2
- package/dist/runtime-diagnostics.d.ts +34 -0
- package/dist/runtime-diagnostics.d.ts.map +1 -0
- package/dist/runtime-diagnostics.js +39 -0
- package/dist/schema.d.ts +487 -17
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +24 -4
- package/dist/task-context.d.ts +41 -2
- package/dist/task-context.d.ts.map +1 -1
- package/dist/task-context.js +102 -4
- package/dist/task-selection.d.ts +44 -1
- package/dist/task-selection.d.ts.map +1 -1
- package/dist/task-selection.js +158 -7
- package/dist/write-coordination.d.ts +27 -0
- package/dist/write-coordination.d.ts.map +1 -0
- package/dist/write-coordination.js +223 -0
- package/package.json +1 -1
- package/planner-skill.md +36 -20
package/dist/plan-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, copyFile, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { z, ZodError } from "zod";
|
|
@@ -14,17 +14,83 @@ const PLANNER_GITIGNORE = [
|
|
|
14
14
|
"generated/",
|
|
15
15
|
"",
|
|
16
16
|
].join("\n");
|
|
17
|
-
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
|
|
17
|
+
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskSchema, TaskPauseSnapshotSchema, ProjectSchema, AcceptedDecisionSchema, RequirementSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
|
|
18
18
|
import { createFeatureId, createIdeaId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatFeatureRef, formatIdeaRef, formatPhaseRef, formatThreeDigitNumber, isLegacyPhaseId } from "./naming.js";
|
|
19
19
|
import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
|
|
20
|
+
import { buildHierarchicalDescriptionFreshness } from "./description-freshness.js";
|
|
20
21
|
import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
|
|
21
22
|
import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
|
|
22
23
|
import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
|
|
23
|
-
import { applyHandoffContextSync, auditPhaseHandoff, handoffContentHash, HandoffContractError, } from "./handoff-context.js";
|
|
24
|
+
import { applyHandoffContextSync, auditPhaseHandoff, externalizeOversizedHandoffContent, handoffContentHash, materializeHandoffMetadata, HandoffContractError, TARGET_HANDOFF_CONTENT_CHARS, validateHandoffReadBackVerification, } from "./handoff-context.js";
|
|
25
|
+
import { withPlanRootWriteLock } from "./write-coordination.js";
|
|
26
|
+
import { ALLOCATION_REGISTRY_VERSION, PLAN_SCHEMA_VERSION, SUPPORTED_ALLOCATION_KINDS, isSupportedAllocationKind, unsupportedAllocationKindDetails, } from "./runtime-diagnostics.js";
|
|
24
27
|
function nowISO() {
|
|
25
28
|
return new Date().toISOString();
|
|
26
29
|
}
|
|
27
30
|
const MAX_SESSION_INFO_ENTRIES = 16;
|
|
31
|
+
function normalizeAcceptedDecisionCreate(input, acceptedAt) {
|
|
32
|
+
const title = input.title.trim();
|
|
33
|
+
if (!title) {
|
|
34
|
+
throw new PlanStoreError("Accepted decision title is required.", undefined, {
|
|
35
|
+
errorCode: "ACCEPTED_DECISION_TITLE_REQUIRED",
|
|
36
|
+
entity: "acceptedDecision",
|
|
37
|
+
operation: "create",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return AcceptedDecisionSchema.parse({
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
title,
|
|
43
|
+
decision: input.decision?.trim() ?? "",
|
|
44
|
+
rationale: input.rationale?.trim() ?? "",
|
|
45
|
+
implementationNotes: input.implementationNotes?.trim() ?? "",
|
|
46
|
+
acceptedAt,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function updateAcceptedDecisionList(decisions, decisionId, input) {
|
|
50
|
+
const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
|
|
51
|
+
const receivedFields = mutableFields.filter((field) => input[field] !== undefined);
|
|
52
|
+
if (receivedFields.length === 0) {
|
|
53
|
+
throw new PlanStoreError("No mutable accepted decision fields were received.", undefined, {
|
|
54
|
+
errorCode: "NO_MUTABLE_FIELDS_RECEIVED",
|
|
55
|
+
entity: "acceptedDecision",
|
|
56
|
+
operation: "update",
|
|
57
|
+
mutableFields,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const index = decisions.findIndex((decision) => decision.id === decisionId);
|
|
61
|
+
if (index < 0) {
|
|
62
|
+
throw new PlanStoreError(`Accepted decision ${decisionId} not found.`, undefined, {
|
|
63
|
+
errorCode: "ACCEPTED_DECISION_NOT_FOUND",
|
|
64
|
+
entity: "acceptedDecision",
|
|
65
|
+
operation: "update",
|
|
66
|
+
decisionId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const current = decisions[index];
|
|
70
|
+
const next = AcceptedDecisionSchema.parse({
|
|
71
|
+
...current,
|
|
72
|
+
...(input.title !== undefined ? { title: input.title.trim() } : {}),
|
|
73
|
+
...(input.decision !== undefined ? { decision: input.decision.trim() } : {}),
|
|
74
|
+
...(input.rationale !== undefined ? { rationale: input.rationale.trim() } : {}),
|
|
75
|
+
...(input.implementationNotes !== undefined ? { implementationNotes: input.implementationNotes.trim() } : {}),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
decisions: decisions.map((decision) => decision.id === decisionId ? next : decision),
|
|
79
|
+
decision: next,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function deleteAcceptedDecisionFromList(decisions, decisionId) {
|
|
83
|
+
const decision = decisions.find((entry) => entry.id === decisionId);
|
|
84
|
+
if (!decision) {
|
|
85
|
+
throw new PlanStoreError(`Accepted decision ${decisionId} not found.`, undefined, {
|
|
86
|
+
errorCode: "ACCEPTED_DECISION_NOT_FOUND",
|
|
87
|
+
entity: "acceptedDecision",
|
|
88
|
+
operation: "delete",
|
|
89
|
+
decisionId,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { decisions: decisions.filter((entry) => entry.id !== decisionId), decision };
|
|
93
|
+
}
|
|
28
94
|
function upsertSessionInfo(entity, sessionId, createdAt) {
|
|
29
95
|
const nextInfo = [
|
|
30
96
|
...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
|
|
@@ -36,6 +102,29 @@ function upsertSessionInfo(entity, sessionId, createdAt) {
|
|
|
36
102
|
|| nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
|
|
37
103
|
return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
|
|
38
104
|
}
|
|
105
|
+
function mergeSessionInfo(current, incoming) {
|
|
106
|
+
const newestBySession = new Map();
|
|
107
|
+
for (const entry of [...(current ?? []), ...(incoming ?? [])]) {
|
|
108
|
+
const prior = newestBySession.get(entry.sessionId);
|
|
109
|
+
if (!prior || entry.createdAt > prior)
|
|
110
|
+
newestBySession.set(entry.sessionId, entry.createdAt);
|
|
111
|
+
}
|
|
112
|
+
return [...newestBySession.entries()]
|
|
113
|
+
.map(([sessionId, createdAt]) => ({ sessionId, createdAt }))
|
|
114
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
115
|
+
.slice(0, MAX_SESSION_INFO_ENTRIES);
|
|
116
|
+
}
|
|
117
|
+
function assertSnapshotNotOlder(entity, entityId, incomingUpdatedAt, currentUpdatedAt) {
|
|
118
|
+
if (incomingUpdatedAt < currentUpdatedAt) {
|
|
119
|
+
throw new PlanStaleWriteError({
|
|
120
|
+
errorCode: "PLAN_STALE_WRITE",
|
|
121
|
+
entity,
|
|
122
|
+
entityId,
|
|
123
|
+
expectedUpdatedAt: incomingUpdatedAt,
|
|
124
|
+
actualUpdatedAt: currentUpdatedAt,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
39
128
|
function resolveStoredFeatureId(features, ref) {
|
|
40
129
|
const raw = ref?.trim();
|
|
41
130
|
if (!raw)
|
|
@@ -67,6 +156,36 @@ export class PlanStoreError extends Error {
|
|
|
67
156
|
this.name = "PlanStoreError";
|
|
68
157
|
}
|
|
69
158
|
}
|
|
159
|
+
/** Optimistic-concurrency failure for a mutation based on an obsolete entity
|
|
160
|
+
* snapshot. Callers must reload, reapply only the intended fields, and retry. */
|
|
161
|
+
export class PlanStaleWriteError extends PlanStoreError {
|
|
162
|
+
details;
|
|
163
|
+
code = "PLAN_STALE_WRITE";
|
|
164
|
+
constructor(details) {
|
|
165
|
+
super(`PLAN_STALE_WRITE: ${details.entity} ${details.entityId} changed after it was read (expected ${details.expectedUpdatedAt || "an unversioned snapshot"}, found ${details.actualUpdatedAt || "an unversioned current value"}). Reload the entity, reapply only the intended fields, and retry.`, undefined, details);
|
|
166
|
+
this.details = details;
|
|
167
|
+
this.name = "PlanStaleWriteError";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function assertPlannerRevision(entity, entityId, expectedUpdatedAt, actualUpdatedAt) {
|
|
171
|
+
if (expectedUpdatedAt !== undefined && expectedUpdatedAt !== actualUpdatedAt) {
|
|
172
|
+
throw new PlanStaleWriteError({
|
|
173
|
+
errorCode: "PLAN_STALE_WRITE",
|
|
174
|
+
entity,
|
|
175
|
+
entityId,
|
|
176
|
+
expectedUpdatedAt,
|
|
177
|
+
actualUpdatedAt,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export class PlanUnsupportedAllocationKindError extends PlanStoreError {
|
|
182
|
+
code = "PLAN_UNSUPPORTED_ALLOCATION_KIND";
|
|
183
|
+
constructor(kind) {
|
|
184
|
+
const details = unsupportedAllocationKindDetails(kind);
|
|
185
|
+
super(`PLAN_UNSUPPORTED_ALLOCATION_KIND: allocation kind "${kind}" is not supported by the loaded Agent Plan runtime. Supported kinds: ${details.supportedKinds.join(", ")}. ${details.action}`, undefined, details);
|
|
186
|
+
this.name = "PlanUnsupportedAllocationKindError";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
70
189
|
// ── Atomic file helpers ────────────────────────────────────────────────
|
|
71
190
|
// Per-path write mutex: serializes concurrent writes to the SAME file so that
|
|
72
191
|
// parallel tool calls (feature_create/phase_create/...) don't truncate JSON.
|
|
@@ -91,9 +210,9 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
|
|
|
91
210
|
/** Allocation registry is deliberately outside the versioned plan. Git worktrees
|
|
92
211
|
* share their common git dir, so reservations are serialized across branches
|
|
93
212
|
* without rewriting project.json or unrelated planner entities. */
|
|
94
|
-
const AllocationKindSchema = z.enum(
|
|
213
|
+
const AllocationKindSchema = z.enum(SUPPORTED_ALLOCATION_KINDS);
|
|
95
214
|
const AllocationRegistrySchema = z.object({
|
|
96
|
-
version: z.literal(
|
|
215
|
+
version: z.literal(ALLOCATION_REGISTRY_VERSION),
|
|
97
216
|
projectId: z.string().min(1),
|
|
98
217
|
allocations: z.array(z.object({
|
|
99
218
|
kind: AllocationKindSchema,
|
|
@@ -126,6 +245,34 @@ async function gitCommonDirFor(planRoot) {
|
|
|
126
245
|
}
|
|
127
246
|
}
|
|
128
247
|
}
|
|
248
|
+
async function readAllocationRegistry(path, projectId) {
|
|
249
|
+
const fallback = { version: ALLOCATION_REGISTRY_VERSION, projectId, allocations: [] };
|
|
250
|
+
const raw = await readFile(path, "utf8")
|
|
251
|
+
.then((content) => JSON.parse(content))
|
|
252
|
+
.catch(() => fallback);
|
|
253
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
254
|
+
const allocations = raw.allocations;
|
|
255
|
+
if (Array.isArray(allocations)) {
|
|
256
|
+
const unsupported = allocations
|
|
257
|
+
.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? String(entry.kind ?? "") : "")
|
|
258
|
+
.filter((kind) => kind && !isSupportedAllocationKind(kind));
|
|
259
|
+
if (unsupported.length > 0)
|
|
260
|
+
throw new PlanUnsupportedAllocationKindError(unsupported[0]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const parsed = AllocationRegistrySchema.safeParse(raw);
|
|
264
|
+
if (!parsed.success) {
|
|
265
|
+
throw new PlanStoreError(`PLAN_RUNTIME_SCHEMA_INCOMPATIBLE: allocation registry ${path} is not compatible with the loaded Agent Plan runtime. Upgrade all Agent Plan packages and reload the harness before retrying the mutation.`, undefined, {
|
|
266
|
+
errorCode: "PLAN_RUNTIME_SCHEMA_INCOMPATIBLE",
|
|
267
|
+
schema: "allocationRegistry",
|
|
268
|
+
expectedVersion: ALLOCATION_REGISTRY_VERSION,
|
|
269
|
+
supportedKinds: SUPPORTED_ALLOCATION_KINDS,
|
|
270
|
+
path,
|
|
271
|
+
validationErrors: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return parsed.data;
|
|
275
|
+
}
|
|
129
276
|
async function writeRegistry(path, registry) {
|
|
130
277
|
await mkdir(dirname(path), { recursive: true });
|
|
131
278
|
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
@@ -193,7 +340,7 @@ export function withFeatureLock(featureId, fn) {
|
|
|
193
340
|
});
|
|
194
341
|
}
|
|
195
342
|
async function atomicWriteText(path, raw, root) {
|
|
196
|
-
|
|
343
|
+
const write = () => withWriteLock(path, async () => {
|
|
197
344
|
writeBusyHook?.(true);
|
|
198
345
|
const localRoot = root ? join(root, ".local") : undefined;
|
|
199
346
|
const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
|
|
@@ -226,6 +373,7 @@ async function atomicWriteText(path, raw, root) {
|
|
|
226
373
|
writeBusyHook?.(false);
|
|
227
374
|
}
|
|
228
375
|
});
|
|
376
|
+
return root ? withPlanRootWriteLock(root, write) : write();
|
|
229
377
|
}
|
|
230
378
|
async function atomicWriteJson(path, data, root) {
|
|
231
379
|
return atomicWriteText(path, JSON.stringify(data, null, 2), root);
|
|
@@ -234,7 +382,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
|
|
|
234
382
|
// NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
|
|
235
383
|
// because those re-acquire withWriteLock(path) — and we already hold it (below).
|
|
236
384
|
// Re-entrant locking is not supported, so calling them would deadlock.
|
|
237
|
-
|
|
385
|
+
const write = () => withWriteLock(path, async () => {
|
|
238
386
|
const current = await readJson(path, schema);
|
|
239
387
|
const updated = updater(current);
|
|
240
388
|
const parsed = schema.parse(updated);
|
|
@@ -270,6 +418,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
|
|
|
270
418
|
}
|
|
271
419
|
return parsed;
|
|
272
420
|
});
|
|
421
|
+
return root ? withPlanRootWriteLock(root, write) : write();
|
|
273
422
|
}
|
|
274
423
|
/** Extract the first meaningful line of a handoff: skip blank lines, strip a
|
|
275
424
|
* leading markdown header (#), trim, and truncate to ~80 chars. */
|
|
@@ -559,22 +708,24 @@ export class PlanStore {
|
|
|
559
708
|
* planners). The caller is responsible for triggering any needed final
|
|
560
709
|
* sync explicitly. */
|
|
561
710
|
async runAsBatch(fn) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
711
|
+
return withPlanRootWriteLock(this.root, async () => {
|
|
712
|
+
const prev = this.batchInProgress;
|
|
713
|
+
this.batchInProgress = true;
|
|
714
|
+
try {
|
|
715
|
+
return await fn();
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
this.batchInProgress = prev;
|
|
719
|
+
}
|
|
720
|
+
});
|
|
570
721
|
}
|
|
571
722
|
/** Public batch wrapper used by the module-level migrateToUuids helper. */
|
|
572
723
|
async runBatchForMigration(fn) {
|
|
573
724
|
return this.runAsBatch(fn);
|
|
574
725
|
}
|
|
575
|
-
/** Public
|
|
576
|
-
*
|
|
577
|
-
*
|
|
726
|
+
/** Public root-scoped write transaction. It serializes the whole sequence
|
|
727
|
+
* across processes and suspends autoSync for nested writes. Use for composite
|
|
728
|
+
* mutations and priority reorders that must not be interleaved. */
|
|
578
729
|
async runBatch(fn) {
|
|
579
730
|
return this.runAsBatch(fn);
|
|
580
731
|
}
|
|
@@ -596,11 +747,13 @@ export class PlanStore {
|
|
|
596
747
|
fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
|
|
597
748
|
toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
|
|
598
749
|
}));
|
|
750
|
+
const activeOwnerSession = normalizedStatus === "in-progress" ? task.activeOwnerSession?.trim() ?? "" : "";
|
|
599
751
|
if (descriptionUpdatedAt !== task.descriptionUpdatedAt
|
|
600
752
|
|| normalizedStatus !== task.status
|
|
601
753
|
|| pauseSnapshot !== task.pauseSnapshot
|
|
754
|
+
|| activeOwnerSession !== task.activeOwnerSession
|
|
602
755
|
|| statusLog.some((entry, index) => entry !== task.statusLog[index])) {
|
|
603
|
-
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
|
|
756
|
+
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog, activeOwnerSession };
|
|
604
757
|
}
|
|
605
758
|
return task;
|
|
606
759
|
});
|
|
@@ -834,10 +987,11 @@ export class PlanStore {
|
|
|
834
987
|
return join(this.featuresDir(), `${featureId}.json`);
|
|
835
988
|
}
|
|
836
989
|
withFeaturesLock(fn) {
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
|
|
990
|
+
// Root-scoped coordination prevents another process from interleaving a
|
|
991
|
+
// feature collection transaction. The sentinel lock retains per-collection
|
|
992
|
+
// serialization within this process; nested file writes are re-entrant at
|
|
993
|
+
// the planner-root layer.
|
|
994
|
+
return withPlanRootWriteLock(this.root, () => withWriteLock(this.featuresDir(), fn));
|
|
841
995
|
}
|
|
842
996
|
/** Idempotent one-time migration: if a legacy features.json exists, split it
|
|
843
997
|
* into features/<id>.json (one per feature) and remove the legacy file.
|
|
@@ -1124,7 +1278,21 @@ export class PlanStore {
|
|
|
1124
1278
|
/** Read-only manifest load. Upgrading legacy `.local` state is explicit
|
|
1125
1279
|
* maintenance (`repair`), never an incidental side effect of opening a plan. */
|
|
1126
1280
|
async loadManifest() {
|
|
1127
|
-
|
|
1281
|
+
let manifest;
|
|
1282
|
+
try {
|
|
1283
|
+
manifest = await readJson(this.manifestPath(), ManifestSchema);
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (error instanceof PlanStoreError && error.details?.validationErrors) {
|
|
1287
|
+
throw new PlanStoreError(`PLAN_RUNTIME_SCHEMA_INCOMPATIBLE: manifest ${this.manifestPath()} is not compatible with the loaded Agent Plan runtime. Expected manifest schemaVersion ${PLAN_SCHEMA_VERSION}. Upgrade all Agent Plan packages and reload the harness before retrying the operation.`, error, {
|
|
1288
|
+
...error.details,
|
|
1289
|
+
errorCode: "PLAN_RUNTIME_SCHEMA_INCOMPATIBLE",
|
|
1290
|
+
schema: "manifest",
|
|
1291
|
+
expectedVersion: PLAN_SCHEMA_VERSION,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1128
1296
|
const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
|
|
1129
1297
|
return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
|
|
1130
1298
|
}
|
|
@@ -1152,6 +1320,9 @@ export class PlanStore {
|
|
|
1152
1320
|
* rewritten; cross-clone coordination requires a shared allocator service.
|
|
1153
1321
|
*/
|
|
1154
1322
|
async allocateEntityIdentity(kind, entityId) {
|
|
1323
|
+
if (!isSupportedAllocationKind(String(kind)))
|
|
1324
|
+
throw new PlanUnsupportedAllocationKindError(String(kind));
|
|
1325
|
+
const supportedKind = kind;
|
|
1155
1326
|
const project = await this.loadProject();
|
|
1156
1327
|
const manifest = await this.loadManifest();
|
|
1157
1328
|
const commonGitDir = await gitCommonDirFor(this.root);
|
|
@@ -1159,31 +1330,29 @@ export class PlanStore {
|
|
|
1159
1330
|
? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
|
|
1160
1331
|
: join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
|
|
1161
1332
|
return withWriteLock(registryPath, async () => {
|
|
1162
|
-
const registry =
|
|
1163
|
-
.then(JSON.parse)
|
|
1164
|
-
.catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
|
|
1333
|
+
const registry = await readAllocationRegistry(registryPath, manifest.projectId);
|
|
1165
1334
|
if (registry.projectId !== manifest.projectId)
|
|
1166
1335
|
throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
|
|
1167
|
-
const prior = registry.allocations.find((entry) => entry.kind ===
|
|
1336
|
+
const prior = registry.allocations.find((entry) => entry.kind === supportedKind && entry.entityId === entityId);
|
|
1168
1337
|
if (prior)
|
|
1169
1338
|
return { number: prior.number, shortId: prior.shortId };
|
|
1170
1339
|
const phases = await this.loadAllPhases();
|
|
1171
1340
|
const features = (await this.loadFeatures()).features;
|
|
1172
1341
|
const ideasDocument = await this.loadIdeas();
|
|
1173
1342
|
const ideas = ideasDocument.ideas;
|
|
1174
|
-
const canonical =
|
|
1343
|
+
const canonical = supportedKind === "feature"
|
|
1175
1344
|
? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
|
|
1176
|
-
:
|
|
1345
|
+
: supportedKind === "phase"
|
|
1177
1346
|
? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
|
|
1178
|
-
:
|
|
1347
|
+
: supportedKind === "task"
|
|
1179
1348
|
? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
|
|
1180
1349
|
: ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
|
|
1181
|
-
const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind ===
|
|
1182
|
-
const counter =
|
|
1350
|
+
const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === supportedKind).map((entry) => entry.number)]);
|
|
1351
|
+
const counter = supportedKind === "feature"
|
|
1183
1352
|
? project.nextFeatureNumber
|
|
1184
|
-
:
|
|
1353
|
+
: supportedKind === "phase"
|
|
1185
1354
|
? project.nextPhaseNumber
|
|
1186
|
-
:
|
|
1355
|
+
: supportedKind === "task"
|
|
1187
1356
|
? project.nextTaskNumber
|
|
1188
1357
|
: ideasDocument.nextIdeaNumber;
|
|
1189
1358
|
let number = Math.max(1, counter);
|
|
@@ -1195,7 +1364,7 @@ export class PlanStore {
|
|
|
1195
1364
|
...ideas.map((idea) => idea.shortId),
|
|
1196
1365
|
...registry.allocations.map((entry) => entry.shortId),
|
|
1197
1366
|
].filter(Boolean));
|
|
1198
|
-
const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${
|
|
1367
|
+
const allocation = { kind: supportedKind, entityId, number, shortId: createShortId(allShortIds, `${supportedKind}:${entityId}`) };
|
|
1199
1368
|
registry.allocations.push(allocation);
|
|
1200
1369
|
await writeRegistry(registryPath, registry);
|
|
1201
1370
|
return { number: allocation.number, shortId: allocation.shortId };
|
|
@@ -1497,6 +1666,14 @@ export class PlanStore {
|
|
|
1497
1666
|
const normalized = this.normalizeStructureSnapshot({ features }, phases);
|
|
1498
1667
|
return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
|
|
1499
1668
|
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Preview child-to-parent description drift without rewriting user-authored
|
|
1671
|
+
* feature or phase prose. Callers decide whether to reconcile each parent.
|
|
1672
|
+
*/
|
|
1673
|
+
async previewDescriptionReconciliation() {
|
|
1674
|
+
const workspace = await this.loadAll();
|
|
1675
|
+
return buildHierarchicalDescriptionFreshness(workspace.features.features, workspace.phases);
|
|
1676
|
+
}
|
|
1500
1677
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
1501
1678
|
* dangling feature.phaseIds references. Idempotent. */
|
|
1502
1679
|
async migratePhaseIds() {
|
|
@@ -1856,11 +2033,17 @@ export class PlanStore {
|
|
|
1856
2033
|
const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
1857
2034
|
return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
|
|
1858
2035
|
}
|
|
1859
|
-
/**
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
|
|
1863
|
-
|
|
2036
|
+
/** Return the archive reason for a canonical terminal phase outcome. Handoff
|
|
2037
|
+
* lifecycle follows the derived phase status, so rejected tasks cannot leave
|
|
2038
|
+
* an operational handoff active or merely hidden from the active list. */
|
|
2039
|
+
terminalHandoffReason(phase) {
|
|
2040
|
+
if (phase.status === "done")
|
|
2041
|
+
return "phase-done";
|
|
2042
|
+
if (phase.status === "rejected")
|
|
2043
|
+
return "phase-rejected";
|
|
2044
|
+
if (phase.status === "canceled")
|
|
2045
|
+
return "phase-canceled";
|
|
2046
|
+
return null;
|
|
1864
2047
|
}
|
|
1865
2048
|
derivePhaseStatus(tasks) {
|
|
1866
2049
|
if (tasks.length === 0)
|
|
@@ -1933,14 +2116,14 @@ export class PlanStore {
|
|
|
1933
2116
|
// (serve.ts, adapters) that invoke it after mutations.
|
|
1934
2117
|
return [];
|
|
1935
2118
|
}
|
|
1936
|
-
/** Auto-clear a phase's handoff
|
|
1937
|
-
*
|
|
1938
|
-
* derived status is "rejected". Returns the composite ref when cleared. */
|
|
2119
|
+
/** Auto-clear a phase's handoff for every canonical terminal derived status.
|
|
2120
|
+
* Returns the composite ref when an active handoff was archived. */
|
|
1939
2121
|
async syncTaskStatusRollup(phaseId) {
|
|
1940
2122
|
const phase = await this.loadPhase(phaseId);
|
|
1941
2123
|
let cleared = null;
|
|
1942
|
-
|
|
1943
|
-
|
|
2124
|
+
const terminalReason = this.terminalHandoffReason(phase);
|
|
2125
|
+
if (terminalReason && phase.handoff !== "") {
|
|
2126
|
+
await this.clearPhaseHandoff(phaseId, terminalReason);
|
|
1944
2127
|
const features = await this.loadFeatures();
|
|
1945
2128
|
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1946
2129
|
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
@@ -2002,9 +2185,10 @@ export class PlanStore {
|
|
|
2002
2185
|
}
|
|
2003
2186
|
}
|
|
2004
2187
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
2005
|
-
async updateProject(updater) {
|
|
2188
|
+
async updateProject(updater, options = {}) {
|
|
2006
2189
|
const timestamp = nowISO();
|
|
2007
2190
|
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
|
|
2191
|
+
assertPlannerRevision("project", "project-guidelines", options.expectedGuidelinesUpdatedAt, current.projectGuidelines.updatedAt);
|
|
2008
2192
|
const previousGuidelines = current.projectGuidelines.content;
|
|
2009
2193
|
const candidate = updater(current);
|
|
2010
2194
|
return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
|
|
@@ -2087,11 +2271,47 @@ export class PlanStore {
|
|
|
2087
2271
|
await this.maybeAutoSync();
|
|
2088
2272
|
return updated;
|
|
2089
2273
|
}
|
|
2274
|
+
/** Field-scoped feature mutation against the lock-reloaded canonical document. */
|
|
2275
|
+
async updateFeature(featureId, updater, options = {}) {
|
|
2276
|
+
let persisted;
|
|
2277
|
+
await this.updateFeatures((document) => {
|
|
2278
|
+
const index = document.features.findIndex((feature) => feature.id === featureId);
|
|
2279
|
+
if (index < 0)
|
|
2280
|
+
throw new PlanStoreError(`Feature ${featureId} not found.`);
|
|
2281
|
+
const current = document.features[index];
|
|
2282
|
+
assertPlannerRevision("feature", featureId, options.expectedUpdatedAt, current.updatedAt);
|
|
2283
|
+
const next = updater(structuredClone(current));
|
|
2284
|
+
if (next.id !== current.id)
|
|
2285
|
+
throw new PlanStoreError("Feature identity cannot be changed by updateFeature.");
|
|
2286
|
+
document.features[index] = next;
|
|
2287
|
+
persisted = next;
|
|
2288
|
+
return document;
|
|
2289
|
+
});
|
|
2290
|
+
return persisted;
|
|
2291
|
+
}
|
|
2090
2292
|
async updateRequirements(updater) {
|
|
2091
2293
|
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
|
|
2092
2294
|
await this.maybeAutoSync();
|
|
2093
2295
|
return updated;
|
|
2094
2296
|
}
|
|
2297
|
+
/** Field-scoped requirement mutation against the lock-reloaded canonical document. */
|
|
2298
|
+
async updateRequirement(requirementId, updater, options = {}) {
|
|
2299
|
+
let persisted;
|
|
2300
|
+
await this.updateRequirements((document) => {
|
|
2301
|
+
const index = document.requirements.findIndex((requirement) => requirement.id === requirementId);
|
|
2302
|
+
if (index < 0)
|
|
2303
|
+
throw new PlanStoreError(`Requirement ${requirementId} not found.`);
|
|
2304
|
+
const current = document.requirements[index];
|
|
2305
|
+
assertPlannerRevision("requirement", requirementId, options.expectedUpdatedAt, current.updatedAt);
|
|
2306
|
+
const next = RequirementSchema.parse(updater(structuredClone(current)));
|
|
2307
|
+
if (next.id !== current.id)
|
|
2308
|
+
throw new PlanStoreError("Requirement identity cannot be changed by updateRequirement.");
|
|
2309
|
+
document.requirements[index] = next;
|
|
2310
|
+
persisted = next;
|
|
2311
|
+
return document;
|
|
2312
|
+
});
|
|
2313
|
+
return persisted;
|
|
2314
|
+
}
|
|
2095
2315
|
async ensureIdeasFileForWrite() {
|
|
2096
2316
|
try {
|
|
2097
2317
|
await access(this.ideasPath());
|
|
@@ -2289,12 +2509,18 @@ export class PlanStore {
|
|
|
2289
2509
|
}
|
|
2290
2510
|
catch (error) {
|
|
2291
2511
|
const rollbackErrors = [];
|
|
2292
|
-
if (featureChanged && nextFeature)
|
|
2293
|
-
await this.
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
if (
|
|
2297
|
-
await this.
|
|
2512
|
+
if (featureChanged && nextFeature) {
|
|
2513
|
+
await atomicWriteJson(this.featurePath(nextFeature.id), originalFeatures.features[featureIndex], this.root)
|
|
2514
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2515
|
+
}
|
|
2516
|
+
if (phaseChanged) {
|
|
2517
|
+
await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
|
|
2518
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2519
|
+
}
|
|
2520
|
+
if (requirementsChanged) {
|
|
2521
|
+
await atomicWriteJson(this.requirementsPath(), originalRequirements, this.root)
|
|
2522
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2523
|
+
}
|
|
2298
2524
|
if (rollbackErrors.length > 0)
|
|
2299
2525
|
throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
|
|
2300
2526
|
throw error;
|
|
@@ -2322,21 +2548,40 @@ export class PlanStore {
|
|
|
2322
2548
|
async saveProject(project) {
|
|
2323
2549
|
// Runtime workDeviations live in .local/deviations.json (T299); never
|
|
2324
2550
|
// persist them here so shared project.json stays stable across worktrees.
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2551
|
+
await withPlanRootWriteLock(this.root, async () => {
|
|
2552
|
+
const previous = await readJson(this.projectPath(), ProjectSchema).catch(() => null);
|
|
2553
|
+
const withMergedGuidelineReads = previous
|
|
2554
|
+
? {
|
|
2555
|
+
...project,
|
|
2556
|
+
projectGuidelines: {
|
|
2557
|
+
...project.projectGuidelines,
|
|
2558
|
+
sessionInfo: mergeSessionInfo(previous.projectGuidelines.sessionInfo, project.projectGuidelines.sessionInfo),
|
|
2559
|
+
},
|
|
2560
|
+
}
|
|
2561
|
+
: project;
|
|
2562
|
+
const stamped = this.stampProjectGuidelinesUpdatedAt(withMergedGuidelineReads, previous?.projectGuidelines.content, nowISO());
|
|
2563
|
+
const parsed = ProjectSchema.parse({ ...stamped, workDeviations: [] });
|
|
2564
|
+
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
2565
|
+
});
|
|
2329
2566
|
await this.touchTimestamp();
|
|
2330
2567
|
await this.maybeAutoSync();
|
|
2331
2568
|
}
|
|
2332
2569
|
async saveFeatures(features) {
|
|
2333
2570
|
await this.withFeaturesLock(async () => {
|
|
2334
2571
|
await this.migrateLegacy();
|
|
2335
|
-
const
|
|
2336
|
-
const
|
|
2572
|
+
const currentFeatures = await this.loadRawFeatures();
|
|
2573
|
+
const currentById = new Map(currentFeatures.map((feature) => [feature.id, feature]));
|
|
2337
2574
|
const timestamp = nowISO();
|
|
2338
2575
|
await this.saveFeaturesRaw({
|
|
2339
|
-
features: features.features.map((feature) =>
|
|
2576
|
+
features: features.features.map((feature) => {
|
|
2577
|
+
const current = currentById.get(feature.id);
|
|
2578
|
+
if (current)
|
|
2579
|
+
assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, current.updatedAt);
|
|
2580
|
+
const merged = current
|
|
2581
|
+
? { ...feature, sessionInfo: mergeSessionInfo(current.sessionInfo, feature.sessionInfo) }
|
|
2582
|
+
: feature;
|
|
2583
|
+
return this.stampDescriptionUpdatedAt(merged, current?.description, current?.descriptionRef, timestamp);
|
|
2584
|
+
}),
|
|
2340
2585
|
});
|
|
2341
2586
|
});
|
|
2342
2587
|
await this.touchTimestamp();
|
|
@@ -2371,66 +2616,105 @@ export class PlanStore {
|
|
|
2371
2616
|
await this.withFeaturesLock(async () => {
|
|
2372
2617
|
await this.migrateLegacy();
|
|
2373
2618
|
const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
|
|
2619
|
+
if (previous)
|
|
2620
|
+
assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, previous.updatedAt);
|
|
2374
2621
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
2375
|
-
const
|
|
2622
|
+
const merged = previous
|
|
2623
|
+
? { ...feature, sessionInfo: mergeSessionInfo(previous.sessionInfo, feature.sessionInfo) }
|
|
2624
|
+
: feature;
|
|
2625
|
+
const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(merged, previous?.description, previous?.descriptionRef, nowISO()));
|
|
2376
2626
|
await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
|
|
2377
2627
|
});
|
|
2378
2628
|
await this.touchTimestamp();
|
|
2379
2629
|
await this.maybeAutoSync();
|
|
2380
2630
|
}
|
|
2381
2631
|
async saveRequirements(reqs) {
|
|
2382
|
-
|
|
2383
|
-
|
|
2632
|
+
await withPlanRootWriteLock(this.root, async () => {
|
|
2633
|
+
const current = await readJson(this.requirementsPath(), RequirementsDocumentSchema).catch(() => null);
|
|
2634
|
+
const currentById = new Map((current?.requirements ?? []).map((requirement) => [requirement.id, requirement]));
|
|
2635
|
+
const parsed = RequirementsDocumentSchema.parse({
|
|
2636
|
+
requirements: reqs.requirements.map((requirement) => {
|
|
2637
|
+
const previous = currentById.get(requirement.id);
|
|
2638
|
+
if (previous)
|
|
2639
|
+
assertSnapshotNotOlder("requirement", requirement.id, requirement.updatedAt, previous.updatedAt);
|
|
2640
|
+
return previous
|
|
2641
|
+
? { ...requirement, sessionInfo: mergeSessionInfo(previous.sessionInfo, requirement.sessionInfo) }
|
|
2642
|
+
: requirement;
|
|
2643
|
+
}),
|
|
2644
|
+
});
|
|
2645
|
+
await atomicWriteJson(this.requirementsPath(), parsed, this.root);
|
|
2646
|
+
});
|
|
2384
2647
|
await this.touchTimestamp();
|
|
2385
2648
|
}
|
|
2386
2649
|
async saveIdeas(ideas) {
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2650
|
+
await withPlanRootWriteLock(this.root, async () => {
|
|
2651
|
+
const parsed = IdeasDocumentSchema.parse(ideas);
|
|
2652
|
+
const maxNumber = parsed.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
|
|
2653
|
+
await atomicWriteJson(this.ideasPath(), {
|
|
2654
|
+
...parsed,
|
|
2655
|
+
nextIdeaNumber: Math.max(parsed.nextIdeaNumber, maxNumber + 1),
|
|
2656
|
+
ideas: [...parsed.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
2657
|
+
}, this.root);
|
|
2658
|
+
});
|
|
2394
2659
|
await this.touchTimestamp();
|
|
2395
2660
|
}
|
|
2396
|
-
async savePhase(phase) {
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2661
|
+
async savePhase(phase, options = {}) {
|
|
2662
|
+
await withPlanRootWriteLock(this.root, async () => {
|
|
2663
|
+
const previous = await this.loadPhase(phase.id).catch(() => null);
|
|
2664
|
+
if (previous) {
|
|
2665
|
+
assertPlannerRevision("phase", phase.id, options.expectedUpdatedAt, previous.updatedAt);
|
|
2666
|
+
if (options.expectedUpdatedAt === undefined) {
|
|
2667
|
+
assertSnapshotNotOlder("phase", phase.id, phase.updatedAt, previous.updatedAt);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
const timestamp = nowISO();
|
|
2671
|
+
const features = await this.loadRawFeatures();
|
|
2672
|
+
const resolvedFeatureId = resolveStoredFeatureId(features, phase.featureId);
|
|
2673
|
+
// Referential integrity: if a featureId is present but cannot be resolved
|
|
2674
|
+
// to a known feature, REJECT — never persist an orphan featureId.
|
|
2675
|
+
// NOTE: a missing/empty featureId is intentionally ALLOWED here so that
|
|
2676
|
+
// legacy migrations, repair, and feature-delete (unlink) can persist phases
|
|
2677
|
+
// without a feature yet. The hard "featureId required" gate lives at the
|
|
2678
|
+
// adapter boundary (Pi phase_create/task_create and MCP planner-phase-add/
|
|
2679
|
+
// planner-task-add), which is where user-facing creation happens.
|
|
2680
|
+
if (phase.featureId && phase.featureId.trim() && !resolvedFeatureId) {
|
|
2681
|
+
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.`);
|
|
2682
|
+
}
|
|
2683
|
+
const normalizedInput = resolvedFeatureId && resolvedFeatureId !== phase.featureId
|
|
2684
|
+
? { ...phase, featureId: resolvedFeatureId }
|
|
2685
|
+
: phase;
|
|
2686
|
+
const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
|
|
2687
|
+
const previousTaskDescriptionRefs = new Map((previous?.tasks ?? []).map((task) => [task.id, task.descriptionRef]));
|
|
2688
|
+
const mergedPhase = previous
|
|
2689
|
+
? { ...normalizedInput, sessionInfo: mergeSessionInfo(previous.sessionInfo, normalizedInput.sessionInfo) }
|
|
2690
|
+
: normalizedInput;
|
|
2691
|
+
const previousTasksById = new Map((previous?.tasks ?? []).map((task) => [task.id, task]));
|
|
2692
|
+
const timestamped = this.stampDescriptionUpdatedAt(mergedPhase, previous?.description, previous?.descriptionRef, timestamp);
|
|
2693
|
+
timestamped.tasks = timestamped.tasks.map((task) => {
|
|
2694
|
+
const previousTask = previousTasksById.get(task.id);
|
|
2695
|
+
const mergedTask = previousTask
|
|
2696
|
+
? { ...task, sessionInfo: mergeSessionInfo(previousTask.sessionInfo, task.sessionInfo) }
|
|
2697
|
+
: task;
|
|
2698
|
+
return this.stampDescriptionUpdatedAt(mergedTask, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp);
|
|
2699
|
+
});
|
|
2700
|
+
const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
|
|
2701
|
+
await mkdir(this.phasesDir(), { recursive: true });
|
|
2702
|
+
await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
|
|
2703
|
+
});
|
|
2421
2704
|
await this.touchTimestamp();
|
|
2422
2705
|
await this.maybeAutoSync();
|
|
2423
2706
|
}
|
|
2424
2707
|
/** Atomic read-modify-write on a single phase file. Serializes concurrent
|
|
2425
2708
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
2426
2709
|
* don't lose tasks (last-write-wins race condition). */
|
|
2427
|
-
async updatePhase(phaseId, updater) {
|
|
2710
|
+
async updatePhase(phaseId, updater, options = {}) {
|
|
2428
2711
|
const features = await this.loadRawFeatures();
|
|
2429
2712
|
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
2430
2713
|
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
2431
2714
|
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
2432
2715
|
// not persisted); the return value is re-derived for the caller.
|
|
2433
2716
|
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
2717
|
+
assertPlannerRevision("phase", phaseId, options.expectedUpdatedAt, rawPhase.updatedAt);
|
|
2434
2718
|
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
2435
2719
|
// The updater may mutate `current`, therefore snapshot descriptions first.
|
|
2436
2720
|
const previousDescription = current.description;
|
|
@@ -2455,6 +2739,114 @@ export class PlanStore {
|
|
|
2455
2739
|
await this.maybeAutoSync();
|
|
2456
2740
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
2457
2741
|
}
|
|
2742
|
+
/** Field-scoped task mutation against the lock-reloaded canonical phase. */
|
|
2743
|
+
async updateTask(phaseId, taskId, updater, options = {}) {
|
|
2744
|
+
let persisted;
|
|
2745
|
+
const phase = await this.updatePhase(phaseId, (currentPhase) => {
|
|
2746
|
+
const index = currentPhase.tasks.findIndex((task) => task.id === taskId);
|
|
2747
|
+
if (index < 0)
|
|
2748
|
+
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
2749
|
+
const current = currentPhase.tasks[index];
|
|
2750
|
+
assertPlannerRevision("task", taskId, options.expectedUpdatedAt, current.updatedAt);
|
|
2751
|
+
const next = TaskSchema.parse(updater(structuredClone(current)));
|
|
2752
|
+
if (next.id !== current.id || next.phaseId !== current.phaseId) {
|
|
2753
|
+
throw new PlanStoreError("Task identity and parent phase cannot be changed by updateTask.");
|
|
2754
|
+
}
|
|
2755
|
+
currentPhase.tasks[index] = next;
|
|
2756
|
+
currentPhase.updatedAt = next.updatedAt;
|
|
2757
|
+
persisted = next;
|
|
2758
|
+
return currentPhase;
|
|
2759
|
+
});
|
|
2760
|
+
const persistedPhase = await this.loadPhase(phaseId);
|
|
2761
|
+
const persistedTask = persistedPhase.tasks.find((task) => task.id === taskId);
|
|
2762
|
+
if (!persistedTask || JSON.stringify(persistedTask.checklist) !== JSON.stringify(persisted.checklist)) {
|
|
2763
|
+
throw new PlanStoreError(`Task checklist persistence verification failed for ${taskId}.`, undefined, { errorCode: "TASK_CHECKLIST_PERSISTENCE_FAILED", phaseId, taskId });
|
|
2764
|
+
}
|
|
2765
|
+
return { phase: persistedPhase, task: persistedTask };
|
|
2766
|
+
}
|
|
2767
|
+
async createAcceptedDecision(owner, input, acceptedAt = nowISO()) {
|
|
2768
|
+
const decision = normalizeAcceptedDecisionCreate(input, acceptedAt);
|
|
2769
|
+
if (owner.kind === "project") {
|
|
2770
|
+
await this.updateProject((project) => ({ ...project, acceptedDecisions: [...project.acceptedDecisions, decision] }));
|
|
2771
|
+
return decision;
|
|
2772
|
+
}
|
|
2773
|
+
if (owner.kind === "feature") {
|
|
2774
|
+
await this.updateFeature(owner.featureId, (feature) => ({ ...feature, acceptedDecisions: [...feature.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2775
|
+
return decision;
|
|
2776
|
+
}
|
|
2777
|
+
if (owner.kind === "phase") {
|
|
2778
|
+
await this.updatePhase(owner.phaseId, (phase) => ({ ...phase, acceptedDecisions: [...phase.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2779
|
+
return decision;
|
|
2780
|
+
}
|
|
2781
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => ({ ...task, acceptedDecisions: [...task.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2782
|
+
return decision;
|
|
2783
|
+
}
|
|
2784
|
+
async updateAcceptedDecision(owner, decisionId, input) {
|
|
2785
|
+
let updated;
|
|
2786
|
+
if (owner.kind === "project") {
|
|
2787
|
+
await this.updateProject((project) => {
|
|
2788
|
+
const result = updateAcceptedDecisionList(project.acceptedDecisions, decisionId, input);
|
|
2789
|
+
updated = result.decision;
|
|
2790
|
+
return { ...project, acceptedDecisions: result.decisions };
|
|
2791
|
+
});
|
|
2792
|
+
return updated;
|
|
2793
|
+
}
|
|
2794
|
+
if (owner.kind === "feature") {
|
|
2795
|
+
await this.updateFeature(owner.featureId, (feature) => {
|
|
2796
|
+
const result = updateAcceptedDecisionList(feature.acceptedDecisions, decisionId, input);
|
|
2797
|
+
updated = result.decision;
|
|
2798
|
+
return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2799
|
+
});
|
|
2800
|
+
return updated;
|
|
2801
|
+
}
|
|
2802
|
+
if (owner.kind === "phase") {
|
|
2803
|
+
await this.updatePhase(owner.phaseId, (phase) => {
|
|
2804
|
+
const result = updateAcceptedDecisionList(phase.acceptedDecisions, decisionId, input);
|
|
2805
|
+
updated = result.decision;
|
|
2806
|
+
return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2807
|
+
});
|
|
2808
|
+
return updated;
|
|
2809
|
+
}
|
|
2810
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => {
|
|
2811
|
+
const result = updateAcceptedDecisionList(task.acceptedDecisions, decisionId, input);
|
|
2812
|
+
updated = result.decision;
|
|
2813
|
+
return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2814
|
+
});
|
|
2815
|
+
return updated;
|
|
2816
|
+
}
|
|
2817
|
+
async deleteAcceptedDecision(owner, decisionId) {
|
|
2818
|
+
let deleted;
|
|
2819
|
+
if (owner.kind === "project") {
|
|
2820
|
+
await this.updateProject((project) => {
|
|
2821
|
+
const result = deleteAcceptedDecisionFromList(project.acceptedDecisions, decisionId);
|
|
2822
|
+
deleted = result.decision;
|
|
2823
|
+
return { ...project, acceptedDecisions: result.decisions };
|
|
2824
|
+
});
|
|
2825
|
+
return deleted;
|
|
2826
|
+
}
|
|
2827
|
+
if (owner.kind === "feature") {
|
|
2828
|
+
await this.updateFeature(owner.featureId, (feature) => {
|
|
2829
|
+
const result = deleteAcceptedDecisionFromList(feature.acceptedDecisions, decisionId);
|
|
2830
|
+
deleted = result.decision;
|
|
2831
|
+
return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2832
|
+
});
|
|
2833
|
+
return deleted;
|
|
2834
|
+
}
|
|
2835
|
+
if (owner.kind === "phase") {
|
|
2836
|
+
await this.updatePhase(owner.phaseId, (phase) => {
|
|
2837
|
+
const result = deleteAcceptedDecisionFromList(phase.acceptedDecisions, decisionId);
|
|
2838
|
+
deleted = result.decision;
|
|
2839
|
+
return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2840
|
+
});
|
|
2841
|
+
return deleted;
|
|
2842
|
+
}
|
|
2843
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => {
|
|
2844
|
+
const result = deleteAcceptedDecisionFromList(task.acceptedDecisions, decisionId);
|
|
2845
|
+
deleted = result.decision;
|
|
2846
|
+
return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2847
|
+
});
|
|
2848
|
+
return deleted;
|
|
2849
|
+
}
|
|
2458
2850
|
/** Save a durable resume checkpoint without introducing a separate canonical task status. */
|
|
2459
2851
|
async pauseTask(phaseId, taskId, input) {
|
|
2460
2852
|
const snapshot = TaskPauseSnapshotSchema.parse(input);
|
|
@@ -2477,6 +2869,7 @@ export class PlanStore {
|
|
|
2477
2869
|
status: "planned",
|
|
2478
2870
|
pauseSnapshot: snapshot,
|
|
2479
2871
|
pauseHistory: [...task.pauseHistory, snapshot],
|
|
2872
|
+
activeOwnerSession: "",
|
|
2480
2873
|
statusLog: [...task.statusLog, {
|
|
2481
2874
|
id: createStatusLogEntryId(),
|
|
2482
2875
|
date: snapshot.pausedAt,
|
|
@@ -2493,7 +2886,7 @@ export class PlanStore {
|
|
|
2493
2886
|
return paused;
|
|
2494
2887
|
}
|
|
2495
2888
|
/** Resume a checkpointed task without resetting its original startedAt. */
|
|
2496
|
-
async resumeTask(phaseId, taskId, timestamp = nowISO()) {
|
|
2889
|
+
async resumeTask(phaseId, taskId, timestamp = nowISO(), ownerSessionId = "") {
|
|
2497
2890
|
let resumed;
|
|
2498
2891
|
await this.updatePhase(phaseId, (phase) => {
|
|
2499
2892
|
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
@@ -2510,6 +2903,7 @@ export class PlanStore {
|
|
|
2510
2903
|
...task,
|
|
2511
2904
|
status: "in-progress",
|
|
2512
2905
|
pauseSnapshot: null,
|
|
2906
|
+
activeOwnerSession: ownerSessionId.trim() || task.activeOwnerSession,
|
|
2513
2907
|
startedAt: task.startedAt || timestamp,
|
|
2514
2908
|
statusLog: [...task.statusLog, {
|
|
2515
2909
|
id: createStatusLogEntryId(),
|
|
@@ -2526,6 +2920,123 @@ export class PlanStore {
|
|
|
2526
2920
|
});
|
|
2527
2921
|
return resumed;
|
|
2528
2922
|
}
|
|
2923
|
+
/** Reopen a completed task atomically without changing any other task.
|
|
2924
|
+
* Completion evidence remains in the description and status log; completedAt is
|
|
2925
|
+
* cleared because the task is once again active work. */
|
|
2926
|
+
async reopenTask(phaseId, taskId, options) {
|
|
2927
|
+
if (!options.confirmed) {
|
|
2928
|
+
throw new PlanStoreError("Task reopening requires explicit confirmation.", undefined, { errorCode: "TASK_REOPEN_CONFIRMATION_REQUIRED", taskId });
|
|
2929
|
+
}
|
|
2930
|
+
const timestamp = options.timestamp ?? nowISO();
|
|
2931
|
+
let reopened;
|
|
2932
|
+
await this.updatePhase(phaseId, (phase) => {
|
|
2933
|
+
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
2934
|
+
if (!task)
|
|
2935
|
+
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`, undefined, { errorCode: "TASK_NOT_FOUND", taskId });
|
|
2936
|
+
if (task.status !== "done")
|
|
2937
|
+
throw new PlanStoreError(`Task ${taskId} is not completed.`, undefined, { errorCode: "TASK_REOPEN_NOT_DONE", taskId });
|
|
2938
|
+
reopened = {
|
|
2939
|
+
...task,
|
|
2940
|
+
status: "in-progress",
|
|
2941
|
+
completedAt: "",
|
|
2942
|
+
pauseSnapshot: null,
|
|
2943
|
+
activeOwnerSession: options.ownerSessionId?.trim() ?? "",
|
|
2944
|
+
statusLog: [...task.statusLog, {
|
|
2945
|
+
id: createStatusLogEntryId(),
|
|
2946
|
+
date: timestamp,
|
|
2947
|
+
fromStatus: "done",
|
|
2948
|
+
toStatus: "in-progress",
|
|
2949
|
+
title: "done → in-progress (reopened)",
|
|
2950
|
+
description: "Reopened through the confirmed lifecycle operation; prior completion evidence is retained.",
|
|
2951
|
+
}],
|
|
2952
|
+
updatedAt: timestamp,
|
|
2953
|
+
};
|
|
2954
|
+
phase.tasks = phase.tasks.map((candidate) => candidate.id === taskId ? reopened : candidate);
|
|
2955
|
+
return phase;
|
|
2956
|
+
});
|
|
2957
|
+
return reopened;
|
|
2958
|
+
}
|
|
2959
|
+
/** Add a validated dependency edge from one task to another. */
|
|
2960
|
+
async addTaskDependency(phaseId, taskId, dependencyId, timestamp = nowISO()) {
|
|
2961
|
+
const phases = await this.loadAllPhases();
|
|
2962
|
+
const tasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
2963
|
+
const source = tasks.find((entry) => entry.phase.id === phaseId && entry.task.id === taskId);
|
|
2964
|
+
const dependency = tasks.find((entry) => entry.task.id === dependencyId);
|
|
2965
|
+
if (!source)
|
|
2966
|
+
throw new PlanStoreError("Task not found.", undefined, { errorCode: "TASK_NOT_FOUND" });
|
|
2967
|
+
if (!dependency)
|
|
2968
|
+
throw new PlanStoreError("Dependency task not found.", undefined, { errorCode: "DEPENDENCY_TASK_NOT_FOUND" });
|
|
2969
|
+
if (taskId === dependencyId)
|
|
2970
|
+
throw new PlanStoreError("A task cannot depend on itself.", undefined, { errorCode: "DEPENDENCY_SELF" });
|
|
2971
|
+
if (source.task.dependsOn.includes(dependencyId))
|
|
2972
|
+
return source.task;
|
|
2973
|
+
const graph = new Map(tasks.map(({ task }) => [task.id, [...task.dependsOn]]));
|
|
2974
|
+
graph.set(taskId, [...(graph.get(taskId) ?? []), dependencyId]);
|
|
2975
|
+
const visit = (id, path = new Set()) => { if (path.has(id))
|
|
2976
|
+
return true; const next = new Set(path); next.add(id); return (graph.get(id) ?? []).some((child) => visit(child, next)); };
|
|
2977
|
+
if (visit(taskId))
|
|
2978
|
+
throw new PlanStoreError("Dependency would create a cycle.", undefined, { errorCode: "DEPENDENCY_CYCLE" });
|
|
2979
|
+
return (await this.updateTask(phaseId, taskId, (task) => ({ ...task, dependsOn: [...task.dependsOn, dependencyId], updatedAt: timestamp }))).task;
|
|
2980
|
+
}
|
|
2981
|
+
/** Remove a dependency edge without affecting either task. */
|
|
2982
|
+
async deleteTaskDependency(phaseId, taskId, dependencyId) {
|
|
2983
|
+
return (await this.updateTask(phaseId, taskId, (task) => {
|
|
2984
|
+
if (!task.dependsOn.includes(dependencyId))
|
|
2985
|
+
throw new PlanStoreError("Dependency not found.", undefined, { errorCode: "DEPENDENCY_NOT_FOUND" });
|
|
2986
|
+
return { ...task, dependsOn: task.dependsOn.filter((id) => id !== dependencyId), updatedAt: nowISO() };
|
|
2987
|
+
})).task;
|
|
2988
|
+
}
|
|
2989
|
+
/** Create a planner-owned subtask with a stable ID under a task. */
|
|
2990
|
+
async createSubtask(phaseId, taskId, input, timestamp = nowISO()) {
|
|
2991
|
+
const title = input.title.trim();
|
|
2992
|
+
if (!title)
|
|
2993
|
+
throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
|
|
2994
|
+
let created;
|
|
2995
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
2996
|
+
created = { id: randomUUID(), title, status: input.status ?? "planned", description: input.description?.trim() ?? "", createdAt: timestamp, updatedAt: timestamp };
|
|
2997
|
+
task.subtasks = [...(task.subtasks ?? []), created];
|
|
2998
|
+
return task;
|
|
2999
|
+
});
|
|
3000
|
+
return created;
|
|
3001
|
+
}
|
|
3002
|
+
/** Update a subtask by its parent task and planner-owned ID. */
|
|
3003
|
+
async updateSubtask(phaseId, taskId, subtaskId, input, timestamp = nowISO()) {
|
|
3004
|
+
let updated;
|
|
3005
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3006
|
+
const index = (task.subtasks ?? []).findIndex((subtask) => subtask.id === subtaskId);
|
|
3007
|
+
if (index < 0)
|
|
3008
|
+
throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
|
|
3009
|
+
const current = task.subtasks[index];
|
|
3010
|
+
if (input.title !== undefined && !input.title.trim())
|
|
3011
|
+
throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
|
|
3012
|
+
updated = { ...current, ...(input.title !== undefined ? { title: input.title.trim() } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}), ...(input.status !== undefined ? { status: input.status } : {}), updatedAt: timestamp };
|
|
3013
|
+
task.subtasks = task.subtasks.map((candidate, candidateIndex) => candidateIndex === index ? updated : candidate);
|
|
3014
|
+
return task;
|
|
3015
|
+
});
|
|
3016
|
+
return updated;
|
|
3017
|
+
}
|
|
3018
|
+
/** Delete a subtask by its planner-owned ID. */
|
|
3019
|
+
async deleteSubtask(phaseId, taskId, subtaskId) {
|
|
3020
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3021
|
+
if (!(task.subtasks ?? []).some((subtask) => subtask.id === subtaskId))
|
|
3022
|
+
throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
|
|
3023
|
+
task.subtasks = task.subtasks.filter((subtask) => subtask.id !== subtaskId);
|
|
3024
|
+
return task;
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
/** Reorder subtasks without changing their stable identities. */
|
|
3028
|
+
async reorderSubtasks(phaseId, taskId, orderedIds) {
|
|
3029
|
+
let ordered = [];
|
|
3030
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3031
|
+
const current = task.subtasks ?? [];
|
|
3032
|
+
if (orderedIds.length !== current.length || new Set(orderedIds).size !== current.length || orderedIds.some((id) => !current.some((subtask) => subtask.id === id)))
|
|
3033
|
+
throw new PlanStoreError("Subtask order must contain every existing subtask exactly once.", undefined, { errorCode: "SUBTASK_ORDER_INVALID" });
|
|
3034
|
+
ordered = orderedIds.map((id) => current.find((subtask) => subtask.id === id));
|
|
3035
|
+
task.subtasks = ordered;
|
|
3036
|
+
return task;
|
|
3037
|
+
});
|
|
3038
|
+
return ordered;
|
|
3039
|
+
}
|
|
2529
3040
|
// ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
|
|
2530
3041
|
/** Get the handoff text for a phase ("" if none). Throws if phase missing. */
|
|
2531
3042
|
async getPhaseHandoff(phaseId) {
|
|
@@ -2533,7 +3044,8 @@ export class PlanStore {
|
|
|
2533
3044
|
}
|
|
2534
3045
|
async validateHandoffSupportingDocuments(documents) {
|
|
2535
3046
|
const docsRoot = resolve(this.root, "docs");
|
|
2536
|
-
const
|
|
3047
|
+
const metadata = [];
|
|
3048
|
+
const contents = [];
|
|
2537
3049
|
const seen = new Set();
|
|
2538
3050
|
for (const document of documents) {
|
|
2539
3051
|
const normalizedPath = document.path.trim().replace(/\\/g, "/");
|
|
@@ -2548,18 +3060,23 @@ export class PlanStore {
|
|
|
2548
3060
|
if (!target.startsWith(`${docsRoot}${sep}`)) {
|
|
2549
3061
|
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
|
|
2550
3062
|
}
|
|
3063
|
+
const fileStat = await lstat(target).catch(() => null);
|
|
3064
|
+
if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
|
|
3065
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
|
|
3066
|
+
}
|
|
2551
3067
|
const content = await readFile(target, "utf8").catch(() => null);
|
|
2552
3068
|
if (content === null || !content.trim()) {
|
|
2553
3069
|
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
|
|
2554
3070
|
}
|
|
2555
|
-
|
|
3071
|
+
metadata.push({
|
|
2556
3072
|
path: normalizedPath,
|
|
2557
3073
|
description: document.description.trim(),
|
|
2558
3074
|
contentHash: handoffContentHash(content),
|
|
2559
3075
|
contentLength: content.length,
|
|
2560
3076
|
});
|
|
3077
|
+
contents.push(content);
|
|
2561
3078
|
}
|
|
2562
|
-
return
|
|
3079
|
+
return { metadata, contents };
|
|
2563
3080
|
}
|
|
2564
3081
|
/** Audit one exact phase before preparing a handoff refresh. */
|
|
2565
3082
|
async preparePhaseHandoff(phaseId) {
|
|
@@ -2578,6 +3095,9 @@ export class PlanStore {
|
|
|
2578
3095
|
async refreshPhaseHandoff(phaseId, input) {
|
|
2579
3096
|
return this.runAsBatch(async () => {
|
|
2580
3097
|
const originalPhase = await this.loadPhase(phaseId);
|
|
3098
|
+
if (this.terminalHandoffReason(originalPhase)) {
|
|
3099
|
+
throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
|
|
3100
|
+
}
|
|
2581
3101
|
if (!originalPhase.featureId)
|
|
2582
3102
|
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
2583
3103
|
const originalFeatures = await this.loadFeatures();
|
|
@@ -2585,8 +3105,35 @@ export class PlanStore {
|
|
|
2585
3105
|
if (featureIndex < 0)
|
|
2586
3106
|
throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
|
|
2587
3107
|
const timestamp = nowISO();
|
|
2588
|
-
const
|
|
2589
|
-
const
|
|
3108
|
+
const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
|
|
3109
|
+
const materializedContent = materializeHandoffMetadata(input.content, input.reason, existingCreatedAt, timestamp);
|
|
3110
|
+
let effectiveInput = { ...input, content: materializedContent };
|
|
3111
|
+
if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
|
|
3112
|
+
const stamp = timestamp.replace(/[:.]/g, "-");
|
|
3113
|
+
const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
|
|
3114
|
+
const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
|
|
3115
|
+
if (externalized.externalized) {
|
|
3116
|
+
const docsDir = resolve(this.root, "docs");
|
|
3117
|
+
await mkdir(docsDir, { recursive: true });
|
|
3118
|
+
const target = resolve(this.root, autoPath.slice(".planner/".length));
|
|
3119
|
+
if (!target.startsWith(`${docsDir}${sep}`)) {
|
|
3120
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
|
|
3121
|
+
}
|
|
3122
|
+
await writeFile(target, externalized.extendedContent, "utf8");
|
|
3123
|
+
const autoDoc = externalized.supportingDocument;
|
|
3124
|
+
effectiveInput = {
|
|
3125
|
+
...input,
|
|
3126
|
+
content: externalized.content,
|
|
3127
|
+
supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
|
|
3132
|
+
const verifiedInput = {
|
|
3133
|
+
...effectiveInput,
|
|
3134
|
+
verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
|
|
3135
|
+
verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
|
|
3136
|
+
};
|
|
2590
3137
|
const originalFeature = originalFeatures.features[featureIndex];
|
|
2591
3138
|
const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
|
|
2592
3139
|
try {
|
|
@@ -2627,14 +3174,38 @@ export class PlanStore {
|
|
|
2627
3174
|
}
|
|
2628
3175
|
});
|
|
2629
3176
|
}
|
|
2630
|
-
/**
|
|
2631
|
-
*
|
|
2632
|
-
|
|
3177
|
+
/** Mark a persisted handoff resume-ready after a separate persisted read-back.
|
|
3178
|
+
* Legacy source evidence is optional; compact handoffs derive it from state. */
|
|
3179
|
+
async verifyPhaseHandoffReadBack(phaseId, input) {
|
|
3180
|
+
const timestamp = nowISO();
|
|
3181
|
+
const phase = await this.updatePhase(phaseId, (current) => {
|
|
3182
|
+
const sourceReviews = validateHandoffReadBackVerification(current, input);
|
|
3183
|
+
return {
|
|
3184
|
+
...current,
|
|
3185
|
+
handoffReadAt: timestamp,
|
|
3186
|
+
handoffAudit: {
|
|
3187
|
+
...current.handoffAudit,
|
|
3188
|
+
resumeReadyAt: timestamp,
|
|
3189
|
+
readBackSourceReviews: sourceReviews,
|
|
3190
|
+
},
|
|
3191
|
+
};
|
|
3192
|
+
});
|
|
3193
|
+
return {
|
|
3194
|
+
phaseId: phase.id,
|
|
3195
|
+
handoffUpdatedAt: phase.handoffUpdatedAt,
|
|
3196
|
+
contentHash: phase.handoffAudit.contentHash,
|
|
3197
|
+
resumeReadyAt: phase.handoffAudit.resumeReadyAt,
|
|
3198
|
+
sourceReviews: phase.handoffAudit.readBackSourceReviews,
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
/** Set the handoff text for a phase + stamp handoffUpdatedAt. A terminal
|
|
3202
|
+
* phase cannot receive a new operational handoff. Replacing an existing
|
|
3203
|
+
* handoff archives the previous content as `superseded` first. */
|
|
2633
3204
|
async setPhaseHandoff(phaseId, text) {
|
|
2634
3205
|
const phase = await this.loadPhase(phaseId);
|
|
2635
3206
|
const normalized = text.trim();
|
|
2636
|
-
if (
|
|
2637
|
-
throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId};
|
|
3207
|
+
if (this.terminalHandoffReason(phase)) {
|
|
3208
|
+
throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
|
|
2638
3209
|
}
|
|
2639
3210
|
if (phase.handoff && normalized && phase.handoff !== normalized) {
|
|
2640
3211
|
await this.clearPhaseHandoff(phaseId, "superseded");
|
|
@@ -2665,7 +3236,7 @@ export class PlanStore {
|
|
|
2665
3236
|
}
|
|
2666
3237
|
const phases = await this.loadAllPhases();
|
|
2667
3238
|
const target = phases.find((p) => p.status === "in-progress")
|
|
2668
|
-
?? phases.find((p) =>
|
|
3239
|
+
?? phases.find((p) => !this.terminalHandoffReason(p))
|
|
2669
3240
|
?? null;
|
|
2670
3241
|
if (!target)
|
|
2671
3242
|
return { imported: false }; // no non-completed phase — leave file for a later run
|
|
@@ -2686,7 +3257,8 @@ export class PlanStore {
|
|
|
2686
3257
|
* metadata entry { file, clearedAt, reason } is prepended to handoffHistory
|
|
2687
3258
|
* (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
|
|
2688
3259
|
* left unchanged as an audit trail. If the handoff is empty, this is a no-op.
|
|
2689
|
-
* reason: "phase-done" | "
|
|
3260
|
+
* reason: "phase-done" | "phase-rejected" | "phase-canceled" |
|
|
3261
|
+
* "manual" | "superseded" | "imported". */
|
|
2690
3262
|
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
2691
3263
|
await this.migrateLegacyHandoffArchive();
|
|
2692
3264
|
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
@@ -2712,14 +3284,15 @@ export class PlanStore {
|
|
|
2712
3284
|
}
|
|
2713
3285
|
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
|
|
2714
3286
|
}
|
|
2715
|
-
/** Archive stale handoffs
|
|
2716
|
-
*
|
|
3287
|
+
/** Archive stale handoffs for every canonical terminal phase outcome.
|
|
3288
|
+
* Idempotent: only non-empty phase.handoff values are moved. */
|
|
2717
3289
|
async archiveStaleHandoffs() {
|
|
2718
3290
|
const phases = await this.loadAllPhases();
|
|
2719
3291
|
let archived = 0;
|
|
2720
3292
|
for (const phase of phases) {
|
|
2721
|
-
|
|
2722
|
-
|
|
3293
|
+
const reason = this.terminalHandoffReason(phase);
|
|
3294
|
+
if (reason && phase.handoff) {
|
|
3295
|
+
await this.clearPhaseHandoff(phase.id, reason);
|
|
2723
3296
|
archived += 1;
|
|
2724
3297
|
}
|
|
2725
3298
|
}
|
|
@@ -2730,7 +3303,7 @@ export class PlanStore {
|
|
|
2730
3303
|
return this.runAsBatch(() => this.archiveStaleHandoffs());
|
|
2731
3304
|
}
|
|
2732
3305
|
/** List only active/pending phase handoffs, newest first. Handoffs from
|
|
2733
|
-
*
|
|
3306
|
+
* canonically terminal phases are archived before returning. */
|
|
2734
3307
|
async listHandoffs(options = {}) {
|
|
2735
3308
|
await this.archiveStaleHandoffs();
|
|
2736
3309
|
const phases = await this.loadAllPhases();
|
|
@@ -2741,7 +3314,7 @@ export class PlanStore {
|
|
|
2741
3314
|
featureNumber.set(f.id, f.number);
|
|
2742
3315
|
const out = [];
|
|
2743
3316
|
for (const p of phases) {
|
|
2744
|
-
if (!p.handoff ||
|
|
3317
|
+
if (!p.handoff || this.terminalHandoffReason(p))
|
|
2745
3318
|
continue;
|
|
2746
3319
|
if (p.featureId && !featureIds.has(p.featureId))
|
|
2747
3320
|
continue;
|
|
@@ -2757,6 +3330,8 @@ export class PlanStore {
|
|
|
2757
3330
|
contentLength: p.handoff.length,
|
|
2758
3331
|
contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
|
|
2759
3332
|
verifiedAt: p.handoffAudit?.verifiedAt ?? "",
|
|
3333
|
+
resumeReady: Boolean(p.handoffAudit?.resumeReadyAt),
|
|
3334
|
+
resumeReadyAt: p.handoffAudit?.resumeReadyAt ?? "",
|
|
2760
3335
|
});
|
|
2761
3336
|
}
|
|
2762
3337
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|