@agent-plan/core 0.2.25 → 0.2.27
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/display-status.d.ts +3 -3
- package/dist/display-status.d.ts.map +1 -1
- package/dist/display-status.js +5 -4
- package/dist/handoff-context.d.ts +222 -1
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +461 -11
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/naming.d.ts +3 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +7 -0
- 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 +38 -0
- package/dist/payload-fallback.d.ts.map +1 -0
- package/dist/payload-fallback.js +79 -0
- package/dist/plan-store.d.ts +192 -36
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +1048 -126
- package/dist/planner-rules.d.ts.map +1 -1
- package/dist/planner-rules.js +9 -3
- package/dist/planner-skill.d.ts +24 -0
- package/dist/planner-skill.d.ts.map +1 -0
- package/dist/planner-skill.js +113 -0
- package/dist/project-context-migration.d.ts +47 -0
- package/dist/project-context-migration.d.ts.map +1 -0
- package/dist/project-context-migration.js +168 -0
- package/dist/read-tracking.d.ts +47 -13
- package/dist/read-tracking.d.ts.map +1 -1
- package/dist/read-tracking.js +88 -33
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +34 -9
- package/dist/refs.d.ts +6 -1
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +25 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +24 -2
- package/dist/requirement-macro-tasks.d.ts +18 -0
- package/dist/requirement-macro-tasks.d.ts.map +1 -0
- package/dist/requirement-macro-tasks.js +55 -0
- 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 +1575 -290
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +89 -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/task-start-outcome.d.ts +1 -1
- package/dist/task-start-outcome.d.ts.map +1 -1
- package/dist/task-start-outcome.js +1 -0
- 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 +3 -1
- package/planner-skill.md +226 -0
- package/skills/grill-me/SKILL.md +10 -0
package/dist/plan-store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
1
|
+
import { access, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { z, ZodError } from "zod";
|
|
5
5
|
/** Canonical `.planner/.gitignore` content (P042 spec): ignore the `.local/`
|
|
@@ -14,15 +14,83 @@ const PLANNER_GITIGNORE = [
|
|
|
14
14
|
"generated/",
|
|
15
15
|
"",
|
|
16
16
|
].join("\n");
|
|
17
|
-
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
|
|
18
|
-
import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
|
|
17
|
+
import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskSchema, TaskPauseSnapshotSchema, ProjectSchema, AcceptedDecisionSchema, RequirementSchema, RequirementsDocumentSchema, IdeaSchema, IdeasDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
|
|
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
|
-
import {
|
|
22
|
+
import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
|
|
23
|
+
import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.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";
|
|
22
27
|
function nowISO() {
|
|
23
28
|
return new Date().toISOString();
|
|
24
29
|
}
|
|
25
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
|
+
}
|
|
26
94
|
function upsertSessionInfo(entity, sessionId, createdAt) {
|
|
27
95
|
const nextInfo = [
|
|
28
96
|
...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
|
|
@@ -34,6 +102,29 @@ function upsertSessionInfo(entity, sessionId, createdAt) {
|
|
|
34
102
|
|| nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
|
|
35
103
|
return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
|
|
36
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
|
+
}
|
|
37
128
|
function resolveStoredFeatureId(features, ref) {
|
|
38
129
|
const raw = ref?.trim();
|
|
39
130
|
if (!raw)
|
|
@@ -65,6 +156,36 @@ export class PlanStoreError extends Error {
|
|
|
65
156
|
this.name = "PlanStoreError";
|
|
66
157
|
}
|
|
67
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
|
+
}
|
|
68
189
|
// ── Atomic file helpers ────────────────────────────────────────────────
|
|
69
190
|
// Per-path write mutex: serializes concurrent writes to the SAME file so that
|
|
70
191
|
// parallel tool calls (feature_create/phase_create/...) don't truncate JSON.
|
|
@@ -89,9 +210,9 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
|
|
|
89
210
|
/** Allocation registry is deliberately outside the versioned plan. Git worktrees
|
|
90
211
|
* share their common git dir, so reservations are serialized across branches
|
|
91
212
|
* without rewriting project.json or unrelated planner entities. */
|
|
92
|
-
const AllocationKindSchema = z.enum(
|
|
213
|
+
const AllocationKindSchema = z.enum(SUPPORTED_ALLOCATION_KINDS);
|
|
93
214
|
const AllocationRegistrySchema = z.object({
|
|
94
|
-
version: z.literal(
|
|
215
|
+
version: z.literal(ALLOCATION_REGISTRY_VERSION),
|
|
95
216
|
projectId: z.string().min(1),
|
|
96
217
|
allocations: z.array(z.object({
|
|
97
218
|
kind: AllocationKindSchema,
|
|
@@ -124,6 +245,34 @@ async function gitCommonDirFor(planRoot) {
|
|
|
124
245
|
}
|
|
125
246
|
}
|
|
126
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
|
+
}
|
|
127
276
|
async function writeRegistry(path, registry) {
|
|
128
277
|
await mkdir(dirname(path), { recursive: true });
|
|
129
278
|
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
@@ -191,7 +340,7 @@ export function withFeatureLock(featureId, fn) {
|
|
|
191
340
|
});
|
|
192
341
|
}
|
|
193
342
|
async function atomicWriteText(path, raw, root) {
|
|
194
|
-
|
|
343
|
+
const write = () => withWriteLock(path, async () => {
|
|
195
344
|
writeBusyHook?.(true);
|
|
196
345
|
const localRoot = root ? join(root, ".local") : undefined;
|
|
197
346
|
const tmpDir = localRoot ? join(localRoot, "tmp") : dirname(path);
|
|
@@ -224,6 +373,7 @@ async function atomicWriteText(path, raw, root) {
|
|
|
224
373
|
writeBusyHook?.(false);
|
|
225
374
|
}
|
|
226
375
|
});
|
|
376
|
+
return root ? withPlanRootWriteLock(root, write) : write();
|
|
227
377
|
}
|
|
228
378
|
async function atomicWriteJson(path, data, root) {
|
|
229
379
|
return atomicWriteText(path, JSON.stringify(data, null, 2), root);
|
|
@@ -232,7 +382,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
|
|
|
232
382
|
// NOTE: write the file INLINE here, do NOT call atomicWriteJson/atomicWriteText,
|
|
233
383
|
// because those re-acquire withWriteLock(path) — and we already hold it (below).
|
|
234
384
|
// Re-entrant locking is not supported, so calling them would deadlock.
|
|
235
|
-
|
|
385
|
+
const write = () => withWriteLock(path, async () => {
|
|
236
386
|
const current = await readJson(path, schema);
|
|
237
387
|
const updated = updater(current);
|
|
238
388
|
const parsed = schema.parse(updated);
|
|
@@ -268,6 +418,7 @@ async function atomicUpdateJson(path, schema, updater, root) {
|
|
|
268
418
|
}
|
|
269
419
|
return parsed;
|
|
270
420
|
});
|
|
421
|
+
return root ? withPlanRootWriteLock(root, write) : write();
|
|
271
422
|
}
|
|
272
423
|
/** Extract the first meaningful line of a handoff: skip blank lines, strip a
|
|
273
424
|
* leading markdown header (#), trim, and truncate to ~80 chars. */
|
|
@@ -557,22 +708,24 @@ export class PlanStore {
|
|
|
557
708
|
* planners). The caller is responsible for triggering any needed final
|
|
558
709
|
* sync explicitly. */
|
|
559
710
|
async runAsBatch(fn) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
+
});
|
|
568
721
|
}
|
|
569
722
|
/** Public batch wrapper used by the module-level migrateToUuids helper. */
|
|
570
723
|
async runBatchForMigration(fn) {
|
|
571
724
|
return this.runAsBatch(fn);
|
|
572
725
|
}
|
|
573
|
-
/** Public
|
|
574
|
-
*
|
|
575
|
-
*
|
|
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. */
|
|
576
729
|
async runBatch(fn) {
|
|
577
730
|
return this.runAsBatch(fn);
|
|
578
731
|
}
|
|
@@ -584,7 +737,7 @@ export class PlanStore {
|
|
|
584
737
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
|
|
585
738
|
// Do NOT renumber here — renumbering would break references after deletions.
|
|
586
739
|
const normalized = tasks.map((task) => {
|
|
587
|
-
const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
|
|
740
|
+
const descriptionUpdatedAt = (task.description || task.descriptionRef) && !task.descriptionUpdatedAt
|
|
588
741
|
? task.createdAt
|
|
589
742
|
: task.descriptionUpdatedAt;
|
|
590
743
|
const normalizedStatus = task.status === "paused" ? "planned" : task.status;
|
|
@@ -594,11 +747,13 @@ export class PlanStore {
|
|
|
594
747
|
fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
|
|
595
748
|
toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
|
|
596
749
|
}));
|
|
750
|
+
const activeOwnerSession = normalizedStatus === "in-progress" ? task.activeOwnerSession?.trim() ?? "" : "";
|
|
597
751
|
if (descriptionUpdatedAt !== task.descriptionUpdatedAt
|
|
598
752
|
|| normalizedStatus !== task.status
|
|
599
753
|
|| pauseSnapshot !== task.pauseSnapshot
|
|
754
|
+
|| activeOwnerSession !== task.activeOwnerSession
|
|
600
755
|
|| statusLog.some((entry, index) => entry !== task.statusLog[index])) {
|
|
601
|
-
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
|
|
756
|
+
return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog, activeOwnerSession };
|
|
602
757
|
}
|
|
603
758
|
return task;
|
|
604
759
|
});
|
|
@@ -623,17 +778,56 @@ export class PlanStore {
|
|
|
623
778
|
/** Stamp description edits independently from generic entity mutations.
|
|
624
779
|
* Legacy entities cannot reveal their historical description-edit time, so
|
|
625
780
|
* their creation time is the earliest truthful fallback. */
|
|
626
|
-
stampDescriptionUpdatedAt(entity, previousDescription, timestamp) {
|
|
627
|
-
if (!entity.description)
|
|
781
|
+
stampDescriptionUpdatedAt(entity, previousDescription, previousDescriptionRef, timestamp) {
|
|
782
|
+
if (!entity.description && !entity.descriptionRef)
|
|
628
783
|
return { ...entity, descriptionUpdatedAt: "" };
|
|
629
|
-
if (previousDescription === undefined
|
|
784
|
+
if (previousDescription === undefined
|
|
785
|
+
|| previousDescription !== entity.description
|
|
786
|
+
|| previousDescriptionRef !== entity.descriptionRef) {
|
|
630
787
|
return { ...entity, descriptionUpdatedAt: timestamp };
|
|
631
788
|
}
|
|
632
789
|
return entity.descriptionUpdatedAt ? entity : { ...entity, descriptionUpdatedAt: entity.createdAt };
|
|
633
790
|
}
|
|
791
|
+
stampProjectGuidelinesUpdatedAt(project, previousContent, timestamp) {
|
|
792
|
+
const nextContent = project.projectGuidelines.content.trim();
|
|
793
|
+
const nextGuidelines = {
|
|
794
|
+
content: nextContent,
|
|
795
|
+
updatedAt: project.projectGuidelines.updatedAt,
|
|
796
|
+
sessionInfo: project.projectGuidelines.sessionInfo,
|
|
797
|
+
};
|
|
798
|
+
if (!nextContent) {
|
|
799
|
+
return {
|
|
800
|
+
...project,
|
|
801
|
+
projectGuidelines: {
|
|
802
|
+
...nextGuidelines,
|
|
803
|
+
updatedAt: "",
|
|
804
|
+
sessionInfo: [],
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (previousContent === undefined || previousContent !== nextContent) {
|
|
809
|
+
return {
|
|
810
|
+
...project,
|
|
811
|
+
projectGuidelines: {
|
|
812
|
+
...nextGuidelines,
|
|
813
|
+
updatedAt: timestamp,
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (nextGuidelines.updatedAt) {
|
|
818
|
+
return { ...project, projectGuidelines: nextGuidelines };
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
...project,
|
|
822
|
+
projectGuidelines: {
|
|
823
|
+
...nextGuidelines,
|
|
824
|
+
updatedAt: timestamp,
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
634
828
|
normalizeFeaturesDocument(doc) {
|
|
635
829
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
|
|
636
|
-
const features = doc.features.map((feature) => feature.description && !feature.descriptionUpdatedAt
|
|
830
|
+
const features = doc.features.map((feature) => (feature.description || feature.descriptionRef) && !feature.descriptionUpdatedAt
|
|
637
831
|
? { ...feature, descriptionUpdatedAt: feature.createdAt }
|
|
638
832
|
: feature);
|
|
639
833
|
return { doc: { ...doc, features }, changed: features.some((feature, index) => feature !== doc.features[index]) };
|
|
@@ -642,7 +836,7 @@ export class PlanStore {
|
|
|
642
836
|
const { tasks, changed } = this.normalizeTasks(phase.tasks);
|
|
643
837
|
const nextTaskIds = tasks.map((task) => task.id);
|
|
644
838
|
const taskIdsChanged = nextTaskIds.length !== phase.taskIds.length || nextTaskIds.some((id, index) => id !== phase.taskIds[index]);
|
|
645
|
-
const descriptionUpdatedAt = phase.description && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
|
|
839
|
+
const descriptionUpdatedAt = (phase.description || phase.descriptionRef) && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
|
|
646
840
|
const descriptionTimestampChanged = descriptionUpdatedAt !== phase.descriptionUpdatedAt;
|
|
647
841
|
return {
|
|
648
842
|
phase: {
|
|
@@ -780,6 +974,9 @@ export class PlanStore {
|
|
|
780
974
|
requirementsPath() {
|
|
781
975
|
return join(this.root, "requirements.json");
|
|
782
976
|
}
|
|
977
|
+
ideasPath() {
|
|
978
|
+
return join(this.root, "ideas.json");
|
|
979
|
+
}
|
|
783
980
|
featuresPath() {
|
|
784
981
|
return join(this.root, "features.json");
|
|
785
982
|
}
|
|
@@ -790,10 +987,11 @@ export class PlanStore {
|
|
|
790
987
|
return join(this.featuresDir(), `${featureId}.json`);
|
|
791
988
|
}
|
|
792
989
|
withFeaturesLock(fn) {
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
|
|
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));
|
|
797
995
|
}
|
|
798
996
|
/** Idempotent one-time migration: if a legacy features.json exists, split it
|
|
799
997
|
* into features/<id>.json (one per feature) and remove the legacy file.
|
|
@@ -946,6 +1144,11 @@ export class PlanStore {
|
|
|
946
1144
|
name: projectName,
|
|
947
1145
|
goal: "",
|
|
948
1146
|
description: "",
|
|
1147
|
+
projectGuidelines: {
|
|
1148
|
+
content: "",
|
|
1149
|
+
updatedAt: "",
|
|
1150
|
+
sessionInfo: [],
|
|
1151
|
+
},
|
|
949
1152
|
webPort: 0,
|
|
950
1153
|
scope: [],
|
|
951
1154
|
outOfScope: [],
|
|
@@ -967,6 +1170,7 @@ export class PlanStore {
|
|
|
967
1170
|
workDeviations: [],
|
|
968
1171
|
});
|
|
969
1172
|
await this.saveRequirements({ requirements: [] });
|
|
1173
|
+
await this.saveIdeas({ nextIdeaNumber: 1, ideas: [] });
|
|
970
1174
|
await this.saveFeatures({ features: [] });
|
|
971
1175
|
await this.saveResume({
|
|
972
1176
|
updatedAt: nowISO(),
|
|
@@ -985,6 +1189,8 @@ export class PlanStore {
|
|
|
985
1189
|
// never causes git conflicts. The canonical source is plan-core; this file
|
|
986
1190
|
// is the per-project copy / override point, loaded at planner startup.
|
|
987
1191
|
await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
|
|
1192
|
+
await this.syncPlannerSkill();
|
|
1193
|
+
await this.syncGrillMeSkill();
|
|
988
1194
|
// Write a README stub
|
|
989
1195
|
const readme = [
|
|
990
1196
|
"# Project Plan",
|
|
@@ -994,8 +1200,11 @@ export class PlanStore {
|
|
|
994
1200
|
"## Structure",
|
|
995
1201
|
"",
|
|
996
1202
|
"- `manifest.json` — metadata",
|
|
1203
|
+
"- `SKILL.md` — managed cross-harness planner operating guide",
|
|
1204
|
+
"- `skills/grill-me/SKILL.md` — managed idea-discussion interview skill",
|
|
997
1205
|
"- `project.json` — scope, rules, stack, tools",
|
|
998
1206
|
"- `requirements.json` — requirements and macro-tasks",
|
|
1207
|
+
"- `ideas.json` — top-level Ideas Inbox (excluded from work status derivation)",
|
|
999
1208
|
"- `phases/` — one JSON file per phase",
|
|
1000
1209
|
"- `generated/` — auto-generated markdown views (under `.local/`)",
|
|
1001
1210
|
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
@@ -1018,6 +1227,22 @@ export class PlanStore {
|
|
|
1018
1227
|
async extensionRules() {
|
|
1019
1228
|
return loadExtensionRules(this.root);
|
|
1020
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Create or safely refresh the project-local planner usage skill. Explicit
|
|
1232
|
+
* planner-load surfaces call this so unmodified managed copies follow the
|
|
1233
|
+
* installed Agent Plan version while project customizations are preserved.
|
|
1234
|
+
*/
|
|
1235
|
+
async syncPlannerSkill() {
|
|
1236
|
+
return syncProjectPlannerSkill(this.root);
|
|
1237
|
+
}
|
|
1238
|
+
/** Safely create or refresh the project-local grill-me skill for Ideas. */
|
|
1239
|
+
async syncGrillMeSkill() {
|
|
1240
|
+
return syncProjectGrillMeSkill(this.root);
|
|
1241
|
+
}
|
|
1242
|
+
/** Load grill-me instructions only when an Ideas discussion requests them. */
|
|
1243
|
+
async ideaDiscussionSkill() {
|
|
1244
|
+
return loadProjectGrillMeSkill(this.root);
|
|
1245
|
+
}
|
|
1021
1246
|
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
1022
1247
|
* canonical transient/derived patterns). Projects initialized before the
|
|
1023
1248
|
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
@@ -1053,7 +1278,21 @@ export class PlanStore {
|
|
|
1053
1278
|
/** Read-only manifest load. Upgrading legacy `.local` state is explicit
|
|
1054
1279
|
* maintenance (`repair`), never an incidental side effect of opening a plan. */
|
|
1055
1280
|
async loadManifest() {
|
|
1056
|
-
|
|
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
|
+
}
|
|
1057
1296
|
const timestamp = await readJson(this.timestampPath(), z.object({ updatedAt: TimestampSchema })).catch(() => undefined);
|
|
1058
1297
|
return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
|
|
1059
1298
|
}
|
|
@@ -1077,10 +1316,13 @@ export class PlanStore {
|
|
|
1077
1316
|
* Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
|
|
1078
1317
|
* the same cross-process lock used for atomic writes. The registry reserves
|
|
1079
1318
|
* numbers/short IDs before an entity file is written, so parallel branches
|
|
1080
|
-
* cannot allocate the same F/P/T or shortId. Existing entities are never
|
|
1319
|
+
* cannot allocate the same F/P/T/I or shortId. Existing entities are never
|
|
1081
1320
|
* rewritten; cross-clone coordination requires a shared allocator service.
|
|
1082
1321
|
*/
|
|
1083
1322
|
async allocateEntityIdentity(kind, entityId) {
|
|
1323
|
+
if (!isSupportedAllocationKind(String(kind)))
|
|
1324
|
+
throw new PlanUnsupportedAllocationKindError(String(kind));
|
|
1325
|
+
const supportedKind = kind;
|
|
1084
1326
|
const project = await this.loadProject();
|
|
1085
1327
|
const manifest = await this.loadManifest();
|
|
1086
1328
|
const commonGitDir = await gitCommonDirFor(this.root);
|
|
@@ -1088,32 +1330,41 @@ export class PlanStore {
|
|
|
1088
1330
|
? join(commonGitDir, "agent-plan", "allocations", `${manifest.projectId}.json`)
|
|
1089
1331
|
: join(this.localRoot(), "allocations", `${manifest.projectId}.json`);
|
|
1090
1332
|
return withWriteLock(registryPath, async () => {
|
|
1091
|
-
const registry =
|
|
1092
|
-
.then(JSON.parse)
|
|
1093
|
-
.catch(() => ({ version: 1, projectId: manifest.projectId, allocations: [] })));
|
|
1333
|
+
const registry = await readAllocationRegistry(registryPath, manifest.projectId);
|
|
1094
1334
|
if (registry.projectId !== manifest.projectId)
|
|
1095
1335
|
throw new PlanStoreError(`allocation registry project mismatch: ${registryPath}`);
|
|
1096
|
-
const prior = registry.allocations.find((entry) => entry.kind ===
|
|
1336
|
+
const prior = registry.allocations.find((entry) => entry.kind === supportedKind && entry.entityId === entityId);
|
|
1097
1337
|
if (prior)
|
|
1098
1338
|
return { number: prior.number, shortId: prior.shortId };
|
|
1099
1339
|
const phases = await this.loadAllPhases();
|
|
1100
1340
|
const features = (await this.loadFeatures()).features;
|
|
1101
|
-
const
|
|
1341
|
+
const ideasDocument = await this.loadIdeas();
|
|
1342
|
+
const ideas = ideasDocument.ideas;
|
|
1343
|
+
const canonical = supportedKind === "feature"
|
|
1102
1344
|
? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
|
|
1103
|
-
:
|
|
1345
|
+
: supportedKind === "phase"
|
|
1104
1346
|
? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
|
|
1105
|
-
:
|
|
1106
|
-
|
|
1107
|
-
|
|
1347
|
+
: supportedKind === "task"
|
|
1348
|
+
? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
|
|
1349
|
+
: ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
|
|
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"
|
|
1352
|
+
? project.nextFeatureNumber
|
|
1353
|
+
: supportedKind === "phase"
|
|
1354
|
+
? project.nextPhaseNumber
|
|
1355
|
+
: supportedKind === "task"
|
|
1356
|
+
? project.nextTaskNumber
|
|
1357
|
+
: ideasDocument.nextIdeaNumber;
|
|
1108
1358
|
let number = Math.max(1, counter);
|
|
1109
1359
|
while (usedNumbers.has(number))
|
|
1110
1360
|
number += 1;
|
|
1111
1361
|
const allShortIds = new Set([
|
|
1112
1362
|
...features.map((feature) => feature.shortId),
|
|
1113
1363
|
...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
|
|
1364
|
+
...ideas.map((idea) => idea.shortId),
|
|
1114
1365
|
...registry.allocations.map((entry) => entry.shortId),
|
|
1115
1366
|
].filter(Boolean));
|
|
1116
|
-
const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${
|
|
1367
|
+
const allocation = { kind: supportedKind, entityId, number, shortId: createShortId(allShortIds, `${supportedKind}:${entityId}`) };
|
|
1117
1368
|
registry.allocations.push(allocation);
|
|
1118
1369
|
await writeRegistry(registryPath, registry);
|
|
1119
1370
|
return { number: allocation.number, shortId: allocation.shortId };
|
|
@@ -1124,6 +1375,7 @@ export class PlanStore {
|
|
|
1124
1375
|
async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
|
|
1125
1376
|
async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
|
|
1126
1377
|
async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
|
|
1378
|
+
async allocIdeaNumber() { return this.allocateLegacyNumber("idea"); }
|
|
1127
1379
|
async allocateLegacyNumber(kind) {
|
|
1128
1380
|
const id = `legacy-${kind}-${randomUUID()}`;
|
|
1129
1381
|
return (await this.allocateEntityIdentity(kind, id)).number;
|
|
@@ -1316,6 +1568,18 @@ export class PlanStore {
|
|
|
1316
1568
|
return { requirements: [] };
|
|
1317
1569
|
}
|
|
1318
1570
|
}
|
|
1571
|
+
async loadIdeas() {
|
|
1572
|
+
try {
|
|
1573
|
+
const document = await readJson(this.ideasPath(), IdeasDocumentSchema);
|
|
1574
|
+
return {
|
|
1575
|
+
...document,
|
|
1576
|
+
ideas: [...document.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
catch {
|
|
1580
|
+
return { nextIdeaNumber: 1, ideas: [] };
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1319
1583
|
async linkedRequirementsForPhase(phaseId) {
|
|
1320
1584
|
const requirements = await this.loadRequirements();
|
|
1321
1585
|
return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
|
|
@@ -1390,16 +1654,25 @@ export class PlanStore {
|
|
|
1390
1654
|
return deriveParentDisplay(childStatuses);
|
|
1391
1655
|
}
|
|
1392
1656
|
async loadAll() {
|
|
1393
|
-
const [manifest, project, requirements, phases] = await Promise.all([
|
|
1657
|
+
const [manifest, project, requirements, ideas, phases] = await Promise.all([
|
|
1394
1658
|
this.loadManifest(),
|
|
1395
1659
|
this.loadProject(),
|
|
1396
1660
|
this.loadRequirements(),
|
|
1661
|
+
this.loadIdeas(),
|
|
1397
1662
|
this.loadAllPhases(),
|
|
1398
1663
|
]);
|
|
1399
1664
|
const rawFeatures = await this.loadRawFeatures();
|
|
1400
1665
|
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
1401
1666
|
const normalized = this.normalizeStructureSnapshot({ features }, phases);
|
|
1402
|
-
return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
|
|
1667
|
+
return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
|
|
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);
|
|
1403
1676
|
}
|
|
1404
1677
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
1405
1678
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -1760,11 +2033,17 @@ export class PlanStore {
|
|
|
1760
2033
|
const duplicateShortIds = [...sidCounts.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
1761
2034
|
return { duplicatePhaseIds, danglingPhaseIds, duplicateShortIds };
|
|
1762
2035
|
}
|
|
1763
|
-
/**
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
|
|
1767
|
-
|
|
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;
|
|
1768
2047
|
}
|
|
1769
2048
|
derivePhaseStatus(tasks) {
|
|
1770
2049
|
if (tasks.length === 0)
|
|
@@ -1837,14 +2116,14 @@ export class PlanStore {
|
|
|
1837
2116
|
// (serve.ts, adapters) that invoke it after mutations.
|
|
1838
2117
|
return [];
|
|
1839
2118
|
}
|
|
1840
|
-
/** Auto-clear a phase's handoff
|
|
1841
|
-
*
|
|
1842
|
-
* 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. */
|
|
1843
2121
|
async syncTaskStatusRollup(phaseId) {
|
|
1844
2122
|
const phase = await this.loadPhase(phaseId);
|
|
1845
2123
|
let cleared = null;
|
|
1846
|
-
|
|
1847
|
-
|
|
2124
|
+
const terminalReason = this.terminalHandoffReason(phase);
|
|
2125
|
+
if (terminalReason && phase.handoff !== "") {
|
|
2126
|
+
await this.clearPhaseHandoff(phaseId, terminalReason);
|
|
1848
2127
|
const features = await this.loadFeatures();
|
|
1849
2128
|
const feature = features.features.find((f) => f.id === phase.featureId);
|
|
1850
2129
|
cleared = formatPhaseRef(phase.number, feature?.number);
|
|
@@ -1906,11 +2185,52 @@ export class PlanStore {
|
|
|
1906
2185
|
}
|
|
1907
2186
|
}
|
|
1908
2187
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
1909
|
-
async updateProject(updater) {
|
|
1910
|
-
const
|
|
2188
|
+
async updateProject(updater, options = {}) {
|
|
2189
|
+
const timestamp = nowISO();
|
|
2190
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
|
|
2191
|
+
assertPlannerRevision("project", "project-guidelines", options.expectedGuidelinesUpdatedAt, current.projectGuidelines.updatedAt);
|
|
2192
|
+
const previousGuidelines = current.projectGuidelines.content;
|
|
2193
|
+
const candidate = updater(current);
|
|
2194
|
+
return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
|
|
2195
|
+
}, this.root);
|
|
1911
2196
|
await this.maybeAutoSync();
|
|
1912
2197
|
return updated;
|
|
1913
2198
|
}
|
|
2199
|
+
/** Preview the explicit, lossless migration from legacy project rule and
|
|
2200
|
+
* decision fields into Project Guidelines and structured accepted decisions. */
|
|
2201
|
+
async previewLegacyProjectContextMigration() {
|
|
2202
|
+
return previewLegacyProjectContextMigration(await readJson(this.projectPath(), ProjectSchema));
|
|
2203
|
+
}
|
|
2204
|
+
/** Prepare an explicit planner session before recap/context delivery.
|
|
2205
|
+
* Ordinary entity reads remain non-mutating. */
|
|
2206
|
+
async preparePlannerSession() {
|
|
2207
|
+
return plannerSessionPreparationResult(await this.migrateLegacyProjectContext());
|
|
2208
|
+
}
|
|
2209
|
+
/** Apply and verify the legacy project-context migration. Callers must invoke
|
|
2210
|
+
* this only from an explicit preparation or compatibility-recovery flow. */
|
|
2211
|
+
async migrateLegacyProjectContext() {
|
|
2212
|
+
const original = await readJson(this.projectPath(), ProjectSchema);
|
|
2213
|
+
const acceptedAt = nowISO();
|
|
2214
|
+
const expected = applyLegacyProjectContextMigration(original, acceptedAt);
|
|
2215
|
+
if (!expected.applied)
|
|
2216
|
+
return expected;
|
|
2217
|
+
await this.updateProject(() => expected.project);
|
|
2218
|
+
const persisted = await readJson(this.projectPath(), ProjectSchema);
|
|
2219
|
+
const migratedDecisionIds = new Set(expected.preview.acceptedDecisionAdditions.map((decision) => decision.id));
|
|
2220
|
+
const persistedDecisionIds = new Set(persisted.acceptedDecisions.map((decision) => decision.id));
|
|
2221
|
+
const verified = persisted.projectGuidelines.content === expected.preview.resultingGuidelinesContent
|
|
2222
|
+
&& persisted.globalRules.length === 0
|
|
2223
|
+
&& persisted.workflowRules.beforePhaseStart.length === 0
|
|
2224
|
+
&& persisted.workflowRules.beforeTaskStart.length === 0
|
|
2225
|
+
&& persisted.workflowRules.afterPhaseComplete.length === 0
|
|
2226
|
+
&& persisted.decisions.length === 0
|
|
2227
|
+
&& [...migratedDecisionIds].every((id) => persistedDecisionIds.has(id));
|
|
2228
|
+
if (!verified) {
|
|
2229
|
+
await atomicWriteJson(this.projectPath(), ProjectSchema.parse(original), this.root);
|
|
2230
|
+
throw new PlanStoreError("Legacy project-context migration failed persisted read-back verification; the original project was restored.");
|
|
2231
|
+
}
|
|
2232
|
+
return { applied: true, preview: expected.preview, project: persisted };
|
|
2233
|
+
}
|
|
1914
2234
|
/** Persist an explicitly approved work deviation without coupling it to a harness. */
|
|
1915
2235
|
async addWorkDeviation(deviation) {
|
|
1916
2236
|
const deviations = [...(await this.loadWorkDeviations()), deviation];
|
|
@@ -1938,10 +2258,11 @@ export class PlanStore {
|
|
|
1938
2258
|
// Updaters commonly mutate `current` in place, so snapshot descriptions
|
|
1939
2259
|
// before invoking them rather than comparing object references afterward.
|
|
1940
2260
|
const previousDescriptions = new Map(current.features.map((feature) => [feature.id, feature.description]));
|
|
2261
|
+
const previousDescriptionRefs = new Map(current.features.map((feature) => [feature.id, feature.descriptionRef]));
|
|
1941
2262
|
const timestamp = nowISO();
|
|
1942
2263
|
const candidate = updater(current);
|
|
1943
2264
|
const stamped = {
|
|
1944
|
-
features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
|
|
2265
|
+
features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
|
|
1945
2266
|
};
|
|
1946
2267
|
const upd = this.normalizeFeaturesDocument(stamped).doc;
|
|
1947
2268
|
await this.saveFeaturesRaw(upd);
|
|
@@ -1950,11 +2271,176 @@ export class PlanStore {
|
|
|
1950
2271
|
await this.maybeAutoSync();
|
|
1951
2272
|
return updated;
|
|
1952
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
|
+
}
|
|
1953
2292
|
async updateRequirements(updater) {
|
|
1954
2293
|
const updated = await atomicUpdateJson(this.requirementsPath(), RequirementsDocumentSchema, updater, this.root);
|
|
1955
2294
|
await this.maybeAutoSync();
|
|
1956
2295
|
return updated;
|
|
1957
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
|
+
}
|
|
2315
|
+
async ensureIdeasFileForWrite() {
|
|
2316
|
+
try {
|
|
2317
|
+
await access(this.ideasPath());
|
|
2318
|
+
}
|
|
2319
|
+
catch {
|
|
2320
|
+
await atomicWriteJson(this.ideasPath(), IdeasDocumentSchema.parse({ nextIdeaNumber: 1, ideas: [] }), this.root);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
async updateIdeas(updater) {
|
|
2324
|
+
await this.ensureIdeasFileForWrite();
|
|
2325
|
+
const updated = await atomicUpdateJson(this.ideasPath(), IdeasDocumentSchema, (current) => {
|
|
2326
|
+
const candidate = IdeasDocumentSchema.parse(updater(current));
|
|
2327
|
+
const maxNumber = candidate.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
|
|
2328
|
+
return {
|
|
2329
|
+
...candidate,
|
|
2330
|
+
nextIdeaNumber: Math.max(candidate.nextIdeaNumber, maxNumber + 1),
|
|
2331
|
+
ideas: [...candidate.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
2332
|
+
};
|
|
2333
|
+
}, this.root);
|
|
2334
|
+
await this.touchTimestamp();
|
|
2335
|
+
await this.maybeAutoSync();
|
|
2336
|
+
return updated;
|
|
2337
|
+
}
|
|
2338
|
+
async createIdea(input, timestamp = nowISO()) {
|
|
2339
|
+
const id = createIdeaId();
|
|
2340
|
+
const { number, shortId } = await this.allocateEntityIdentity("idea", id);
|
|
2341
|
+
const idea = IdeaSchema.parse({
|
|
2342
|
+
id,
|
|
2343
|
+
number,
|
|
2344
|
+
shortId,
|
|
2345
|
+
title: input.title.trim(),
|
|
2346
|
+
description: input.description?.trim() ?? "",
|
|
2347
|
+
promotion: null,
|
|
2348
|
+
createdAt: timestamp,
|
|
2349
|
+
updatedAt: timestamp,
|
|
2350
|
+
});
|
|
2351
|
+
await this.updateIdeas((document) => ({
|
|
2352
|
+
nextIdeaNumber: Math.max(document.nextIdeaNumber, number + 1),
|
|
2353
|
+
ideas: [...document.ideas, idea],
|
|
2354
|
+
}));
|
|
2355
|
+
return idea;
|
|
2356
|
+
}
|
|
2357
|
+
async updateIdea(ideaId, input, timestamp = nowISO()) {
|
|
2358
|
+
let updated;
|
|
2359
|
+
await this.updateIdeas((document) => {
|
|
2360
|
+
const existing = document.ideas.find((idea) => idea.id === ideaId);
|
|
2361
|
+
if (!existing)
|
|
2362
|
+
throw new PlanStoreError(`Idea ${ideaId} not found.`);
|
|
2363
|
+
updated = IdeaSchema.parse({
|
|
2364
|
+
...existing,
|
|
2365
|
+
...(input.title !== undefined ? { title: input.title.trim() } : {}),
|
|
2366
|
+
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
|
2367
|
+
updatedAt: timestamp,
|
|
2368
|
+
});
|
|
2369
|
+
return {
|
|
2370
|
+
...document,
|
|
2371
|
+
ideas: document.ideas.map((idea) => idea.id === ideaId ? updated : idea),
|
|
2372
|
+
};
|
|
2373
|
+
});
|
|
2374
|
+
return updated;
|
|
2375
|
+
}
|
|
2376
|
+
async deleteIdea(ideaId) {
|
|
2377
|
+
let deleted = false;
|
|
2378
|
+
await this.updateIdeas((document) => {
|
|
2379
|
+
deleted = document.ideas.some((idea) => idea.id === ideaId);
|
|
2380
|
+
return deleted
|
|
2381
|
+
? { ...document, ideas: document.ideas.filter((idea) => idea.id !== ideaId) }
|
|
2382
|
+
: document;
|
|
2383
|
+
});
|
|
2384
|
+
return deleted;
|
|
2385
|
+
}
|
|
2386
|
+
async resolveIdeaPromotionTarget(targetType, targetId) {
|
|
2387
|
+
const phases = await this.loadAllPhases();
|
|
2388
|
+
const features = (await this.loadFeatures()).features;
|
|
2389
|
+
if (targetType === "feature") {
|
|
2390
|
+
const feature = features.find((candidate) => candidate.id === targetId);
|
|
2391
|
+
if (!feature)
|
|
2392
|
+
throw new PlanStoreError(`Idea promotion target feature ${targetId} not found.`);
|
|
2393
|
+
return formatFeatureRef(feature.number);
|
|
2394
|
+
}
|
|
2395
|
+
if (targetType === "phase") {
|
|
2396
|
+
const phase = phases.find((candidate) => candidate.id === targetId);
|
|
2397
|
+
if (!phase)
|
|
2398
|
+
throw new PlanStoreError(`Idea promotion target phase ${targetId} not found.`);
|
|
2399
|
+
const feature = features.find((candidate) => candidate.id === phase.featureId);
|
|
2400
|
+
return formatPhaseRef(phase.number, feature?.number);
|
|
2401
|
+
}
|
|
2402
|
+
for (const phase of phases) {
|
|
2403
|
+
const task = phase.tasks.find((candidate) => candidate.id === targetId);
|
|
2404
|
+
if (!task)
|
|
2405
|
+
continue;
|
|
2406
|
+
const feature = features.find((candidate) => candidate.id === phase.featureId);
|
|
2407
|
+
return `${formatPhaseRef(phase.number, feature?.number)}/T${formatThreeDigitNumber(task.number)}`;
|
|
2408
|
+
}
|
|
2409
|
+
throw new PlanStoreError(`Idea promotion target task ${targetId} not found.`);
|
|
2410
|
+
}
|
|
2411
|
+
async promoteIdea(ideaId, input) {
|
|
2412
|
+
const targetRef = await this.resolveIdeaPromotionTarget(input.targetType, input.targetId);
|
|
2413
|
+
const promotedAt = input.promotedAt ?? nowISO();
|
|
2414
|
+
let promoted;
|
|
2415
|
+
await this.updateIdeas((document) => {
|
|
2416
|
+
const existing = document.ideas.find((idea) => idea.id === ideaId);
|
|
2417
|
+
if (!existing)
|
|
2418
|
+
throw new PlanStoreError(`Idea ${ideaId} not found.`);
|
|
2419
|
+
if (existing.promotion) {
|
|
2420
|
+
if (existing.promotion.targetType === input.targetType && existing.promotion.targetId === input.targetId) {
|
|
2421
|
+
promoted = existing;
|
|
2422
|
+
return document;
|
|
2423
|
+
}
|
|
2424
|
+
throw new PlanStoreError(`Idea ${formatIdeaRef(existing.number)} is already promoted to ${existing.promotion.targetRef}.`);
|
|
2425
|
+
}
|
|
2426
|
+
const promotion = {
|
|
2427
|
+
targetType: input.targetType,
|
|
2428
|
+
targetId: input.targetId,
|
|
2429
|
+
targetRef,
|
|
2430
|
+
promotedAt,
|
|
2431
|
+
};
|
|
2432
|
+
promoted = IdeaSchema.parse({
|
|
2433
|
+
...existing,
|
|
2434
|
+
promotion,
|
|
2435
|
+
updatedAt: promotedAt,
|
|
2436
|
+
});
|
|
2437
|
+
return {
|
|
2438
|
+
...document,
|
|
2439
|
+
ideas: document.ideas.map((idea) => idea.id === ideaId ? promoted : idea),
|
|
2440
|
+
};
|
|
2441
|
+
});
|
|
2442
|
+
return promoted;
|
|
2443
|
+
}
|
|
1958
2444
|
/**
|
|
1959
2445
|
* Persist completion of one full task → phase → feature → requirements read
|
|
1960
2446
|
* sequence. Session metadata is deliberately the only changed entity field:
|
|
@@ -2023,12 +2509,18 @@ export class PlanStore {
|
|
|
2023
2509
|
}
|
|
2024
2510
|
catch (error) {
|
|
2025
2511
|
const rollbackErrors = [];
|
|
2026
|
-
if (featureChanged && nextFeature)
|
|
2027
|
-
await this.
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
if (
|
|
2031
|
-
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
|
+
}
|
|
2032
2524
|
if (rollbackErrors.length > 0)
|
|
2033
2525
|
throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
|
|
2034
2526
|
throw error;
|
|
@@ -2039,21 +2531,57 @@ export class PlanStore {
|
|
|
2039
2531
|
return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
|
|
2040
2532
|
});
|
|
2041
2533
|
}
|
|
2534
|
+
async recordProjectGuidelinesRead(input) {
|
|
2535
|
+
const sessionId = input.sessionId.trim();
|
|
2536
|
+
if (!sessionId)
|
|
2537
|
+
throw new PlanStoreError("Project-guidelines attestation requires a non-empty sessionId.");
|
|
2538
|
+
const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
|
|
2539
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (project) => {
|
|
2540
|
+
if (!project.projectGuidelines.content.trim())
|
|
2541
|
+
return project;
|
|
2542
|
+
const result = upsertSessionInfo(project.projectGuidelines, sessionId, createdAt);
|
|
2543
|
+
return result.changed ? { ...project, projectGuidelines: result.entity } : project;
|
|
2544
|
+
}, this.root);
|
|
2545
|
+
await this.maybeAutoSync();
|
|
2546
|
+
return updated;
|
|
2547
|
+
}
|
|
2042
2548
|
async saveProject(project) {
|
|
2043
2549
|
// Runtime workDeviations live in .local/deviations.json (T299); never
|
|
2044
2550
|
// persist them here so shared project.json stays stable across worktrees.
|
|
2045
|
-
|
|
2046
|
-
|
|
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
|
+
});
|
|
2047
2566
|
await this.touchTimestamp();
|
|
2048
2567
|
await this.maybeAutoSync();
|
|
2049
2568
|
}
|
|
2050
2569
|
async saveFeatures(features) {
|
|
2051
2570
|
await this.withFeaturesLock(async () => {
|
|
2052
2571
|
await this.migrateLegacy();
|
|
2053
|
-
const
|
|
2572
|
+
const currentFeatures = await this.loadRawFeatures();
|
|
2573
|
+
const currentById = new Map(currentFeatures.map((feature) => [feature.id, feature]));
|
|
2054
2574
|
const timestamp = nowISO();
|
|
2055
2575
|
await this.saveFeaturesRaw({
|
|
2056
|
-
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
|
+
}),
|
|
2057
2585
|
});
|
|
2058
2586
|
});
|
|
2059
2587
|
await this.touchTimestamp();
|
|
@@ -2088,63 +2616,115 @@ export class PlanStore {
|
|
|
2088
2616
|
await this.withFeaturesLock(async () => {
|
|
2089
2617
|
await this.migrateLegacy();
|
|
2090
2618
|
const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
|
|
2619
|
+
if (previous)
|
|
2620
|
+
assertSnapshotNotOlder("feature", feature.id, feature.updatedAt, previous.updatedAt);
|
|
2091
2621
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
2092
|
-
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()));
|
|
2093
2626
|
await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
|
|
2094
2627
|
});
|
|
2095
2628
|
await this.touchTimestamp();
|
|
2096
2629
|
await this.maybeAutoSync();
|
|
2097
2630
|
}
|
|
2098
2631
|
async saveRequirements(reqs) {
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
+
});
|
|
2101
2647
|
await this.touchTimestamp();
|
|
2102
2648
|
}
|
|
2103
|
-
async
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2649
|
+
async saveIdeas(ideas) {
|
|
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
|
+
});
|
|
2659
|
+
await this.touchTimestamp();
|
|
2660
|
+
}
|
|
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
|
+
});
|
|
2127
2704
|
await this.touchTimestamp();
|
|
2128
2705
|
await this.maybeAutoSync();
|
|
2129
2706
|
}
|
|
2130
2707
|
/** Atomic read-modify-write on a single phase file. Serializes concurrent
|
|
2131
2708
|
* task_create / phase_update calls on the SAME phaseId so batch operations
|
|
2132
2709
|
* don't lose tasks (last-write-wins race condition). */
|
|
2133
|
-
async updatePhase(phaseId, updater) {
|
|
2710
|
+
async updatePhase(phaseId, updater, options = {}) {
|
|
2134
2711
|
const features = await this.loadRawFeatures();
|
|
2135
2712
|
// Augment the raw (on-disk) phase with its DERIVED status before handing it
|
|
2136
2713
|
// to the updater, so updaters that read 'phase.status' see the truth. The
|
|
2137
2714
|
// returned object's 'status' is stripped by PhaseSchema.parse (status is
|
|
2138
2715
|
// not persisted); the return value is re-derived for the caller.
|
|
2139
2716
|
const raw = await atomicUpdateJson(this.phasePath(phaseId), PhaseSchema, (rawPhase) => {
|
|
2717
|
+
assertPlannerRevision("phase", phaseId, options.expectedUpdatedAt, rawPhase.updatedAt);
|
|
2140
2718
|
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
2141
2719
|
// The updater may mutate `current`, therefore snapshot descriptions first.
|
|
2142
2720
|
const previousDescription = current.description;
|
|
2721
|
+
const previousDescriptionRef = current.descriptionRef;
|
|
2143
2722
|
const previousTaskDescriptions = new Map(current.tasks.map((task) => [task.id, task.description]));
|
|
2723
|
+
const previousTaskDescriptionRefs = new Map(current.tasks.map((task) => [task.id, task.descriptionRef]));
|
|
2144
2724
|
const timestamp = nowISO();
|
|
2145
2725
|
const next = updater(current);
|
|
2146
|
-
const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, timestamp);
|
|
2147
|
-
timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), timestamp));
|
|
2726
|
+
const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, previousDescriptionRef, timestamp);
|
|
2727
|
+
timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
|
|
2148
2728
|
const resolvedFeatureId = resolveStoredFeatureId(features, timestamped.featureId);
|
|
2149
2729
|
// Referential integrity: reject orphan featureId.
|
|
2150
2730
|
if (timestamped.featureId && timestamped.featureId.trim() && !resolvedFeatureId) {
|
|
@@ -2159,6 +2739,109 @@ export class PlanStore {
|
|
|
2159
2739
|
await this.maybeAutoSync();
|
|
2160
2740
|
return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
|
|
2161
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
|
+
return { phase, task: persisted };
|
|
2761
|
+
}
|
|
2762
|
+
async createAcceptedDecision(owner, input, acceptedAt = nowISO()) {
|
|
2763
|
+
const decision = normalizeAcceptedDecisionCreate(input, acceptedAt);
|
|
2764
|
+
if (owner.kind === "project") {
|
|
2765
|
+
await this.updateProject((project) => ({ ...project, acceptedDecisions: [...project.acceptedDecisions, decision] }));
|
|
2766
|
+
return decision;
|
|
2767
|
+
}
|
|
2768
|
+
if (owner.kind === "feature") {
|
|
2769
|
+
await this.updateFeature(owner.featureId, (feature) => ({ ...feature, acceptedDecisions: [...feature.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2770
|
+
return decision;
|
|
2771
|
+
}
|
|
2772
|
+
if (owner.kind === "phase") {
|
|
2773
|
+
await this.updatePhase(owner.phaseId, (phase) => ({ ...phase, acceptedDecisions: [...phase.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2774
|
+
return decision;
|
|
2775
|
+
}
|
|
2776
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => ({ ...task, acceptedDecisions: [...task.acceptedDecisions, decision], updatedAt: acceptedAt }));
|
|
2777
|
+
return decision;
|
|
2778
|
+
}
|
|
2779
|
+
async updateAcceptedDecision(owner, decisionId, input) {
|
|
2780
|
+
let updated;
|
|
2781
|
+
if (owner.kind === "project") {
|
|
2782
|
+
await this.updateProject((project) => {
|
|
2783
|
+
const result = updateAcceptedDecisionList(project.acceptedDecisions, decisionId, input);
|
|
2784
|
+
updated = result.decision;
|
|
2785
|
+
return { ...project, acceptedDecisions: result.decisions };
|
|
2786
|
+
});
|
|
2787
|
+
return updated;
|
|
2788
|
+
}
|
|
2789
|
+
if (owner.kind === "feature") {
|
|
2790
|
+
await this.updateFeature(owner.featureId, (feature) => {
|
|
2791
|
+
const result = updateAcceptedDecisionList(feature.acceptedDecisions, decisionId, input);
|
|
2792
|
+
updated = result.decision;
|
|
2793
|
+
return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2794
|
+
});
|
|
2795
|
+
return updated;
|
|
2796
|
+
}
|
|
2797
|
+
if (owner.kind === "phase") {
|
|
2798
|
+
await this.updatePhase(owner.phaseId, (phase) => {
|
|
2799
|
+
const result = updateAcceptedDecisionList(phase.acceptedDecisions, decisionId, input);
|
|
2800
|
+
updated = result.decision;
|
|
2801
|
+
return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2802
|
+
});
|
|
2803
|
+
return updated;
|
|
2804
|
+
}
|
|
2805
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => {
|
|
2806
|
+
const result = updateAcceptedDecisionList(task.acceptedDecisions, decisionId, input);
|
|
2807
|
+
updated = result.decision;
|
|
2808
|
+
return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2809
|
+
});
|
|
2810
|
+
return updated;
|
|
2811
|
+
}
|
|
2812
|
+
async deleteAcceptedDecision(owner, decisionId) {
|
|
2813
|
+
let deleted;
|
|
2814
|
+
if (owner.kind === "project") {
|
|
2815
|
+
await this.updateProject((project) => {
|
|
2816
|
+
const result = deleteAcceptedDecisionFromList(project.acceptedDecisions, decisionId);
|
|
2817
|
+
deleted = result.decision;
|
|
2818
|
+
return { ...project, acceptedDecisions: result.decisions };
|
|
2819
|
+
});
|
|
2820
|
+
return deleted;
|
|
2821
|
+
}
|
|
2822
|
+
if (owner.kind === "feature") {
|
|
2823
|
+
await this.updateFeature(owner.featureId, (feature) => {
|
|
2824
|
+
const result = deleteAcceptedDecisionFromList(feature.acceptedDecisions, decisionId);
|
|
2825
|
+
deleted = result.decision;
|
|
2826
|
+
return { ...feature, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2827
|
+
});
|
|
2828
|
+
return deleted;
|
|
2829
|
+
}
|
|
2830
|
+
if (owner.kind === "phase") {
|
|
2831
|
+
await this.updatePhase(owner.phaseId, (phase) => {
|
|
2832
|
+
const result = deleteAcceptedDecisionFromList(phase.acceptedDecisions, decisionId);
|
|
2833
|
+
deleted = result.decision;
|
|
2834
|
+
return { ...phase, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2835
|
+
});
|
|
2836
|
+
return deleted;
|
|
2837
|
+
}
|
|
2838
|
+
await this.updateTask(owner.phaseId, owner.taskId, (task) => {
|
|
2839
|
+
const result = deleteAcceptedDecisionFromList(task.acceptedDecisions, decisionId);
|
|
2840
|
+
deleted = result.decision;
|
|
2841
|
+
return { ...task, acceptedDecisions: result.decisions, updatedAt: nowISO() };
|
|
2842
|
+
});
|
|
2843
|
+
return deleted;
|
|
2844
|
+
}
|
|
2162
2845
|
/** Save a durable resume checkpoint without introducing a separate canonical task status. */
|
|
2163
2846
|
async pauseTask(phaseId, taskId, input) {
|
|
2164
2847
|
const snapshot = TaskPauseSnapshotSchema.parse(input);
|
|
@@ -2181,6 +2864,7 @@ export class PlanStore {
|
|
|
2181
2864
|
status: "planned",
|
|
2182
2865
|
pauseSnapshot: snapshot,
|
|
2183
2866
|
pauseHistory: [...task.pauseHistory, snapshot],
|
|
2867
|
+
activeOwnerSession: "",
|
|
2184
2868
|
statusLog: [...task.statusLog, {
|
|
2185
2869
|
id: createStatusLogEntryId(),
|
|
2186
2870
|
date: snapshot.pausedAt,
|
|
@@ -2197,7 +2881,7 @@ export class PlanStore {
|
|
|
2197
2881
|
return paused;
|
|
2198
2882
|
}
|
|
2199
2883
|
/** Resume a checkpointed task without resetting its original startedAt. */
|
|
2200
|
-
async resumeTask(phaseId, taskId, timestamp = nowISO()) {
|
|
2884
|
+
async resumeTask(phaseId, taskId, timestamp = nowISO(), ownerSessionId = "") {
|
|
2201
2885
|
let resumed;
|
|
2202
2886
|
await this.updatePhase(phaseId, (phase) => {
|
|
2203
2887
|
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
@@ -2214,6 +2898,7 @@ export class PlanStore {
|
|
|
2214
2898
|
...task,
|
|
2215
2899
|
status: "in-progress",
|
|
2216
2900
|
pauseSnapshot: null,
|
|
2901
|
+
activeOwnerSession: ownerSessionId.trim() || task.activeOwnerSession,
|
|
2217
2902
|
startedAt: task.startedAt || timestamp,
|
|
2218
2903
|
statusLog: [...task.statusLog, {
|
|
2219
2904
|
id: createStatusLogEntryId(),
|
|
@@ -2230,11 +2915,164 @@ export class PlanStore {
|
|
|
2230
2915
|
});
|
|
2231
2916
|
return resumed;
|
|
2232
2917
|
}
|
|
2918
|
+
/** Reopen a completed task atomically without changing any other task.
|
|
2919
|
+
* Completion evidence remains in the description and status log; completedAt is
|
|
2920
|
+
* cleared because the task is once again active work. */
|
|
2921
|
+
async reopenTask(phaseId, taskId, options) {
|
|
2922
|
+
if (!options.confirmed) {
|
|
2923
|
+
throw new PlanStoreError("Task reopening requires explicit confirmation.", undefined, { errorCode: "TASK_REOPEN_CONFIRMATION_REQUIRED", taskId });
|
|
2924
|
+
}
|
|
2925
|
+
const timestamp = options.timestamp ?? nowISO();
|
|
2926
|
+
let reopened;
|
|
2927
|
+
await this.updatePhase(phaseId, (phase) => {
|
|
2928
|
+
const task = phase.tasks.find((candidate) => candidate.id === taskId);
|
|
2929
|
+
if (!task)
|
|
2930
|
+
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`, undefined, { errorCode: "TASK_NOT_FOUND", taskId });
|
|
2931
|
+
if (task.status !== "done")
|
|
2932
|
+
throw new PlanStoreError(`Task ${taskId} is not completed.`, undefined, { errorCode: "TASK_REOPEN_NOT_DONE", taskId });
|
|
2933
|
+
reopened = {
|
|
2934
|
+
...task,
|
|
2935
|
+
status: "in-progress",
|
|
2936
|
+
completedAt: "",
|
|
2937
|
+
pauseSnapshot: null,
|
|
2938
|
+
activeOwnerSession: options.ownerSessionId?.trim() ?? "",
|
|
2939
|
+
statusLog: [...task.statusLog, {
|
|
2940
|
+
id: createStatusLogEntryId(),
|
|
2941
|
+
date: timestamp,
|
|
2942
|
+
fromStatus: "done",
|
|
2943
|
+
toStatus: "in-progress",
|
|
2944
|
+
title: "done → in-progress (reopened)",
|
|
2945
|
+
description: "Reopened through the confirmed lifecycle operation; prior completion evidence is retained.",
|
|
2946
|
+
}],
|
|
2947
|
+
updatedAt: timestamp,
|
|
2948
|
+
};
|
|
2949
|
+
phase.tasks = phase.tasks.map((candidate) => candidate.id === taskId ? reopened : candidate);
|
|
2950
|
+
return phase;
|
|
2951
|
+
});
|
|
2952
|
+
return reopened;
|
|
2953
|
+
}
|
|
2954
|
+
/** Add a validated dependency edge from one task to another. */
|
|
2955
|
+
async addTaskDependency(phaseId, taskId, dependencyId, timestamp = nowISO()) {
|
|
2956
|
+
const phases = await this.loadAllPhases();
|
|
2957
|
+
const tasks = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task })));
|
|
2958
|
+
const source = tasks.find((entry) => entry.phase.id === phaseId && entry.task.id === taskId);
|
|
2959
|
+
const dependency = tasks.find((entry) => entry.task.id === dependencyId);
|
|
2960
|
+
if (!source)
|
|
2961
|
+
throw new PlanStoreError("Task not found.", undefined, { errorCode: "TASK_NOT_FOUND" });
|
|
2962
|
+
if (!dependency)
|
|
2963
|
+
throw new PlanStoreError("Dependency task not found.", undefined, { errorCode: "DEPENDENCY_TASK_NOT_FOUND" });
|
|
2964
|
+
if (taskId === dependencyId)
|
|
2965
|
+
throw new PlanStoreError("A task cannot depend on itself.", undefined, { errorCode: "DEPENDENCY_SELF" });
|
|
2966
|
+
if (source.task.dependsOn.includes(dependencyId))
|
|
2967
|
+
return source.task;
|
|
2968
|
+
const graph = new Map(tasks.map(({ task }) => [task.id, [...task.dependsOn]]));
|
|
2969
|
+
graph.set(taskId, [...(graph.get(taskId) ?? []), dependencyId]);
|
|
2970
|
+
const visit = (id, path = new Set()) => { if (path.has(id))
|
|
2971
|
+
return true; const next = new Set(path); next.add(id); return (graph.get(id) ?? []).some((child) => visit(child, next)); };
|
|
2972
|
+
if (visit(taskId))
|
|
2973
|
+
throw new PlanStoreError("Dependency would create a cycle.", undefined, { errorCode: "DEPENDENCY_CYCLE" });
|
|
2974
|
+
return (await this.updateTask(phaseId, taskId, (task) => ({ ...task, dependsOn: [...task.dependsOn, dependencyId], updatedAt: timestamp }))).task;
|
|
2975
|
+
}
|
|
2976
|
+
/** Remove a dependency edge without affecting either task. */
|
|
2977
|
+
async deleteTaskDependency(phaseId, taskId, dependencyId) {
|
|
2978
|
+
return (await this.updateTask(phaseId, taskId, (task) => {
|
|
2979
|
+
if (!task.dependsOn.includes(dependencyId))
|
|
2980
|
+
throw new PlanStoreError("Dependency not found.", undefined, { errorCode: "DEPENDENCY_NOT_FOUND" });
|
|
2981
|
+
return { ...task, dependsOn: task.dependsOn.filter((id) => id !== dependencyId), updatedAt: nowISO() };
|
|
2982
|
+
})).task;
|
|
2983
|
+
}
|
|
2984
|
+
/** Create a planner-owned subtask with a stable ID under a task. */
|
|
2985
|
+
async createSubtask(phaseId, taskId, input, timestamp = nowISO()) {
|
|
2986
|
+
const title = input.title.trim();
|
|
2987
|
+
if (!title)
|
|
2988
|
+
throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
|
|
2989
|
+
let created;
|
|
2990
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
2991
|
+
created = { id: randomUUID(), title, status: input.status ?? "planned", description: input.description?.trim() ?? "", createdAt: timestamp, updatedAt: timestamp };
|
|
2992
|
+
task.subtasks = [...(task.subtasks ?? []), created];
|
|
2993
|
+
return task;
|
|
2994
|
+
});
|
|
2995
|
+
return created;
|
|
2996
|
+
}
|
|
2997
|
+
/** Update a subtask by its parent task and planner-owned ID. */
|
|
2998
|
+
async updateSubtask(phaseId, taskId, subtaskId, input, timestamp = nowISO()) {
|
|
2999
|
+
let updated;
|
|
3000
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3001
|
+
const index = (task.subtasks ?? []).findIndex((subtask) => subtask.id === subtaskId);
|
|
3002
|
+
if (index < 0)
|
|
3003
|
+
throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
|
|
3004
|
+
const current = task.subtasks[index];
|
|
3005
|
+
if (input.title !== undefined && !input.title.trim())
|
|
3006
|
+
throw new PlanStoreError("Subtask title is required.", undefined, { errorCode: "SUBTASK_TITLE_REQUIRED" });
|
|
3007
|
+
updated = { ...current, ...(input.title !== undefined ? { title: input.title.trim() } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}), ...(input.status !== undefined ? { status: input.status } : {}), updatedAt: timestamp };
|
|
3008
|
+
task.subtasks = task.subtasks.map((candidate, candidateIndex) => candidateIndex === index ? updated : candidate);
|
|
3009
|
+
return task;
|
|
3010
|
+
});
|
|
3011
|
+
return updated;
|
|
3012
|
+
}
|
|
3013
|
+
/** Delete a subtask by its planner-owned ID. */
|
|
3014
|
+
async deleteSubtask(phaseId, taskId, subtaskId) {
|
|
3015
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3016
|
+
if (!(task.subtasks ?? []).some((subtask) => subtask.id === subtaskId))
|
|
3017
|
+
throw new PlanStoreError(`Subtask ${subtaskId} was not found.`, undefined, { errorCode: "SUBTASK_NOT_FOUND" });
|
|
3018
|
+
task.subtasks = task.subtasks.filter((subtask) => subtask.id !== subtaskId);
|
|
3019
|
+
return task;
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
/** Reorder subtasks without changing their stable identities. */
|
|
3023
|
+
async reorderSubtasks(phaseId, taskId, orderedIds) {
|
|
3024
|
+
let ordered = [];
|
|
3025
|
+
await this.updateTask(phaseId, taskId, (task) => {
|
|
3026
|
+
const current = task.subtasks ?? [];
|
|
3027
|
+
if (orderedIds.length !== current.length || new Set(orderedIds).size !== current.length || orderedIds.some((id) => !current.some((subtask) => subtask.id === id)))
|
|
3028
|
+
throw new PlanStoreError("Subtask order must contain every existing subtask exactly once.", undefined, { errorCode: "SUBTASK_ORDER_INVALID" });
|
|
3029
|
+
ordered = orderedIds.map((id) => current.find((subtask) => subtask.id === id));
|
|
3030
|
+
task.subtasks = ordered;
|
|
3031
|
+
return task;
|
|
3032
|
+
});
|
|
3033
|
+
return ordered;
|
|
3034
|
+
}
|
|
2233
3035
|
// ── Phase-scoped handoff (entity field, harness-agnostic) ────────────
|
|
2234
3036
|
/** Get the handoff text for a phase ("" if none). Throws if phase missing. */
|
|
2235
3037
|
async getPhaseHandoff(phaseId) {
|
|
2236
3038
|
return (await this.loadPhase(phaseId)).handoff;
|
|
2237
3039
|
}
|
|
3040
|
+
async validateHandoffSupportingDocuments(documents) {
|
|
3041
|
+
const docsRoot = resolve(this.root, "docs");
|
|
3042
|
+
const metadata = [];
|
|
3043
|
+
const contents = [];
|
|
3044
|
+
const seen = new Set();
|
|
3045
|
+
for (const document of documents) {
|
|
3046
|
+
const normalizedPath = document.path.trim().replace(/\\/g, "/");
|
|
3047
|
+
if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
|
|
3048
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
|
|
3049
|
+
}
|
|
3050
|
+
if (seen.has(normalizedPath)) {
|
|
3051
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
|
|
3052
|
+
}
|
|
3053
|
+
seen.add(normalizedPath);
|
|
3054
|
+
const target = resolve(this.root, normalizedPath.slice(".planner/".length));
|
|
3055
|
+
if (!target.startsWith(`${docsRoot}${sep}`)) {
|
|
3056
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
|
|
3057
|
+
}
|
|
3058
|
+
const fileStat = await lstat(target).catch(() => null);
|
|
3059
|
+
if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
|
|
3060
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
|
|
3061
|
+
}
|
|
3062
|
+
const content = await readFile(target, "utf8").catch(() => null);
|
|
3063
|
+
if (content === null || !content.trim()) {
|
|
3064
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
|
|
3065
|
+
}
|
|
3066
|
+
metadata.push({
|
|
3067
|
+
path: normalizedPath,
|
|
3068
|
+
description: document.description.trim(),
|
|
3069
|
+
contentHash: handoffContentHash(content),
|
|
3070
|
+
contentLength: content.length,
|
|
3071
|
+
});
|
|
3072
|
+
contents.push(content);
|
|
3073
|
+
}
|
|
3074
|
+
return { metadata, contents };
|
|
3075
|
+
}
|
|
2238
3076
|
/** Audit one exact phase before preparing a handoff refresh. */
|
|
2239
3077
|
async preparePhaseHandoff(phaseId) {
|
|
2240
3078
|
const phase = await this.loadPhase(phaseId);
|
|
@@ -2252,6 +3090,9 @@ export class PlanStore {
|
|
|
2252
3090
|
async refreshPhaseHandoff(phaseId, input) {
|
|
2253
3091
|
return this.runAsBatch(async () => {
|
|
2254
3092
|
const originalPhase = await this.loadPhase(phaseId);
|
|
3093
|
+
if (this.terminalHandoffReason(originalPhase)) {
|
|
3094
|
+
throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
|
|
3095
|
+
}
|
|
2255
3096
|
if (!originalPhase.featureId)
|
|
2256
3097
|
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
2257
3098
|
const originalFeatures = await this.loadFeatures();
|
|
@@ -2259,41 +3100,113 @@ export class PlanStore {
|
|
|
2259
3100
|
if (featureIndex < 0)
|
|
2260
3101
|
throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
|
|
2261
3102
|
const timestamp = nowISO();
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
3103
|
+
const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
|
|
3104
|
+
const materializedContent = materializeHandoffMetadata(input.content, input.reason, existingCreatedAt, timestamp);
|
|
3105
|
+
let effectiveInput = { ...input, content: materializedContent };
|
|
3106
|
+
if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
|
|
3107
|
+
const stamp = timestamp.replace(/[:.]/g, "-");
|
|
3108
|
+
const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
|
|
3109
|
+
const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
|
|
3110
|
+
if (externalized.externalized) {
|
|
3111
|
+
const docsDir = resolve(this.root, "docs");
|
|
3112
|
+
await mkdir(docsDir, { recursive: true });
|
|
3113
|
+
const target = resolve(this.root, autoPath.slice(".planner/".length));
|
|
3114
|
+
if (!target.startsWith(`${docsDir}${sep}`)) {
|
|
3115
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
|
|
3116
|
+
}
|
|
3117
|
+
await writeFile(target, externalized.extendedContent, "utf8");
|
|
3118
|
+
const autoDoc = externalized.supportingDocument;
|
|
3119
|
+
effectiveInput = {
|
|
3120
|
+
...input,
|
|
3121
|
+
content: externalized.content,
|
|
3122
|
+
supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
|
|
3127
|
+
const verifiedInput = {
|
|
3128
|
+
...effectiveInput,
|
|
3129
|
+
verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
|
|
3130
|
+
verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
|
|
3131
|
+
};
|
|
3132
|
+
const originalFeature = originalFeatures.features[featureIndex];
|
|
3133
|
+
const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
|
|
2265
3134
|
try {
|
|
2266
|
-
|
|
3135
|
+
// Keep the transaction granular: only the parent feature and target phase
|
|
3136
|
+
// belong to this handoff refresh. Rewriting the whole feature document can
|
|
3137
|
+
// disturb unrelated feature containment metadata.
|
|
3138
|
+
await this.saveFeature(applied.feature);
|
|
2267
3139
|
await this.savePhase(applied.phase);
|
|
3140
|
+
const phase = await this.loadPhase(phaseId);
|
|
3141
|
+
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
|
|
3142
|
+
const persistedHash = handoffContentHash(phase.handoff);
|
|
3143
|
+
if (phase.handoff !== applied.phase.handoff
|
|
3144
|
+
|| !phase.handoffAudit
|
|
3145
|
+
|| phase.handoffAudit.contentHash !== persistedHash
|
|
3146
|
+
|| phase.handoffAudit.contentLength !== phase.handoff.length) {
|
|
3147
|
+
throw new HandoffContractError("HANDOFF_PERSISTENCE_VERIFICATION_FAILED", "The persisted handoff did not match the verified content. The write was rolled back.", { expectedHash: applied.phase.handoffAudit?.contentHash ?? "", actualHash: persistedHash });
|
|
3148
|
+
}
|
|
3149
|
+
return {
|
|
3150
|
+
phase,
|
|
3151
|
+
feature,
|
|
3152
|
+
updatedTaskIds: applied.updatedTaskIds,
|
|
3153
|
+
handoffUpdatedAt: phase.handoffUpdatedAt,
|
|
3154
|
+
handoffAudit: phase.handoffAudit,
|
|
3155
|
+
};
|
|
2268
3156
|
}
|
|
2269
3157
|
catch (error) {
|
|
2270
3158
|
const rollbackErrors = [];
|
|
2271
|
-
|
|
2272
|
-
|
|
3159
|
+
// Restore the exact parsed snapshots directly. Going through saveFeature /
|
|
3160
|
+
// savePhase would restamp description freshness against the failed write.
|
|
3161
|
+
await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
|
|
3162
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
3163
|
+
await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
|
|
3164
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2273
3165
|
if (rollbackErrors.length > 0) {
|
|
2274
3166
|
throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
|
|
2275
3167
|
}
|
|
2276
3168
|
throw error;
|
|
2277
3169
|
}
|
|
2278
|
-
const phase = await this.loadPhase(phaseId);
|
|
2279
|
-
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
|
|
2280
|
-
return { phase, feature, updatedTaskIds: applied.updatedTaskIds, handoffUpdatedAt: phase.handoffUpdatedAt };
|
|
2281
3170
|
});
|
|
2282
3171
|
}
|
|
2283
|
-
/**
|
|
2284
|
-
*
|
|
2285
|
-
|
|
3172
|
+
/** Mark a persisted handoff resume-ready only after a separate full read-back
|
|
3173
|
+
* has reconciled every required source and found no omissions. */
|
|
3174
|
+
async verifyPhaseHandoffReadBack(phaseId, input) {
|
|
3175
|
+
const timestamp = nowISO();
|
|
3176
|
+
const phase = await this.updatePhase(phaseId, (current) => {
|
|
3177
|
+
const sourceReviews = validateHandoffReadBackVerification(current, input);
|
|
3178
|
+
return {
|
|
3179
|
+
...current,
|
|
3180
|
+
handoffReadAt: timestamp,
|
|
3181
|
+
handoffAudit: {
|
|
3182
|
+
...current.handoffAudit,
|
|
3183
|
+
resumeReadyAt: timestamp,
|
|
3184
|
+
readBackSourceReviews: sourceReviews,
|
|
3185
|
+
},
|
|
3186
|
+
};
|
|
3187
|
+
});
|
|
3188
|
+
return {
|
|
3189
|
+
phaseId: phase.id,
|
|
3190
|
+
handoffUpdatedAt: phase.handoffUpdatedAt,
|
|
3191
|
+
contentHash: phase.handoffAudit.contentHash,
|
|
3192
|
+
resumeReadyAt: phase.handoffAudit.resumeReadyAt,
|
|
3193
|
+
sourceReviews: phase.handoffAudit.readBackSourceReviews,
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
/** Set the handoff text for a phase + stamp handoffUpdatedAt. A terminal
|
|
3197
|
+
* phase cannot receive a new operational handoff. Replacing an existing
|
|
3198
|
+
* handoff archives the previous content as `superseded` first. */
|
|
2286
3199
|
async setPhaseHandoff(phaseId, text) {
|
|
2287
3200
|
const phase = await this.loadPhase(phaseId);
|
|
2288
3201
|
const normalized = text.trim();
|
|
2289
|
-
if (
|
|
2290
|
-
throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId};
|
|
3202
|
+
if (this.terminalHandoffReason(phase)) {
|
|
3203
|
+
throw new PlanStoreError(`Cannot write a handoff on ${phase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
|
|
2291
3204
|
}
|
|
2292
3205
|
if (phase.handoff && normalized && phase.handoff !== normalized) {
|
|
2293
3206
|
await this.clearPhaseHandoff(phaseId, "superseded");
|
|
2294
3207
|
}
|
|
2295
3208
|
const now = new Date().toISOString();
|
|
2296
|
-
await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
|
|
3209
|
+
await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now, handoffAudit: null }));
|
|
2297
3210
|
}
|
|
2298
3211
|
/** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
|
|
2299
3212
|
* Does NOT clear it: read/load/show are non-mutating resume operations. */
|
|
@@ -2318,7 +3231,7 @@ export class PlanStore {
|
|
|
2318
3231
|
}
|
|
2319
3232
|
const phases = await this.loadAllPhases();
|
|
2320
3233
|
const target = phases.find((p) => p.status === "in-progress")
|
|
2321
|
-
?? phases.find((p) =>
|
|
3234
|
+
?? phases.find((p) => !this.terminalHandoffReason(p))
|
|
2322
3235
|
?? null;
|
|
2323
3236
|
if (!target)
|
|
2324
3237
|
return { imported: false }; // no non-completed phase — leave file for a later run
|
|
@@ -2339,7 +3252,8 @@ export class PlanStore {
|
|
|
2339
3252
|
* metadata entry { file, clearedAt, reason } is prepended to handoffHistory
|
|
2340
3253
|
* (capped at 5; oldest file is deleted when trimmed). handoffUpdatedAt is
|
|
2341
3254
|
* left unchanged as an audit trail. If the handoff is empty, this is a no-op.
|
|
2342
|
-
* reason: "phase-done" | "
|
|
3255
|
+
* reason: "phase-done" | "phase-rejected" | "phase-canceled" |
|
|
3256
|
+
* "manual" | "superseded" | "imported". */
|
|
2343
3257
|
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
2344
3258
|
await this.migrateLegacyHandoffArchive();
|
|
2345
3259
|
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
@@ -2365,14 +3279,15 @@ export class PlanStore {
|
|
|
2365
3279
|
}
|
|
2366
3280
|
await this.updatePhase(phaseId, (p) => ({ ...p, handoff: "", handoffHistory: trimmed }));
|
|
2367
3281
|
}
|
|
2368
|
-
/** Archive stale handoffs
|
|
2369
|
-
*
|
|
3282
|
+
/** Archive stale handoffs for every canonical terminal phase outcome.
|
|
3283
|
+
* Idempotent: only non-empty phase.handoff values are moved. */
|
|
2370
3284
|
async archiveStaleHandoffs() {
|
|
2371
3285
|
const phases = await this.loadAllPhases();
|
|
2372
3286
|
let archived = 0;
|
|
2373
3287
|
for (const phase of phases) {
|
|
2374
|
-
|
|
2375
|
-
|
|
3288
|
+
const reason = this.terminalHandoffReason(phase);
|
|
3289
|
+
if (reason && phase.handoff) {
|
|
3290
|
+
await this.clearPhaseHandoff(phase.id, reason);
|
|
2376
3291
|
archived += 1;
|
|
2377
3292
|
}
|
|
2378
3293
|
}
|
|
@@ -2383,8 +3298,8 @@ export class PlanStore {
|
|
|
2383
3298
|
return this.runAsBatch(() => this.archiveStaleHandoffs());
|
|
2384
3299
|
}
|
|
2385
3300
|
/** List only active/pending phase handoffs, newest first. Handoffs from
|
|
2386
|
-
*
|
|
2387
|
-
async listHandoffs() {
|
|
3301
|
+
* canonically terminal phases are archived before returning. */
|
|
3302
|
+
async listHandoffs(options = {}) {
|
|
2388
3303
|
await this.archiveStaleHandoffs();
|
|
2389
3304
|
const phases = await this.loadAllPhases();
|
|
2390
3305
|
const features = await this.loadFeatures();
|
|
@@ -2394,7 +3309,7 @@ export class PlanStore {
|
|
|
2394
3309
|
featureNumber.set(f.id, f.number);
|
|
2395
3310
|
const out = [];
|
|
2396
3311
|
for (const p of phases) {
|
|
2397
|
-
if (!p.handoff ||
|
|
3312
|
+
if (!p.handoff || this.terminalHandoffReason(p))
|
|
2398
3313
|
continue;
|
|
2399
3314
|
if (p.featureId && !featureIds.has(p.featureId))
|
|
2400
3315
|
continue;
|
|
@@ -2405,7 +3320,13 @@ export class PlanStore {
|
|
|
2405
3320
|
compositeRef: formatPhaseRef(p.number, fnum),
|
|
2406
3321
|
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
2407
3322
|
firstLine: handoffFirstLine(p.handoff),
|
|
2408
|
-
content: p.handoff,
|
|
3323
|
+
...(options.includeContent ? { content: p.handoff } : {}),
|
|
3324
|
+
auditVersion: p.handoffAudit?.version ?? null,
|
|
3325
|
+
contentLength: p.handoff.length,
|
|
3326
|
+
contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
|
|
3327
|
+
verifiedAt: p.handoffAudit?.verifiedAt ?? "",
|
|
3328
|
+
resumeReady: Boolean(p.handoffAudit?.resumeReadyAt),
|
|
3329
|
+
resumeReadyAt: p.handoffAudit?.resumeReadyAt ?? "",
|
|
2409
3330
|
});
|
|
2410
3331
|
}
|
|
2411
3332
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -2503,14 +3424,15 @@ export class PlanStore {
|
|
|
2503
3424
|
await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
|
|
2504
3425
|
}
|
|
2505
3426
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
2506
|
-
/** Load the full workspace
|
|
3427
|
+
/** Load the full workspace, including the rollup-independent Ideas Inbox. */
|
|
2507
3428
|
async loadWorkspace() {
|
|
2508
3429
|
const manifest = await this.loadManifest();
|
|
2509
3430
|
const phases = await this.loadAllPhases();
|
|
2510
3431
|
const project = await this.loadProject();
|
|
2511
3432
|
const features = await this.loadFeatures();
|
|
2512
3433
|
const requirements = await this.loadRequirements();
|
|
2513
|
-
|
|
3434
|
+
const ideas = await this.loadIdeas();
|
|
3435
|
+
return { manifest, phases, project, features, requirements, ideas };
|
|
2514
3436
|
}
|
|
2515
3437
|
// ── Markdown generation ────────────────────────────────────────────
|
|
2516
3438
|
/** Load all data, render markdown, and write into generated/. Skips files
|