@agent-plan/core 0.2.25 → 0.2.26
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/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 +91 -1
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +134 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/naming.d.ts +3 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +7 -0
- package/dist/payload-fallback.d.ts +38 -0
- package/dist/payload-fallback.d.ts.map +1 -0
- package/dist/payload-fallback.js +73 -0
- package/dist/plan-store.d.ts +65 -8
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +391 -39
- package/dist/planner-rules.d.ts.map +1 -1
- package/dist/planner-rules.js +4 -2
- 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 +20 -10
- package/dist/read-tracking.d.ts.map +1 -1
- package/dist/read-tracking.js +35 -29
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +1 -0
- 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 -1
- 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/schema.d.ts +1088 -273
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +65 -0
- 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/package.json +3 -1
- package/planner-skill.md +210 -0
- package/skills/grill-me/SKILL.md +10 -0
package/dist/plan-store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
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,11 +14,13 @@ 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, TaskPauseSnapshotSchema, ProjectSchema, 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
20
|
import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
|
|
21
|
-
import {
|
|
21
|
+
import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
|
|
22
|
+
import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
|
|
23
|
+
import { applyHandoffContextSync, auditPhaseHandoff, handoffContentHash, HandoffContractError, } from "./handoff-context.js";
|
|
22
24
|
function nowISO() {
|
|
23
25
|
return new Date().toISOString();
|
|
24
26
|
}
|
|
@@ -89,7 +91,7 @@ const CROSS_PROCESS_LOCK_RETRY_MS = 10;
|
|
|
89
91
|
/** Allocation registry is deliberately outside the versioned plan. Git worktrees
|
|
90
92
|
* share their common git dir, so reservations are serialized across branches
|
|
91
93
|
* without rewriting project.json or unrelated planner entities. */
|
|
92
|
-
const AllocationKindSchema = z.enum(["feature", "phase", "task"]);
|
|
94
|
+
const AllocationKindSchema = z.enum(["feature", "phase", "task", "idea"]);
|
|
93
95
|
const AllocationRegistrySchema = z.object({
|
|
94
96
|
version: z.literal(1),
|
|
95
97
|
projectId: z.string().min(1),
|
|
@@ -584,7 +586,7 @@ export class PlanStore {
|
|
|
584
586
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
|
|
585
587
|
// Do NOT renumber here — renumbering would break references after deletions.
|
|
586
588
|
const normalized = tasks.map((task) => {
|
|
587
|
-
const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
|
|
589
|
+
const descriptionUpdatedAt = (task.description || task.descriptionRef) && !task.descriptionUpdatedAt
|
|
588
590
|
? task.createdAt
|
|
589
591
|
: task.descriptionUpdatedAt;
|
|
590
592
|
const normalizedStatus = task.status === "paused" ? "planned" : task.status;
|
|
@@ -623,17 +625,56 @@ export class PlanStore {
|
|
|
623
625
|
/** Stamp description edits independently from generic entity mutations.
|
|
624
626
|
* Legacy entities cannot reveal their historical description-edit time, so
|
|
625
627
|
* their creation time is the earliest truthful fallback. */
|
|
626
|
-
stampDescriptionUpdatedAt(entity, previousDescription, timestamp) {
|
|
627
|
-
if (!entity.description)
|
|
628
|
+
stampDescriptionUpdatedAt(entity, previousDescription, previousDescriptionRef, timestamp) {
|
|
629
|
+
if (!entity.description && !entity.descriptionRef)
|
|
628
630
|
return { ...entity, descriptionUpdatedAt: "" };
|
|
629
|
-
if (previousDescription === undefined
|
|
631
|
+
if (previousDescription === undefined
|
|
632
|
+
|| previousDescription !== entity.description
|
|
633
|
+
|| previousDescriptionRef !== entity.descriptionRef) {
|
|
630
634
|
return { ...entity, descriptionUpdatedAt: timestamp };
|
|
631
635
|
}
|
|
632
636
|
return entity.descriptionUpdatedAt ? entity : { ...entity, descriptionUpdatedAt: entity.createdAt };
|
|
633
637
|
}
|
|
638
|
+
stampProjectGuidelinesUpdatedAt(project, previousContent, timestamp) {
|
|
639
|
+
const nextContent = project.projectGuidelines.content.trim();
|
|
640
|
+
const nextGuidelines = {
|
|
641
|
+
content: nextContent,
|
|
642
|
+
updatedAt: project.projectGuidelines.updatedAt,
|
|
643
|
+
sessionInfo: project.projectGuidelines.sessionInfo,
|
|
644
|
+
};
|
|
645
|
+
if (!nextContent) {
|
|
646
|
+
return {
|
|
647
|
+
...project,
|
|
648
|
+
projectGuidelines: {
|
|
649
|
+
...nextGuidelines,
|
|
650
|
+
updatedAt: "",
|
|
651
|
+
sessionInfo: [],
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (previousContent === undefined || previousContent !== nextContent) {
|
|
656
|
+
return {
|
|
657
|
+
...project,
|
|
658
|
+
projectGuidelines: {
|
|
659
|
+
...nextGuidelines,
|
|
660
|
+
updatedAt: timestamp,
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (nextGuidelines.updatedAt) {
|
|
665
|
+
return { ...project, projectGuidelines: nextGuidelines };
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
...project,
|
|
669
|
+
projectGuidelines: {
|
|
670
|
+
...nextGuidelines,
|
|
671
|
+
updatedAt: timestamp,
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
634
675
|
normalizeFeaturesDocument(doc) {
|
|
635
676
|
// Numbers are a STABLE global sequence (assigned once at create from project.nextFeatureNumber).
|
|
636
|
-
const features = doc.features.map((feature) => feature.description && !feature.descriptionUpdatedAt
|
|
677
|
+
const features = doc.features.map((feature) => (feature.description || feature.descriptionRef) && !feature.descriptionUpdatedAt
|
|
637
678
|
? { ...feature, descriptionUpdatedAt: feature.createdAt }
|
|
638
679
|
: feature);
|
|
639
680
|
return { doc: { ...doc, features }, changed: features.some((feature, index) => feature !== doc.features[index]) };
|
|
@@ -642,7 +683,7 @@ export class PlanStore {
|
|
|
642
683
|
const { tasks, changed } = this.normalizeTasks(phase.tasks);
|
|
643
684
|
const nextTaskIds = tasks.map((task) => task.id);
|
|
644
685
|
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;
|
|
686
|
+
const descriptionUpdatedAt = (phase.description || phase.descriptionRef) && !phase.descriptionUpdatedAt ? phase.createdAt : phase.descriptionUpdatedAt;
|
|
646
687
|
const descriptionTimestampChanged = descriptionUpdatedAt !== phase.descriptionUpdatedAt;
|
|
647
688
|
return {
|
|
648
689
|
phase: {
|
|
@@ -780,6 +821,9 @@ export class PlanStore {
|
|
|
780
821
|
requirementsPath() {
|
|
781
822
|
return join(this.root, "requirements.json");
|
|
782
823
|
}
|
|
824
|
+
ideasPath() {
|
|
825
|
+
return join(this.root, "ideas.json");
|
|
826
|
+
}
|
|
783
827
|
featuresPath() {
|
|
784
828
|
return join(this.root, "features.json");
|
|
785
829
|
}
|
|
@@ -946,6 +990,11 @@ export class PlanStore {
|
|
|
946
990
|
name: projectName,
|
|
947
991
|
goal: "",
|
|
948
992
|
description: "",
|
|
993
|
+
projectGuidelines: {
|
|
994
|
+
content: "",
|
|
995
|
+
updatedAt: "",
|
|
996
|
+
sessionInfo: [],
|
|
997
|
+
},
|
|
949
998
|
webPort: 0,
|
|
950
999
|
scope: [],
|
|
951
1000
|
outOfScope: [],
|
|
@@ -967,6 +1016,7 @@ export class PlanStore {
|
|
|
967
1016
|
workDeviations: [],
|
|
968
1017
|
});
|
|
969
1018
|
await this.saveRequirements({ requirements: [] });
|
|
1019
|
+
await this.saveIdeas({ nextIdeaNumber: 1, ideas: [] });
|
|
970
1020
|
await this.saveFeatures({ features: [] });
|
|
971
1021
|
await this.saveResume({
|
|
972
1022
|
updatedAt: nowISO(),
|
|
@@ -985,6 +1035,8 @@ export class PlanStore {
|
|
|
985
1035
|
// never causes git conflicts. The canonical source is plan-core; this file
|
|
986
1036
|
// is the per-project copy / override point, loaded at planner startup.
|
|
987
1037
|
await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
|
|
1038
|
+
await this.syncPlannerSkill();
|
|
1039
|
+
await this.syncGrillMeSkill();
|
|
988
1040
|
// Write a README stub
|
|
989
1041
|
const readme = [
|
|
990
1042
|
"# Project Plan",
|
|
@@ -994,8 +1046,11 @@ export class PlanStore {
|
|
|
994
1046
|
"## Structure",
|
|
995
1047
|
"",
|
|
996
1048
|
"- `manifest.json` — metadata",
|
|
1049
|
+
"- `SKILL.md` — managed cross-harness planner operating guide",
|
|
1050
|
+
"- `skills/grill-me/SKILL.md` — managed idea-discussion interview skill",
|
|
997
1051
|
"- `project.json` — scope, rules, stack, tools",
|
|
998
1052
|
"- `requirements.json` — requirements and macro-tasks",
|
|
1053
|
+
"- `ideas.json` — top-level Ideas Inbox (excluded from work status derivation)",
|
|
999
1054
|
"- `phases/` — one JSON file per phase",
|
|
1000
1055
|
"- `generated/` — auto-generated markdown views (under `.local/`)",
|
|
1001
1056
|
"- `schema/plan.schema.json` — JSON Schema for tooling",
|
|
@@ -1018,6 +1073,22 @@ export class PlanStore {
|
|
|
1018
1073
|
async extensionRules() {
|
|
1019
1074
|
return loadExtensionRules(this.root);
|
|
1020
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Create or safely refresh the project-local planner usage skill. Explicit
|
|
1078
|
+
* planner-load surfaces call this so unmodified managed copies follow the
|
|
1079
|
+
* installed Agent Plan version while project customizations are preserved.
|
|
1080
|
+
*/
|
|
1081
|
+
async syncPlannerSkill() {
|
|
1082
|
+
return syncProjectPlannerSkill(this.root);
|
|
1083
|
+
}
|
|
1084
|
+
/** Safely create or refresh the project-local grill-me skill for Ideas. */
|
|
1085
|
+
async syncGrillMeSkill() {
|
|
1086
|
+
return syncProjectGrillMeSkill(this.root);
|
|
1087
|
+
}
|
|
1088
|
+
/** Load grill-me instructions only when an Ideas discussion requests them. */
|
|
1089
|
+
async ideaDiscussionSkill() {
|
|
1090
|
+
return loadProjectGrillMeSkill(this.root);
|
|
1091
|
+
}
|
|
1021
1092
|
/** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
|
|
1022
1093
|
* canonical transient/derived patterns). Projects initialized before the
|
|
1023
1094
|
* `.local/` move either have no `.planner/.gitignore` or one with stale
|
|
@@ -1077,7 +1148,7 @@ export class PlanStore {
|
|
|
1077
1148
|
* Worktrees in the same clone share `.git/agent-plan/allocations`, guarded by
|
|
1078
1149
|
* the same cross-process lock used for atomic writes. The registry reserves
|
|
1079
1150
|
* 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
|
|
1151
|
+
* cannot allocate the same F/P/T/I or shortId. Existing entities are never
|
|
1081
1152
|
* rewritten; cross-clone coordination requires a shared allocator service.
|
|
1082
1153
|
*/
|
|
1083
1154
|
async allocateEntityIdentity(kind, entityId) {
|
|
@@ -1098,19 +1169,30 @@ export class PlanStore {
|
|
|
1098
1169
|
return { number: prior.number, shortId: prior.shortId };
|
|
1099
1170
|
const phases = await this.loadAllPhases();
|
|
1100
1171
|
const features = (await this.loadFeatures()).features;
|
|
1172
|
+
const ideasDocument = await this.loadIdeas();
|
|
1173
|
+
const ideas = ideasDocument.ideas;
|
|
1101
1174
|
const canonical = kind === "feature"
|
|
1102
1175
|
? features.map((feature) => ({ number: feature.number, shortId: feature.shortId }))
|
|
1103
1176
|
: kind === "phase"
|
|
1104
1177
|
? phases.map((phase) => ({ number: phase.number, shortId: phase.shortId }))
|
|
1105
|
-
:
|
|
1178
|
+
: kind === "task"
|
|
1179
|
+
? phases.flatMap((phase) => phase.tasks.map((task) => ({ number: task.number, shortId: task.shortId })))
|
|
1180
|
+
: ideas.map((idea) => ({ number: idea.number, shortId: idea.shortId }));
|
|
1106
1181
|
const usedNumbers = new Set([...canonical.map((entry) => entry.number), ...registry.allocations.filter((entry) => entry.kind === kind).map((entry) => entry.number)]);
|
|
1107
|
-
const counter = kind === "feature"
|
|
1182
|
+
const counter = kind === "feature"
|
|
1183
|
+
? project.nextFeatureNumber
|
|
1184
|
+
: kind === "phase"
|
|
1185
|
+
? project.nextPhaseNumber
|
|
1186
|
+
: kind === "task"
|
|
1187
|
+
? project.nextTaskNumber
|
|
1188
|
+
: ideasDocument.nextIdeaNumber;
|
|
1108
1189
|
let number = Math.max(1, counter);
|
|
1109
1190
|
while (usedNumbers.has(number))
|
|
1110
1191
|
number += 1;
|
|
1111
1192
|
const allShortIds = new Set([
|
|
1112
1193
|
...features.map((feature) => feature.shortId),
|
|
1113
1194
|
...phases.flatMap((phase) => [phase.shortId, ...phase.tasks.map((task) => task.shortId)]),
|
|
1195
|
+
...ideas.map((idea) => idea.shortId),
|
|
1114
1196
|
...registry.allocations.map((entry) => entry.shortId),
|
|
1115
1197
|
].filter(Boolean));
|
|
1116
1198
|
const allocation = { kind, entityId, number, shortId: createShortId(allShortIds, `${kind}:${entityId}`) };
|
|
@@ -1124,6 +1206,7 @@ export class PlanStore {
|
|
|
1124
1206
|
async allocFeatureNumber() { return this.allocateLegacyNumber("feature"); }
|
|
1125
1207
|
async allocPhaseNumber() { return this.allocateLegacyNumber("phase"); }
|
|
1126
1208
|
async allocTaskNumber() { return this.allocateLegacyNumber("task"); }
|
|
1209
|
+
async allocIdeaNumber() { return this.allocateLegacyNumber("idea"); }
|
|
1127
1210
|
async allocateLegacyNumber(kind) {
|
|
1128
1211
|
const id = `legacy-${kind}-${randomUUID()}`;
|
|
1129
1212
|
return (await this.allocateEntityIdentity(kind, id)).number;
|
|
@@ -1316,6 +1399,18 @@ export class PlanStore {
|
|
|
1316
1399
|
return { requirements: [] };
|
|
1317
1400
|
}
|
|
1318
1401
|
}
|
|
1402
|
+
async loadIdeas() {
|
|
1403
|
+
try {
|
|
1404
|
+
const document = await readJson(this.ideasPath(), IdeasDocumentSchema);
|
|
1405
|
+
return {
|
|
1406
|
+
...document,
|
|
1407
|
+
ideas: [...document.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
catch {
|
|
1411
|
+
return { nextIdeaNumber: 1, ideas: [] };
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1319
1414
|
async linkedRequirementsForPhase(phaseId) {
|
|
1320
1415
|
const requirements = await this.loadRequirements();
|
|
1321
1416
|
return requirements.requirements.filter((requirement) => requirement.linkedPhaseIds.includes(phaseId));
|
|
@@ -1390,16 +1485,17 @@ export class PlanStore {
|
|
|
1390
1485
|
return deriveParentDisplay(childStatuses);
|
|
1391
1486
|
}
|
|
1392
1487
|
async loadAll() {
|
|
1393
|
-
const [manifest, project, requirements, phases] = await Promise.all([
|
|
1488
|
+
const [manifest, project, requirements, ideas, phases] = await Promise.all([
|
|
1394
1489
|
this.loadManifest(),
|
|
1395
1490
|
this.loadProject(),
|
|
1396
1491
|
this.loadRequirements(),
|
|
1492
|
+
this.loadIdeas(),
|
|
1397
1493
|
this.loadAllPhases(),
|
|
1398
1494
|
]);
|
|
1399
1495
|
const rawFeatures = await this.loadRawFeatures();
|
|
1400
1496
|
const features = rawFeatures.map((f) => ({ ...f, status: this.deriveFeatureStatus(f.id, phases) }));
|
|
1401
1497
|
const normalized = this.normalizeStructureSnapshot({ features }, phases);
|
|
1402
|
-
return { manifest, project, requirements, phases: normalized.phases, features: normalized.features };
|
|
1498
|
+
return { manifest, project, requirements, ideas, phases: normalized.phases, features: normalized.features };
|
|
1403
1499
|
}
|
|
1404
1500
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
1405
1501
|
* dangling feature.phaseIds references. Idempotent. */
|
|
@@ -1907,10 +2003,50 @@ export class PlanStore {
|
|
|
1907
2003
|
}
|
|
1908
2004
|
// ── Savers ───────────────────────────────────────────────────────────
|
|
1909
2005
|
async updateProject(updater) {
|
|
1910
|
-
const
|
|
2006
|
+
const timestamp = nowISO();
|
|
2007
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (current) => {
|
|
2008
|
+
const previousGuidelines = current.projectGuidelines.content;
|
|
2009
|
+
const candidate = updater(current);
|
|
2010
|
+
return this.stampProjectGuidelinesUpdatedAt(candidate, previousGuidelines, timestamp);
|
|
2011
|
+
}, this.root);
|
|
1911
2012
|
await this.maybeAutoSync();
|
|
1912
2013
|
return updated;
|
|
1913
2014
|
}
|
|
2015
|
+
/** Preview the explicit, lossless migration from legacy project rule and
|
|
2016
|
+
* decision fields into Project Guidelines and structured accepted decisions. */
|
|
2017
|
+
async previewLegacyProjectContextMigration() {
|
|
2018
|
+
return previewLegacyProjectContextMigration(await readJson(this.projectPath(), ProjectSchema));
|
|
2019
|
+
}
|
|
2020
|
+
/** Prepare an explicit planner session before recap/context delivery.
|
|
2021
|
+
* Ordinary entity reads remain non-mutating. */
|
|
2022
|
+
async preparePlannerSession() {
|
|
2023
|
+
return plannerSessionPreparationResult(await this.migrateLegacyProjectContext());
|
|
2024
|
+
}
|
|
2025
|
+
/** Apply and verify the legacy project-context migration. Callers must invoke
|
|
2026
|
+
* this only from an explicit preparation or compatibility-recovery flow. */
|
|
2027
|
+
async migrateLegacyProjectContext() {
|
|
2028
|
+
const original = await readJson(this.projectPath(), ProjectSchema);
|
|
2029
|
+
const acceptedAt = nowISO();
|
|
2030
|
+
const expected = applyLegacyProjectContextMigration(original, acceptedAt);
|
|
2031
|
+
if (!expected.applied)
|
|
2032
|
+
return expected;
|
|
2033
|
+
await this.updateProject(() => expected.project);
|
|
2034
|
+
const persisted = await readJson(this.projectPath(), ProjectSchema);
|
|
2035
|
+
const migratedDecisionIds = new Set(expected.preview.acceptedDecisionAdditions.map((decision) => decision.id));
|
|
2036
|
+
const persistedDecisionIds = new Set(persisted.acceptedDecisions.map((decision) => decision.id));
|
|
2037
|
+
const verified = persisted.projectGuidelines.content === expected.preview.resultingGuidelinesContent
|
|
2038
|
+
&& persisted.globalRules.length === 0
|
|
2039
|
+
&& persisted.workflowRules.beforePhaseStart.length === 0
|
|
2040
|
+
&& persisted.workflowRules.beforeTaskStart.length === 0
|
|
2041
|
+
&& persisted.workflowRules.afterPhaseComplete.length === 0
|
|
2042
|
+
&& persisted.decisions.length === 0
|
|
2043
|
+
&& [...migratedDecisionIds].every((id) => persistedDecisionIds.has(id));
|
|
2044
|
+
if (!verified) {
|
|
2045
|
+
await atomicWriteJson(this.projectPath(), ProjectSchema.parse(original), this.root);
|
|
2046
|
+
throw new PlanStoreError("Legacy project-context migration failed persisted read-back verification; the original project was restored.");
|
|
2047
|
+
}
|
|
2048
|
+
return { applied: true, preview: expected.preview, project: persisted };
|
|
2049
|
+
}
|
|
1914
2050
|
/** Persist an explicitly approved work deviation without coupling it to a harness. */
|
|
1915
2051
|
async addWorkDeviation(deviation) {
|
|
1916
2052
|
const deviations = [...(await this.loadWorkDeviations()), deviation];
|
|
@@ -1938,10 +2074,11 @@ export class PlanStore {
|
|
|
1938
2074
|
// Updaters commonly mutate `current` in place, so snapshot descriptions
|
|
1939
2075
|
// before invoking them rather than comparing object references afterward.
|
|
1940
2076
|
const previousDescriptions = new Map(current.features.map((feature) => [feature.id, feature.description]));
|
|
2077
|
+
const previousDescriptionRefs = new Map(current.features.map((feature) => [feature.id, feature.descriptionRef]));
|
|
1941
2078
|
const timestamp = nowISO();
|
|
1942
2079
|
const candidate = updater(current);
|
|
1943
2080
|
const stamped = {
|
|
1944
|
-
features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
|
|
2081
|
+
features: candidate.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
|
|
1945
2082
|
};
|
|
1946
2083
|
const upd = this.normalizeFeaturesDocument(stamped).doc;
|
|
1947
2084
|
await this.saveFeaturesRaw(upd);
|
|
@@ -1955,6 +2092,135 @@ export class PlanStore {
|
|
|
1955
2092
|
await this.maybeAutoSync();
|
|
1956
2093
|
return updated;
|
|
1957
2094
|
}
|
|
2095
|
+
async ensureIdeasFileForWrite() {
|
|
2096
|
+
try {
|
|
2097
|
+
await access(this.ideasPath());
|
|
2098
|
+
}
|
|
2099
|
+
catch {
|
|
2100
|
+
await atomicWriteJson(this.ideasPath(), IdeasDocumentSchema.parse({ nextIdeaNumber: 1, ideas: [] }), this.root);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
async updateIdeas(updater) {
|
|
2104
|
+
await this.ensureIdeasFileForWrite();
|
|
2105
|
+
const updated = await atomicUpdateJson(this.ideasPath(), IdeasDocumentSchema, (current) => {
|
|
2106
|
+
const candidate = IdeasDocumentSchema.parse(updater(current));
|
|
2107
|
+
const maxNumber = candidate.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
|
|
2108
|
+
return {
|
|
2109
|
+
...candidate,
|
|
2110
|
+
nextIdeaNumber: Math.max(candidate.nextIdeaNumber, maxNumber + 1),
|
|
2111
|
+
ideas: [...candidate.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
2112
|
+
};
|
|
2113
|
+
}, this.root);
|
|
2114
|
+
await this.touchTimestamp();
|
|
2115
|
+
await this.maybeAutoSync();
|
|
2116
|
+
return updated;
|
|
2117
|
+
}
|
|
2118
|
+
async createIdea(input, timestamp = nowISO()) {
|
|
2119
|
+
const id = createIdeaId();
|
|
2120
|
+
const { number, shortId } = await this.allocateEntityIdentity("idea", id);
|
|
2121
|
+
const idea = IdeaSchema.parse({
|
|
2122
|
+
id,
|
|
2123
|
+
number,
|
|
2124
|
+
shortId,
|
|
2125
|
+
title: input.title.trim(),
|
|
2126
|
+
description: input.description?.trim() ?? "",
|
|
2127
|
+
promotion: null,
|
|
2128
|
+
createdAt: timestamp,
|
|
2129
|
+
updatedAt: timestamp,
|
|
2130
|
+
});
|
|
2131
|
+
await this.updateIdeas((document) => ({
|
|
2132
|
+
nextIdeaNumber: Math.max(document.nextIdeaNumber, number + 1),
|
|
2133
|
+
ideas: [...document.ideas, idea],
|
|
2134
|
+
}));
|
|
2135
|
+
return idea;
|
|
2136
|
+
}
|
|
2137
|
+
async updateIdea(ideaId, input, timestamp = nowISO()) {
|
|
2138
|
+
let updated;
|
|
2139
|
+
await this.updateIdeas((document) => {
|
|
2140
|
+
const existing = document.ideas.find((idea) => idea.id === ideaId);
|
|
2141
|
+
if (!existing)
|
|
2142
|
+
throw new PlanStoreError(`Idea ${ideaId} not found.`);
|
|
2143
|
+
updated = IdeaSchema.parse({
|
|
2144
|
+
...existing,
|
|
2145
|
+
...(input.title !== undefined ? { title: input.title.trim() } : {}),
|
|
2146
|
+
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
|
2147
|
+
updatedAt: timestamp,
|
|
2148
|
+
});
|
|
2149
|
+
return {
|
|
2150
|
+
...document,
|
|
2151
|
+
ideas: document.ideas.map((idea) => idea.id === ideaId ? updated : idea),
|
|
2152
|
+
};
|
|
2153
|
+
});
|
|
2154
|
+
return updated;
|
|
2155
|
+
}
|
|
2156
|
+
async deleteIdea(ideaId) {
|
|
2157
|
+
let deleted = false;
|
|
2158
|
+
await this.updateIdeas((document) => {
|
|
2159
|
+
deleted = document.ideas.some((idea) => idea.id === ideaId);
|
|
2160
|
+
return deleted
|
|
2161
|
+
? { ...document, ideas: document.ideas.filter((idea) => idea.id !== ideaId) }
|
|
2162
|
+
: document;
|
|
2163
|
+
});
|
|
2164
|
+
return deleted;
|
|
2165
|
+
}
|
|
2166
|
+
async resolveIdeaPromotionTarget(targetType, targetId) {
|
|
2167
|
+
const phases = await this.loadAllPhases();
|
|
2168
|
+
const features = (await this.loadFeatures()).features;
|
|
2169
|
+
if (targetType === "feature") {
|
|
2170
|
+
const feature = features.find((candidate) => candidate.id === targetId);
|
|
2171
|
+
if (!feature)
|
|
2172
|
+
throw new PlanStoreError(`Idea promotion target feature ${targetId} not found.`);
|
|
2173
|
+
return formatFeatureRef(feature.number);
|
|
2174
|
+
}
|
|
2175
|
+
if (targetType === "phase") {
|
|
2176
|
+
const phase = phases.find((candidate) => candidate.id === targetId);
|
|
2177
|
+
if (!phase)
|
|
2178
|
+
throw new PlanStoreError(`Idea promotion target phase ${targetId} not found.`);
|
|
2179
|
+
const feature = features.find((candidate) => candidate.id === phase.featureId);
|
|
2180
|
+
return formatPhaseRef(phase.number, feature?.number);
|
|
2181
|
+
}
|
|
2182
|
+
for (const phase of phases) {
|
|
2183
|
+
const task = phase.tasks.find((candidate) => candidate.id === targetId);
|
|
2184
|
+
if (!task)
|
|
2185
|
+
continue;
|
|
2186
|
+
const feature = features.find((candidate) => candidate.id === phase.featureId);
|
|
2187
|
+
return `${formatPhaseRef(phase.number, feature?.number)}/T${formatThreeDigitNumber(task.number)}`;
|
|
2188
|
+
}
|
|
2189
|
+
throw new PlanStoreError(`Idea promotion target task ${targetId} not found.`);
|
|
2190
|
+
}
|
|
2191
|
+
async promoteIdea(ideaId, input) {
|
|
2192
|
+
const targetRef = await this.resolveIdeaPromotionTarget(input.targetType, input.targetId);
|
|
2193
|
+
const promotedAt = input.promotedAt ?? nowISO();
|
|
2194
|
+
let promoted;
|
|
2195
|
+
await this.updateIdeas((document) => {
|
|
2196
|
+
const existing = document.ideas.find((idea) => idea.id === ideaId);
|
|
2197
|
+
if (!existing)
|
|
2198
|
+
throw new PlanStoreError(`Idea ${ideaId} not found.`);
|
|
2199
|
+
if (existing.promotion) {
|
|
2200
|
+
if (existing.promotion.targetType === input.targetType && existing.promotion.targetId === input.targetId) {
|
|
2201
|
+
promoted = existing;
|
|
2202
|
+
return document;
|
|
2203
|
+
}
|
|
2204
|
+
throw new PlanStoreError(`Idea ${formatIdeaRef(existing.number)} is already promoted to ${existing.promotion.targetRef}.`);
|
|
2205
|
+
}
|
|
2206
|
+
const promotion = {
|
|
2207
|
+
targetType: input.targetType,
|
|
2208
|
+
targetId: input.targetId,
|
|
2209
|
+
targetRef,
|
|
2210
|
+
promotedAt,
|
|
2211
|
+
};
|
|
2212
|
+
promoted = IdeaSchema.parse({
|
|
2213
|
+
...existing,
|
|
2214
|
+
promotion,
|
|
2215
|
+
updatedAt: promotedAt,
|
|
2216
|
+
});
|
|
2217
|
+
return {
|
|
2218
|
+
...document,
|
|
2219
|
+
ideas: document.ideas.map((idea) => idea.id === ideaId ? promoted : idea),
|
|
2220
|
+
};
|
|
2221
|
+
});
|
|
2222
|
+
return promoted;
|
|
2223
|
+
}
|
|
1958
2224
|
/**
|
|
1959
2225
|
* Persist completion of one full task → phase → feature → requirements read
|
|
1960
2226
|
* sequence. Session metadata is deliberately the only changed entity field:
|
|
@@ -2039,10 +2305,26 @@ export class PlanStore {
|
|
|
2039
2305
|
return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
|
|
2040
2306
|
});
|
|
2041
2307
|
}
|
|
2308
|
+
async recordProjectGuidelinesRead(input) {
|
|
2309
|
+
const sessionId = input.sessionId.trim();
|
|
2310
|
+
if (!sessionId)
|
|
2311
|
+
throw new PlanStoreError("Project-guidelines attestation requires a non-empty sessionId.");
|
|
2312
|
+
const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
|
|
2313
|
+
const updated = await atomicUpdateJson(this.projectPath(), ProjectSchema, (project) => {
|
|
2314
|
+
if (!project.projectGuidelines.content.trim())
|
|
2315
|
+
return project;
|
|
2316
|
+
const result = upsertSessionInfo(project.projectGuidelines, sessionId, createdAt);
|
|
2317
|
+
return result.changed ? { ...project, projectGuidelines: result.entity } : project;
|
|
2318
|
+
}, this.root);
|
|
2319
|
+
await this.maybeAutoSync();
|
|
2320
|
+
return updated;
|
|
2321
|
+
}
|
|
2042
2322
|
async saveProject(project) {
|
|
2043
2323
|
// Runtime workDeviations live in .local/deviations.json (T299); never
|
|
2044
2324
|
// persist them here so shared project.json stays stable across worktrees.
|
|
2045
|
-
const
|
|
2325
|
+
const previous = await readJson(this.projectPath(), ProjectSchema).catch(() => null);
|
|
2326
|
+
const stamped = this.stampProjectGuidelinesUpdatedAt(project, previous?.projectGuidelines.content, nowISO());
|
|
2327
|
+
const parsed = ProjectSchema.parse({ ...stamped, workDeviations: [] });
|
|
2046
2328
|
await atomicWriteJson(this.projectPath(), parsed, this.root);
|
|
2047
2329
|
await this.touchTimestamp();
|
|
2048
2330
|
await this.maybeAutoSync();
|
|
@@ -2051,9 +2333,10 @@ export class PlanStore {
|
|
|
2051
2333
|
await this.withFeaturesLock(async () => {
|
|
2052
2334
|
await this.migrateLegacy();
|
|
2053
2335
|
const previousDescriptions = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.description]));
|
|
2336
|
+
const previousDescriptionRefs = new Map((await this.loadRawFeatures()).map((feature) => [feature.id, feature.descriptionRef]));
|
|
2054
2337
|
const timestamp = nowISO();
|
|
2055
2338
|
await this.saveFeaturesRaw({
|
|
2056
|
-
features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), timestamp)),
|
|
2339
|
+
features: features.features.map((feature) => this.stampDescriptionUpdatedAt(feature, previousDescriptions.get(feature.id), previousDescriptionRefs.get(feature.id), timestamp)),
|
|
2057
2340
|
});
|
|
2058
2341
|
});
|
|
2059
2342
|
await this.touchTimestamp();
|
|
@@ -2089,7 +2372,7 @@ export class PlanStore {
|
|
|
2089
2372
|
await this.migrateLegacy();
|
|
2090
2373
|
const previous = await readJson(this.featurePath(feature.id), FeatureSchema).catch(() => null);
|
|
2091
2374
|
await mkdir(this.featuresDir(), { recursive: true });
|
|
2092
|
-
const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, nowISO()));
|
|
2375
|
+
const parsed = FeatureSchema.parse(this.stampDescriptionUpdatedAt(feature, previous?.description, previous?.descriptionRef, nowISO()));
|
|
2093
2376
|
await atomicWriteJson(this.featurePath(parsed.id), parsed, this.root);
|
|
2094
2377
|
});
|
|
2095
2378
|
await this.touchTimestamp();
|
|
@@ -2100,6 +2383,16 @@ export class PlanStore {
|
|
|
2100
2383
|
await atomicWriteJson(this.requirementsPath(), parsed, this.root);
|
|
2101
2384
|
await this.touchTimestamp();
|
|
2102
2385
|
}
|
|
2386
|
+
async saveIdeas(ideas) {
|
|
2387
|
+
const parsed = IdeasDocumentSchema.parse(ideas);
|
|
2388
|
+
const maxNumber = parsed.ideas.reduce((max, idea) => Math.max(max, idea.number), 0);
|
|
2389
|
+
await atomicWriteJson(this.ideasPath(), {
|
|
2390
|
+
...parsed,
|
|
2391
|
+
nextIdeaNumber: Math.max(parsed.nextIdeaNumber, maxNumber + 1),
|
|
2392
|
+
ideas: [...parsed.ideas].sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
|
|
2393
|
+
}, this.root);
|
|
2394
|
+
await this.touchTimestamp();
|
|
2395
|
+
}
|
|
2103
2396
|
async savePhase(phase) {
|
|
2104
2397
|
const previous = await this.loadPhase(phase.id).catch(() => null);
|
|
2105
2398
|
const timestamp = nowISO();
|
|
@@ -2119,8 +2412,9 @@ export class PlanStore {
|
|
|
2119
2412
|
? { ...phase, featureId: resolvedFeatureId }
|
|
2120
2413
|
: phase;
|
|
2121
2414
|
const previousTaskDescriptions = new Map((previous?.tasks ?? []).map((task) => [task.id, task.description]));
|
|
2122
|
-
const
|
|
2123
|
-
timestamped
|
|
2415
|
+
const previousTaskDescriptionRefs = new Map((previous?.tasks ?? []).map((task) => [task.id, task.descriptionRef]));
|
|
2416
|
+
const timestamped = this.stampDescriptionUpdatedAt(normalizedInput, previous?.description, previous?.descriptionRef, timestamp);
|
|
2417
|
+
timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
|
|
2124
2418
|
const parsed = PhaseSchema.parse(this.normalizePhaseDocument(timestamped).phase);
|
|
2125
2419
|
await mkdir(this.phasesDir(), { recursive: true });
|
|
2126
2420
|
await atomicWriteJson(this.phasePath(parsed.id), parsed, this.root);
|
|
@@ -2140,11 +2434,13 @@ export class PlanStore {
|
|
|
2140
2434
|
const current = { ...rawPhase, status: this.derivePhaseStatus(rawPhase.tasks) };
|
|
2141
2435
|
// The updater may mutate `current`, therefore snapshot descriptions first.
|
|
2142
2436
|
const previousDescription = current.description;
|
|
2437
|
+
const previousDescriptionRef = current.descriptionRef;
|
|
2143
2438
|
const previousTaskDescriptions = new Map(current.tasks.map((task) => [task.id, task.description]));
|
|
2439
|
+
const previousTaskDescriptionRefs = new Map(current.tasks.map((task) => [task.id, task.descriptionRef]));
|
|
2144
2440
|
const timestamp = nowISO();
|
|
2145
2441
|
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));
|
|
2442
|
+
const timestamped = this.stampDescriptionUpdatedAt(next, previousDescription, previousDescriptionRef, timestamp);
|
|
2443
|
+
timestamped.tasks = timestamped.tasks.map((task) => this.stampDescriptionUpdatedAt(task, previousTaskDescriptions.get(task.id), previousTaskDescriptionRefs.get(task.id), timestamp));
|
|
2148
2444
|
const resolvedFeatureId = resolveStoredFeatureId(features, timestamped.featureId);
|
|
2149
2445
|
// Referential integrity: reject orphan featureId.
|
|
2150
2446
|
if (timestamped.featureId && timestamped.featureId.trim() && !resolvedFeatureId) {
|
|
@@ -2235,6 +2531,36 @@ export class PlanStore {
|
|
|
2235
2531
|
async getPhaseHandoff(phaseId) {
|
|
2236
2532
|
return (await this.loadPhase(phaseId)).handoff;
|
|
2237
2533
|
}
|
|
2534
|
+
async validateHandoffSupportingDocuments(documents) {
|
|
2535
|
+
const docsRoot = resolve(this.root, "docs");
|
|
2536
|
+
const verified = [];
|
|
2537
|
+
const seen = new Set();
|
|
2538
|
+
for (const document of documents) {
|
|
2539
|
+
const normalizedPath = document.path.trim().replace(/\\/g, "/");
|
|
2540
|
+
if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
|
|
2541
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
|
|
2542
|
+
}
|
|
2543
|
+
if (seen.has(normalizedPath)) {
|
|
2544
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
|
|
2545
|
+
}
|
|
2546
|
+
seen.add(normalizedPath);
|
|
2547
|
+
const target = resolve(this.root, normalizedPath.slice(".planner/".length));
|
|
2548
|
+
if (!target.startsWith(`${docsRoot}${sep}`)) {
|
|
2549
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
|
|
2550
|
+
}
|
|
2551
|
+
const content = await readFile(target, "utf8").catch(() => null);
|
|
2552
|
+
if (content === null || !content.trim()) {
|
|
2553
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
|
|
2554
|
+
}
|
|
2555
|
+
verified.push({
|
|
2556
|
+
path: normalizedPath,
|
|
2557
|
+
description: document.description.trim(),
|
|
2558
|
+
contentHash: handoffContentHash(content),
|
|
2559
|
+
contentLength: content.length,
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
return verified;
|
|
2563
|
+
}
|
|
2238
2564
|
/** Audit one exact phase before preparing a handoff refresh. */
|
|
2239
2565
|
async preparePhaseHandoff(phaseId) {
|
|
2240
2566
|
const phase = await this.loadPhase(phaseId);
|
|
@@ -2259,25 +2585,46 @@ export class PlanStore {
|
|
|
2259
2585
|
if (featureIndex < 0)
|
|
2260
2586
|
throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
|
|
2261
2587
|
const timestamp = nowISO();
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2588
|
+
const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(input.supportingDocuments ?? []);
|
|
2589
|
+
const verifiedInput = { ...input, verifiedSupportingDocuments };
|
|
2590
|
+
const originalFeature = originalFeatures.features[featureIndex];
|
|
2591
|
+
const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
|
|
2265
2592
|
try {
|
|
2266
|
-
|
|
2593
|
+
// Keep the transaction granular: only the parent feature and target phase
|
|
2594
|
+
// belong to this handoff refresh. Rewriting the whole feature document can
|
|
2595
|
+
// disturb unrelated feature containment metadata.
|
|
2596
|
+
await this.saveFeature(applied.feature);
|
|
2267
2597
|
await this.savePhase(applied.phase);
|
|
2598
|
+
const phase = await this.loadPhase(phaseId);
|
|
2599
|
+
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
|
|
2600
|
+
const persistedHash = handoffContentHash(phase.handoff);
|
|
2601
|
+
if (phase.handoff !== applied.phase.handoff
|
|
2602
|
+
|| !phase.handoffAudit
|
|
2603
|
+
|| phase.handoffAudit.contentHash !== persistedHash
|
|
2604
|
+
|| phase.handoffAudit.contentLength !== phase.handoff.length) {
|
|
2605
|
+
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 });
|
|
2606
|
+
}
|
|
2607
|
+
return {
|
|
2608
|
+
phase,
|
|
2609
|
+
feature,
|
|
2610
|
+
updatedTaskIds: applied.updatedTaskIds,
|
|
2611
|
+
handoffUpdatedAt: phase.handoffUpdatedAt,
|
|
2612
|
+
handoffAudit: phase.handoffAudit,
|
|
2613
|
+
};
|
|
2268
2614
|
}
|
|
2269
2615
|
catch (error) {
|
|
2270
2616
|
const rollbackErrors = [];
|
|
2271
|
-
|
|
2272
|
-
|
|
2617
|
+
// Restore the exact parsed snapshots directly. Going through saveFeature /
|
|
2618
|
+
// savePhase would restamp description freshness against the failed write.
|
|
2619
|
+
await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
|
|
2620
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2621
|
+
await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
|
|
2622
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
2273
2623
|
if (rollbackErrors.length > 0) {
|
|
2274
2624
|
throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
|
|
2275
2625
|
}
|
|
2276
2626
|
throw error;
|
|
2277
2627
|
}
|
|
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
2628
|
});
|
|
2282
2629
|
}
|
|
2283
2630
|
/** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
|
|
@@ -2293,7 +2640,7 @@ export class PlanStore {
|
|
|
2293
2640
|
await this.clearPhaseHandoff(phaseId, "superseded");
|
|
2294
2641
|
}
|
|
2295
2642
|
const now = new Date().toISOString();
|
|
2296
|
-
await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now }));
|
|
2643
|
+
await this.updatePhase(phaseId, (current) => ({ ...current, handoff: normalized, handoffUpdatedAt: now, handoffAudit: null }));
|
|
2297
2644
|
}
|
|
2298
2645
|
/** Mark the phase handoff as read/acknowledged on recap (sets handoffReadAt).
|
|
2299
2646
|
* Does NOT clear it: read/load/show are non-mutating resume operations. */
|
|
@@ -2384,7 +2731,7 @@ export class PlanStore {
|
|
|
2384
2731
|
}
|
|
2385
2732
|
/** List only active/pending phase handoffs, newest first. Handoffs from
|
|
2386
2733
|
* phases where every task is done/canceled are archived before returning. */
|
|
2387
|
-
async listHandoffs() {
|
|
2734
|
+
async listHandoffs(options = {}) {
|
|
2388
2735
|
await this.archiveStaleHandoffs();
|
|
2389
2736
|
const phases = await this.loadAllPhases();
|
|
2390
2737
|
const features = await this.loadFeatures();
|
|
@@ -2405,7 +2752,11 @@ export class PlanStore {
|
|
|
2405
2752
|
compositeRef: formatPhaseRef(p.number, fnum),
|
|
2406
2753
|
updatedAt: p.handoffUpdatedAt || p.updatedAt,
|
|
2407
2754
|
firstLine: handoffFirstLine(p.handoff),
|
|
2408
|
-
content: p.handoff,
|
|
2755
|
+
...(options.includeContent ? { content: p.handoff } : {}),
|
|
2756
|
+
auditVersion: p.handoffAudit?.version ?? null,
|
|
2757
|
+
contentLength: p.handoff.length,
|
|
2758
|
+
contentHash: p.handoffAudit?.contentHash ?? handoffContentHash(p.handoff),
|
|
2759
|
+
verifiedAt: p.handoffAudit?.verifiedAt ?? "",
|
|
2409
2760
|
});
|
|
2410
2761
|
}
|
|
2411
2762
|
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -2503,14 +2854,15 @@ export class PlanStore {
|
|
|
2503
2854
|
await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
|
|
2504
2855
|
}
|
|
2505
2856
|
// ── Workspace-level operations ─────────────────────────────────────
|
|
2506
|
-
/** Load the full workspace
|
|
2857
|
+
/** Load the full workspace, including the rollup-independent Ideas Inbox. */
|
|
2507
2858
|
async loadWorkspace() {
|
|
2508
2859
|
const manifest = await this.loadManifest();
|
|
2509
2860
|
const phases = await this.loadAllPhases();
|
|
2510
2861
|
const project = await this.loadProject();
|
|
2511
2862
|
const features = await this.loadFeatures();
|
|
2512
2863
|
const requirements = await this.loadRequirements();
|
|
2513
|
-
|
|
2864
|
+
const ideas = await this.loadIdeas();
|
|
2865
|
+
return { manifest, phases, project, features, requirements, ideas };
|
|
2514
2866
|
}
|
|
2515
2867
|
// ── Markdown generation ────────────────────────────────────────────
|
|
2516
2868
|
/** Load all data, render markdown, and write into generated/. Skips files
|