@agent-plan/core 0.2.29 → 0.3.0
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/accepted-decision-guard.d.ts +94 -0
- package/dist/accepted-decision-guard.d.ts.map +1 -0
- package/dist/accepted-decision-guard.js +110 -0
- package/dist/checklist.d.ts +28 -0
- package/dist/checklist.d.ts.map +1 -1
- package/dist/checklist.js +59 -0
- package/dist/feature-delete.d.ts +62 -0
- package/dist/feature-delete.d.ts.map +1 -0
- package/dist/feature-delete.js +49 -0
- package/dist/guard-decision.d.ts +118 -0
- package/dist/guard-decision.d.ts.map +1 -0
- package/dist/guard-decision.js +203 -0
- package/dist/handoff-context.d.ts +130 -1
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +167 -9
- package/dist/handoff-reply.d.ts +108 -0
- package/dist/handoff-reply.d.ts.map +1 -0
- package/dist/handoff-reply.js +279 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/mutation-reply.d.ts +105 -0
- package/dist/mutation-reply.d.ts.map +1 -0
- package/dist/mutation-reply.js +203 -0
- package/dist/naming.d.ts +1 -1
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +1 -1
- package/dist/pagination.d.ts +19 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +15 -0
- package/dist/payload-fallback.js +1 -1
- package/dist/plan-store.d.ts +141 -8
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +459 -87
- package/dist/project-context-migration.d.ts +5 -0
- package/dist/project-context-migration.d.ts.map +1 -1
- package/dist/project-context-migration.js +13 -0
- package/dist/project-context.d.ts +395 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +295 -0
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +27 -0
- package/dist/recommend-reply.d.ts +51 -0
- package/dist/recommend-reply.d.ts.map +1 -0
- package/dist/recommend-reply.js +84 -0
- package/dist/refs.d.ts +16 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +38 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/schema.d.ts +7 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +8 -3
- package/dist/stranded-phase.d.ts +73 -0
- package/dist/stranded-phase.d.ts.map +1 -0
- package/dist/stranded-phase.js +93 -0
- package/dist/task-completion-gate.d.ts +84 -0
- package/dist/task-completion-gate.d.ts.map +1 -0
- package/dist/task-completion-gate.js +72 -0
- package/dist/task-context.d.ts +1 -0
- package/dist/task-context.d.ts.map +1 -1
- package/dist/task-context.js +31 -13
- package/dist/task-lifecycle.d.ts +12 -0
- package/dist/task-lifecycle.d.ts.map +1 -0
- package/dist/task-lifecycle.js +23 -0
- package/dist/task-order-context.d.ts +72 -0
- package/dist/task-order-context.d.ts.map +1 -0
- package/dist/task-order-context.js +82 -0
- package/dist/task-selection.d.ts.map +1 -1
- package/dist/task-selection.js +14 -12
- package/dist/task-start-outcome.d.ts +26 -0
- package/dist/task-start-outcome.d.ts.map +1 -1
- package/dist/task-start-outcome.js +23 -0
- package/dist/text-bounds.d.ts +32 -0
- package/dist/text-bounds.d.ts.map +1 -0
- package/dist/text-bounds.js +43 -0
- package/dist/write-coordination.d.ts +2 -2
- package/dist/write-coordination.js +2 -2
- package/package.json +1 -1
- package/planner-skill.md +30 -12
package/dist/plan-store.js
CHANGED
|
@@ -21,13 +21,32 @@ import { buildHierarchicalDescriptionFreshness } from "./description-freshness.j
|
|
|
21
21
|
import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
|
|
22
22
|
import { loadProjectGrillMeSkill, syncProjectGrillMeSkill, syncProjectPlannerSkill } from "./planner-skill.js";
|
|
23
23
|
import { applyLegacyProjectContextMigration, plannerSessionPreparationResult, previewLegacyProjectContextMigration, } from "./project-context-migration.js";
|
|
24
|
-
import {
|
|
24
|
+
import { PROJECT_CONTEXT_DELIVERY_VERSION, createCompleteProjectContextDelivery, createProjectContextSnapshot, projectContextFingerprint, projectContextReadState, verifyProjectContextChunks, ProjectContextDeliveryError, ProjectContextReadStateDocumentSchema, } from "./project-context.js";
|
|
25
|
+
import { applyHandoffContextSync, auditPhaseHandoff, externalizeOversizedHandoffContent, extractHandoffSectionHeadings, handoffContentHash, isAutoExternalizedHandoffDocument, materializeHandoffMetadata, HandoffContractError, retainedHandoffContentNotFoundError, supportingDocumentInvalidError, TARGET_HANDOFF_CONTENT_CHARS, validateHandoffReadBackVerification, } from "./handoff-context.js";
|
|
25
26
|
import { withPlanRootWriteLock } from "./write-coordination.js";
|
|
26
27
|
import { ALLOCATION_REGISTRY_VERSION, PLAN_SCHEMA_VERSION, SUPPORTED_ALLOCATION_KINDS, isSupportedAllocationKind, unsupportedAllocationKindDetails, } from "./runtime-diagnostics.js";
|
|
27
|
-
|
|
28
|
+
/** Current timestamp in ISO-8601. The one place that decides "now" for every
|
|
29
|
+
* planner write — adapters import this instead of each calling
|
|
30
|
+
* `new Date().toISOString()` themselves, so a future need to freeze or mock
|
|
31
|
+
* time has one call site to change. */
|
|
32
|
+
export function nowISO() {
|
|
28
33
|
return new Date().toISOString();
|
|
29
34
|
}
|
|
30
35
|
const MAX_SESSION_INFO_ENTRIES = 16;
|
|
36
|
+
const RetainedHandoffDraftSchema = z.object({
|
|
37
|
+
phaseId: z.string(),
|
|
38
|
+
token: z.string(),
|
|
39
|
+
content: z.string(),
|
|
40
|
+
failureReason: z.string(),
|
|
41
|
+
retainedAt: z.string(),
|
|
42
|
+
});
|
|
43
|
+
// Bounds for retained handoff drafts, so a run of failed writes cannot grow
|
|
44
|
+
// .local/ without limit: at most this many distinct phase+token entries
|
|
45
|
+
// (oldest evicted first), each expiring after this age, with a defensive
|
|
46
|
+
// per-entry size ceiling far above MAX_HANDOFF_CONTENT_CHARS.
|
|
47
|
+
const MAX_RETAINED_HANDOFF_DRAFTS = 20;
|
|
48
|
+
const RETAINED_HANDOFF_DRAFT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
49
|
+
const MAX_RETAINED_HANDOFF_DRAFT_CONTENT_CHARS = 200_000;
|
|
31
50
|
function normalizeAcceptedDecisionCreate(input, acceptedAt) {
|
|
32
51
|
const title = input.title.trim();
|
|
33
52
|
if (!title) {
|
|
@@ -114,6 +133,17 @@ function mergeSessionInfo(current, incoming) {
|
|
|
114
133
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
115
134
|
.slice(0, MAX_SESSION_INFO_ENTRIES);
|
|
116
135
|
}
|
|
136
|
+
function mergeProjectContextSessionInfo(current, incoming) {
|
|
137
|
+
const newestBySession = new Map();
|
|
138
|
+
for (const entry of [...(current ?? []), ...(incoming ?? [])]) {
|
|
139
|
+
const prior = newestBySession.get(entry.sessionId);
|
|
140
|
+
if (!prior || entry.createdAt > prior.createdAt)
|
|
141
|
+
newestBySession.set(entry.sessionId, entry);
|
|
142
|
+
}
|
|
143
|
+
return [...newestBySession.values()]
|
|
144
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
|
145
|
+
.slice(0, MAX_SESSION_INFO_ENTRIES);
|
|
146
|
+
}
|
|
117
147
|
function assertSnapshotNotOlder(entity, entityId, incomingUpdatedAt, currentUpdatedAt) {
|
|
118
148
|
if (incomingUpdatedAt < currentUpdatedAt) {
|
|
119
149
|
throw new PlanStaleWriteError({
|
|
@@ -687,8 +717,6 @@ async function readJson(path, schema) {
|
|
|
687
717
|
}
|
|
688
718
|
export class PlanStore {
|
|
689
719
|
root;
|
|
690
|
-
autoSync = false;
|
|
691
|
-
syncGuard = false;
|
|
692
720
|
// While true, maybeAutoSync() is a no-op. Used by batch operations
|
|
693
721
|
// (migrateToUuids, ensureStructureOrdering, syncStatuses, repair) so that
|
|
694
722
|
// their internal savePhase/saveFeatures calls do NOT re-trigger a full
|
|
@@ -702,7 +730,7 @@ export class PlanStore {
|
|
|
702
730
|
/** When enabled, status rollup (syncStatuses) runs automatically after every
|
|
703
731
|
* phase/feature/project save. Used by the pi-adapter so the agent's tool
|
|
704
732
|
* mutations keep phase/feature statuses derived from task statuses. */
|
|
705
|
-
enableAutoSync(
|
|
733
|
+
enableAutoSync(_value) { }
|
|
706
734
|
/** Run a batch operation with autoSync suspended. Internal saves inside the
|
|
707
735
|
* batch will NOT re-trigger syncStatuses (which would be O(N^2) on large
|
|
708
736
|
* planners). The caller is responsible for triggering any needed final
|
|
@@ -1028,6 +1056,9 @@ export class PlanStore {
|
|
|
1028
1056
|
activityPath() {
|
|
1029
1057
|
return join(this.localRoot(), "activity.json");
|
|
1030
1058
|
}
|
|
1059
|
+
projectContextReadsPath() {
|
|
1060
|
+
return join(this.localRoot(), "project-context-reads.json");
|
|
1061
|
+
}
|
|
1031
1062
|
localRoot() {
|
|
1032
1063
|
// Worktree-local, git-ignored runtime state. The shared-vs-local boundary
|
|
1033
1064
|
// and the invariants every harness relies on are documented in
|
|
@@ -1063,6 +1094,9 @@ export class PlanStore {
|
|
|
1063
1094
|
handoffArchiveDir() {
|
|
1064
1095
|
return join(this.localRoot(), "handoff-archive");
|
|
1065
1096
|
}
|
|
1097
|
+
handoffDraftsPath() {
|
|
1098
|
+
return join(this.localRoot(), "handoff-drafts.json");
|
|
1099
|
+
}
|
|
1066
1100
|
/** One-time migration for plans created before .planner/.local/ existed.
|
|
1067
1101
|
* Moves a legacy root-level file into .local/ if the legacy file exists and
|
|
1068
1102
|
* the .local/ counterpart does not. Safe to call on every load. */
|
|
@@ -1677,7 +1711,6 @@ export class PlanStore {
|
|
|
1677
1711
|
/** Migrate legacy non-feature-scoped phase ids to feature-scoped ids and repair
|
|
1678
1712
|
* dangling feature.phaseIds references. Idempotent. */
|
|
1679
1713
|
async migratePhaseIds() {
|
|
1680
|
-
const { readdir, unlink } = await import("node:fs/promises");
|
|
1681
1714
|
const phases = await this.loadAllPhases();
|
|
1682
1715
|
const features = await this.loadFeatures();
|
|
1683
1716
|
// Infer missing featureId from feature.phaseIds references (legacy back-link).
|
|
@@ -2122,6 +2155,13 @@ export class PlanStore {
|
|
|
2122
2155
|
const phase = await this.loadPhase(phaseId);
|
|
2123
2156
|
let cleared = null;
|
|
2124
2157
|
const terminalReason = this.terminalHandoffReason(phase);
|
|
2158
|
+
if (terminalReason) {
|
|
2159
|
+
// A terminal phase can never receive another handoff write, so any
|
|
2160
|
+
// draft retained against a stale/failed prepare token for it is dead
|
|
2161
|
+
// weight — discard it here regardless of whether there was an active
|
|
2162
|
+
// handoff body to archive below.
|
|
2163
|
+
await this.discardRetainedHandoffDrafts(phaseId);
|
|
2164
|
+
}
|
|
2125
2165
|
if (terminalReason && phase.handoff !== "") {
|
|
2126
2166
|
await this.clearPhaseHandoff(phaseId, terminalReason);
|
|
2127
2167
|
const features = await this.loadFeatures();
|
|
@@ -2531,6 +2571,118 @@ export class PlanStore {
|
|
|
2531
2571
|
return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
|
|
2532
2572
|
});
|
|
2533
2573
|
}
|
|
2574
|
+
async loadProjectContextReadStateDocument() {
|
|
2575
|
+
try {
|
|
2576
|
+
await access(this.projectContextReadsPath());
|
|
2577
|
+
}
|
|
2578
|
+
catch {
|
|
2579
|
+
return ProjectContextReadStateDocumentSchema.parse({});
|
|
2580
|
+
}
|
|
2581
|
+
return readJson(this.projectContextReadsPath(), ProjectContextReadStateDocumentSchema);
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* Read project.json and requirements.json as one coherent pair with no
|
|
2585
|
+
* lock. A concurrent writer could otherwise interleave the two reads into
|
|
2586
|
+
* a torn pair (half old, half new); instead of blocking behind the plan
|
|
2587
|
+
* root write lock like a mutation, read twice and compare fingerprints,
|
|
2588
|
+
* retrying until two consecutive reads agree (bounded, so a pathological
|
|
2589
|
+
* writer cannot spin this forever).
|
|
2590
|
+
*/
|
|
2591
|
+
async readCoherentProjectAndRequirements() {
|
|
2592
|
+
const maxAttempts = 5;
|
|
2593
|
+
const readOnce = async () => {
|
|
2594
|
+
const [project, requirements] = await Promise.all([
|
|
2595
|
+
readJson(this.projectPath(), ProjectSchema),
|
|
2596
|
+
readJson(this.requirementsPath(), RequirementsDocumentSchema),
|
|
2597
|
+
]);
|
|
2598
|
+
return { project, requirements };
|
|
2599
|
+
};
|
|
2600
|
+
let current = await readOnce();
|
|
2601
|
+
for (let attempt = 1; attempt < maxAttempts; attempt++) {
|
|
2602
|
+
const currentFingerprint = projectContextFingerprint(createProjectContextSnapshot(current.project, current.requirements));
|
|
2603
|
+
const next = await readOnce();
|
|
2604
|
+
const nextFingerprint = projectContextFingerprint(createProjectContextSnapshot(next.project, next.requirements));
|
|
2605
|
+
if (nextFingerprint === currentFingerprint)
|
|
2606
|
+
return next;
|
|
2607
|
+
current = next;
|
|
2608
|
+
}
|
|
2609
|
+
return current;
|
|
2610
|
+
}
|
|
2611
|
+
async loadProjectContextDelivery(maxChars) {
|
|
2612
|
+
const { project, requirements } = await this.readCoherentProjectAndRequirements();
|
|
2613
|
+
return createCompleteProjectContextDelivery(project, requirements, maxChars);
|
|
2614
|
+
}
|
|
2615
|
+
async projectContextReadStateForSession(sessionId) {
|
|
2616
|
+
const { project, requirements } = await this.readCoherentProjectAndRequirements();
|
|
2617
|
+
const readState = await this.loadProjectContextReadStateDocument();
|
|
2618
|
+
return projectContextReadState(createProjectContextSnapshot(project, requirements), readState.sessionInfo, sessionId);
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Persist a worktree-local project-context read only after receiving the exact
|
|
2622
|
+
* complete chunk sequence for the current project and Requirements revisions.
|
|
2623
|
+
*/
|
|
2624
|
+
/**
|
|
2625
|
+
* Governance decision (P102(F005) accepted decision): a project-context
|
|
2626
|
+
* read stamps sessionInfo on every requirement, the same way it stamps
|
|
2627
|
+
* every project Accepted Decision's parent (project.json) and Project
|
|
2628
|
+
* Guidelines — because the delivery this attests to is lossless and always
|
|
2629
|
+
* contains every requirement in full. This intentionally lets a
|
|
2630
|
+
* project-context read satisfy phase-scoped REQUIREMENTS_READ_REQUIRED
|
|
2631
|
+
* gates: the agent has genuinely seen the requirement, whichever surface
|
|
2632
|
+
* delivered it. `recordContextRead` stamps only the requirement ids it was
|
|
2633
|
+
* given because that call is scoped to one task's linked requirements, not
|
|
2634
|
+
* a full-project delivery.
|
|
2635
|
+
*/
|
|
2636
|
+
async recordProjectContextRead(input) {
|
|
2637
|
+
const sessionId = input.sessionId.trim();
|
|
2638
|
+
if (!sessionId)
|
|
2639
|
+
throw new PlanStoreError("Project-context attestation requires a non-empty sessionId.");
|
|
2640
|
+
const delivery = verifyProjectContextChunks(input.chunks);
|
|
2641
|
+
const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
|
|
2642
|
+
const result = await this.runAsBatch(async () => {
|
|
2643
|
+
const project = await readJson(this.projectPath(), ProjectSchema);
|
|
2644
|
+
const requirements = await readJson(this.requirementsPath(), RequirementsDocumentSchema);
|
|
2645
|
+
const currentSnapshot = createProjectContextSnapshot(project, requirements);
|
|
2646
|
+
const currentFingerprint = projectContextFingerprint(currentSnapshot);
|
|
2647
|
+
if (delivery.evidence.fingerprint !== currentFingerprint) {
|
|
2648
|
+
throw new ProjectContextDeliveryError("PROJECT_CONTEXT_DELIVERY_STALE", "Project context changed after delivery; reload every project-context chunk before attesting the read.");
|
|
2649
|
+
}
|
|
2650
|
+
let nextProject = project;
|
|
2651
|
+
if (project.projectGuidelines.content.trim()) {
|
|
2652
|
+
nextProject = {
|
|
2653
|
+
...project,
|
|
2654
|
+
projectGuidelines: upsertSessionInfo(project.projectGuidelines, sessionId, createdAt).entity,
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
// saveProject bumps touchTimestamp(), strips workDeviations, and merges
|
|
2658
|
+
// projectGuidelines.sessionInfo against the on-disk copy the same way
|
|
2659
|
+
// every other project write does.
|
|
2660
|
+
if (nextProject !== project)
|
|
2661
|
+
await this.saveProject(nextProject);
|
|
2662
|
+
let requirementsChanged = false;
|
|
2663
|
+
const nextRequirements = {
|
|
2664
|
+
requirements: requirements.requirements.map((requirement) => {
|
|
2665
|
+
const updated = upsertSessionInfo(requirement, sessionId, createdAt);
|
|
2666
|
+
requirementsChanged ||= updated.changed;
|
|
2667
|
+
return updated.entity;
|
|
2668
|
+
}),
|
|
2669
|
+
};
|
|
2670
|
+
// saveRequirements applies the stale-write guard and merges sessionInfo
|
|
2671
|
+
// per requirement against the on-disk copy, same as recordContextRead.
|
|
2672
|
+
if (requirementsChanged)
|
|
2673
|
+
await this.saveRequirements(nextRequirements);
|
|
2674
|
+
// The complete-read attestation is the commit marker and is written
|
|
2675
|
+
// last, after both save calls above have durably landed.
|
|
2676
|
+
const previousReadState = await this.loadProjectContextReadStateDocument();
|
|
2677
|
+
const nextReadState = ProjectContextReadStateDocumentSchema.parse({
|
|
2678
|
+
version: PROJECT_CONTEXT_DELIVERY_VERSION,
|
|
2679
|
+
sessionInfo: mergeProjectContextSessionInfo(previousReadState.sessionInfo, [{ sessionId, createdAt, fingerprint: currentFingerprint }]),
|
|
2680
|
+
});
|
|
2681
|
+
await atomicWriteJson(this.projectContextReadsPath(), nextReadState, this.root);
|
|
2682
|
+
return { project: nextProject, requirements: nextRequirements };
|
|
2683
|
+
});
|
|
2684
|
+
return { ...result, delivery, createdAt };
|
|
2685
|
+
}
|
|
2534
2686
|
async recordProjectGuidelinesRead(input) {
|
|
2535
2687
|
const sessionId = input.sessionId.trim();
|
|
2536
2688
|
if (!sessionId)
|
|
@@ -2742,7 +2894,7 @@ export class PlanStore {
|
|
|
2742
2894
|
/** Field-scoped task mutation against the lock-reloaded canonical phase. */
|
|
2743
2895
|
async updateTask(phaseId, taskId, updater, options = {}) {
|
|
2744
2896
|
let persisted;
|
|
2745
|
-
|
|
2897
|
+
await this.updatePhase(phaseId, (currentPhase) => {
|
|
2746
2898
|
const index = currentPhase.tasks.findIndex((task) => task.id === taskId);
|
|
2747
2899
|
if (index < 0)
|
|
2748
2900
|
throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
|
|
@@ -3042,31 +3194,111 @@ export class PlanStore {
|
|
|
3042
3194
|
async getPhaseHandoff(phaseId) {
|
|
3043
3195
|
return (await this.loadPhase(phaseId)).handoff;
|
|
3044
3196
|
}
|
|
3045
|
-
async
|
|
3197
|
+
async loadRetainedHandoffDrafts() {
|
|
3198
|
+
try {
|
|
3199
|
+
return await readJson(this.handoffDraftsPath(), RetainedHandoffDraftSchema.array());
|
|
3200
|
+
}
|
|
3201
|
+
catch {
|
|
3202
|
+
return [];
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
async saveRetainedHandoffDrafts(drafts) {
|
|
3206
|
+
await atomicWriteJson(this.handoffDraftsPath(), drafts, this.root);
|
|
3207
|
+
}
|
|
3208
|
+
isRetainedHandoffDraftFresh(draft) {
|
|
3209
|
+
const retainedAtMs = Date.parse(draft.retainedAt);
|
|
3210
|
+
return Number.isFinite(retainedAtMs) && Date.now() - retainedAtMs < RETAINED_HANDOFF_DRAFT_MAX_AGE_MS;
|
|
3211
|
+
}
|
|
3212
|
+
/** Persist the exact body submitted to refreshPhaseHandoff() against its
|
|
3213
|
+
* phase + prepare token, so a retry can omit `content` and reuse it
|
|
3214
|
+
* instead of resending a capsule up to MAX_HANDOFF_CONTENT_CHARS. Called
|
|
3215
|
+
* from refreshPhaseHandoff() on any write failure except a terminal phase
|
|
3216
|
+
* (nothing to retry there — see discardRetainedHandoffDrafts).
|
|
3217
|
+
*
|
|
3218
|
+
* Worktree-local only: .local/handoff-drafts.json, per the shared-vs-local
|
|
3219
|
+
* boundary (P069(F005)/T298, see localRoot()). A retained body must never
|
|
3220
|
+
* reach shared .planner/ state. It is a plain file, so it survives a
|
|
3221
|
+
* process restart in the same worktree — the failure and the retry can be
|
|
3222
|
+
* separated by a context compaction.
|
|
3223
|
+
*
|
|
3224
|
+
* Bounded so a run of failed writes cannot grow .local/ without limit:
|
|
3225
|
+
* one entry per phase+token (a new failure for the same token replaces
|
|
3226
|
+
* it), entries older than RETAINED_HANDOFF_DRAFT_MAX_AGE_MS are dropped,
|
|
3227
|
+
* and the total is capped at MAX_RETAINED_HANDOFF_DRAFTS (oldest evicted
|
|
3228
|
+
* first). A defensive per-entry size ceiling skips retention entirely for
|
|
3229
|
+
* runaway input rather than writing it to disk. */
|
|
3230
|
+
async retainHandoffDraft(phaseId, token, content, failureReason) {
|
|
3231
|
+
if (content.length > MAX_RETAINED_HANDOFF_DRAFT_CONTENT_CHARS)
|
|
3232
|
+
return;
|
|
3233
|
+
const drafts = (await this.loadRetainedHandoffDrafts())
|
|
3234
|
+
.filter((draft) => this.isRetainedHandoffDraftFresh(draft))
|
|
3235
|
+
.filter((draft) => !(draft.phaseId === phaseId && draft.token === token));
|
|
3236
|
+
drafts.push({ phaseId, token, content, failureReason, retainedAt: nowISO() });
|
|
3237
|
+
drafts.sort((a, b) => Date.parse(a.retainedAt) - Date.parse(b.retainedAt));
|
|
3238
|
+
while (drafts.length > MAX_RETAINED_HANDOFF_DRAFTS)
|
|
3239
|
+
drafts.shift();
|
|
3240
|
+
await this.saveRetainedHandoffDrafts(drafts);
|
|
3241
|
+
}
|
|
3242
|
+
/** Look up the retained draft for one phase + expectedHandoffUpdatedAt
|
|
3243
|
+
* token. An expired entry is treated as absent (and pruned on the next
|
|
3244
|
+
* retainHandoffDraft/discardRetainedHandoffDrafts write). */
|
|
3245
|
+
async getRetainedHandoffDraft(phaseId, token) {
|
|
3246
|
+
const draft = (await this.loadRetainedHandoffDrafts())
|
|
3247
|
+
.find((candidate) => candidate.phaseId === phaseId && candidate.token === token);
|
|
3248
|
+
if (!draft || !this.isRetainedHandoffDraftFresh(draft))
|
|
3249
|
+
return null;
|
|
3250
|
+
return draft;
|
|
3251
|
+
}
|
|
3252
|
+
/** Discard retained draft(s) for a phase. Pass `token` to discard exactly
|
|
3253
|
+
* the entry a successful write consumed; omit it to discard every draft
|
|
3254
|
+
* retained for the phase, regardless of token — used on handoff_clear and
|
|
3255
|
+
* when the phase reaches a terminal status (a terminal phase can never
|
|
3256
|
+
* receive another handoff write, so nothing retained for it is ever
|
|
3257
|
+
* useful again). */
|
|
3258
|
+
async discardRetainedHandoffDrafts(phaseId, token) {
|
|
3259
|
+
const drafts = await this.loadRetainedHandoffDrafts();
|
|
3260
|
+
const next = drafts.filter((draft) => draft.phaseId !== phaseId || (token !== undefined && draft.token !== token));
|
|
3261
|
+
if (next.length !== drafts.length)
|
|
3262
|
+
await this.saveRetainedHandoffDrafts(next);
|
|
3263
|
+
}
|
|
3264
|
+
/** Resolve a `.planner/docs/*.md` path to an absolute on-disk target,
|
|
3265
|
+
* enforcing the same format-and-containment rule everywhere a supporting
|
|
3266
|
+
* document path is trusted: it must look like a Markdown path under
|
|
3267
|
+
* `.planner/docs/` with no `..` segment, and must resolve inside the
|
|
3268
|
+
* docs directory. Shared by validateHandoffSupportingDocuments (strict:
|
|
3269
|
+
* throws on any defect) and readExternalizedHandoffDocument (lenient:
|
|
3270
|
+
* treats any defect, including one it did not have to make itself, as
|
|
3271
|
+
* "unavailable" — a persisted path is never re-trusted blindly). */
|
|
3272
|
+
resolveDocsTarget(rawPath) {
|
|
3273
|
+
const normalizedPath = rawPath.trim().replace(/\\/g, "/");
|
|
3274
|
+
if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
|
|
3275
|
+
throw supportingDocumentInvalidError(`Supporting document path must be a Markdown file under .planner/docs/: ${rawPath}`, { path: rawPath });
|
|
3276
|
+
}
|
|
3046
3277
|
const docsRoot = resolve(this.root, "docs");
|
|
3278
|
+
const target = resolve(this.root, normalizedPath.slice(".planner/".length));
|
|
3279
|
+
if (!target.startsWith(`${docsRoot}${sep}`)) {
|
|
3280
|
+
throw supportingDocumentInvalidError(`Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
|
|
3281
|
+
}
|
|
3282
|
+
return target;
|
|
3283
|
+
}
|
|
3284
|
+
async validateHandoffSupportingDocuments(documents) {
|
|
3047
3285
|
const metadata = [];
|
|
3048
3286
|
const contents = [];
|
|
3049
3287
|
const seen = new Set();
|
|
3050
3288
|
for (const document of documents) {
|
|
3051
3289
|
const normalizedPath = document.path.trim().replace(/\\/g, "/");
|
|
3052
|
-
if (!/^\.planner\/docs\/.+\.md$/i.test(normalizedPath) || normalizedPath.includes("/../")) {
|
|
3053
|
-
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document path must be a Markdown file under .planner/docs/: ${document.path}`, { path: document.path });
|
|
3054
|
-
}
|
|
3055
3290
|
if (seen.has(normalizedPath)) {
|
|
3056
|
-
throw
|
|
3291
|
+
throw supportingDocumentInvalidError(`Supporting document appears more than once: ${normalizedPath}`, { path: normalizedPath });
|
|
3057
3292
|
}
|
|
3058
3293
|
seen.add(normalizedPath);
|
|
3059
|
-
const target =
|
|
3060
|
-
if (!target.startsWith(`${docsRoot}${sep}`)) {
|
|
3061
|
-
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Supporting document escapes .planner/docs/: ${normalizedPath}`, { path: normalizedPath });
|
|
3062
|
-
}
|
|
3294
|
+
const target = this.resolveDocsTarget(document.path);
|
|
3063
3295
|
const fileStat = await lstat(target).catch(() => null);
|
|
3064
3296
|
if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
|
|
3065
|
-
throw
|
|
3297
|
+
throw supportingDocumentInvalidError(`Supporting document must be a regular file under .planner/docs/ (symlinks rejected): ${normalizedPath}`, { path: normalizedPath });
|
|
3066
3298
|
}
|
|
3067
3299
|
const content = await readFile(target, "utf8").catch(() => null);
|
|
3068
3300
|
if (content === null || !content.trim()) {
|
|
3069
|
-
throw
|
|
3301
|
+
throw supportingDocumentInvalidError(`Supporting document is missing or empty: ${normalizedPath}`, { path: normalizedPath });
|
|
3070
3302
|
}
|
|
3071
3303
|
metadata.push({
|
|
3072
3304
|
path: normalizedPath,
|
|
@@ -3078,98 +3310,228 @@ export class PlanStore {
|
|
|
3078
3310
|
}
|
|
3079
3311
|
return { metadata, contents };
|
|
3080
3312
|
}
|
|
3081
|
-
/**
|
|
3082
|
-
|
|
3313
|
+
/** Read back one document a previous handoff refresh auto-externalized,
|
|
3314
|
+
* for handoff_prepare to surface (see PhaseHandoffAudit.externalizedHandoffDocuments).
|
|
3315
|
+
* Unlike validateHandoffSupportingDocuments this never throws: a stale
|
|
3316
|
+
* path, a deleted file, a symlink, or any other defect is reported to the
|
|
3317
|
+
* caller as "missing" rather than failing the whole prepare call — the
|
|
3318
|
+
* document being gone is itself one of the edge cases this exists to
|
|
3319
|
+
* surface, not a reason to hide that it was ever there. */
|
|
3320
|
+
async readExternalizedHandoffDocument(rawPath) {
|
|
3321
|
+
try {
|
|
3322
|
+
const target = this.resolveDocsTarget(rawPath);
|
|
3323
|
+
const fileStat = await lstat(target).catch(() => null);
|
|
3324
|
+
if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile())
|
|
3325
|
+
return null;
|
|
3326
|
+
const content = await readFile(target, "utf8").catch(() => null);
|
|
3327
|
+
return content && content.trim() ? content : null;
|
|
3328
|
+
}
|
|
3329
|
+
catch {
|
|
3330
|
+
return null;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/** The full prior capsule's `##` headings for validateHandoffSectionReconciliation
|
|
3334
|
+
* (P104(F005)/T416): the live phase's own compact-capsule headings, plus —
|
|
3335
|
+
* when there is a live handoff to reconcile against — the headings of
|
|
3336
|
+
* every document its own auto-externalization moved out, read back fresh
|
|
3337
|
+
* here (never the elided copy, matching PhaseHandoffAudit.priorSectionHeadings
|
|
3338
|
+
* as preparePhaseHandoff derives it). Empty when phase.handoff is "": a
|
|
3339
|
+
* first handoff has nothing prior, and a handoff already archived by
|
|
3340
|
+
* handoff_clear has nothing live left to reconcile even though stale
|
|
3341
|
+
* handoffAudit.supportingDocuments may still name an old externalized
|
|
3342
|
+
* file — this is what makes an archived-then-superseded write behave like
|
|
3343
|
+
* a first write instead of demanding dispositions for content that is
|
|
3344
|
+
* already gone. */
|
|
3345
|
+
async priorHandoffSectionHeadings(phase) {
|
|
3346
|
+
if (!phase.handoff.trim())
|
|
3347
|
+
return [];
|
|
3348
|
+
const headings = extractHandoffSectionHeadings(phase.handoff);
|
|
3349
|
+
const autoExternalizedDocuments = (phase.handoffAudit?.supportingDocuments ?? []).filter(isAutoExternalizedHandoffDocument);
|
|
3350
|
+
for (const document of autoExternalizedDocuments) {
|
|
3351
|
+
const content = await this.readExternalizedHandoffDocument(document.path);
|
|
3352
|
+
if (content)
|
|
3353
|
+
headings.push(...extractHandoffSectionHeadings(content));
|
|
3354
|
+
}
|
|
3355
|
+
return [...new Set(headings)];
|
|
3356
|
+
}
|
|
3357
|
+
/** Audit one exact phase before preparing a handoff refresh. When a
|
|
3358
|
+
* supporting-document manifest is already known (it is optional — most
|
|
3359
|
+
* handoffs need none), validate it here so every defect that does not
|
|
3360
|
+
* depend on the drafted body (bad path, missing file, symlink, duplicate,
|
|
3361
|
+
* empty file) fails before a token is issued and before any content is
|
|
3362
|
+
* drafted or transmitted. The same validation runs again at write time
|
|
3363
|
+
* (refreshPhaseHandoff), which stays the authority: it also catches a
|
|
3364
|
+
* document that was valid here but got deleted before the write, and the
|
|
3365
|
+
* one check that genuinely needs the drafted body (the content must link
|
|
3366
|
+
* each document's path). */
|
|
3367
|
+
async preparePhaseHandoff(phaseId, supportingDocuments) {
|
|
3083
3368
|
const phase = await this.loadPhase(phaseId);
|
|
3084
3369
|
if (!phase.featureId)
|
|
3085
3370
|
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
3086
3371
|
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === phase.featureId);
|
|
3087
3372
|
if (!feature)
|
|
3088
3373
|
throw new PlanStoreError(`Parent feature ${phase.featureId} not found for phase ${phaseId}.`);
|
|
3089
|
-
|
|
3374
|
+
if (supportingDocuments && supportingDocuments.length > 0) {
|
|
3375
|
+
await this.validateHandoffSupportingDocuments(supportingDocuments);
|
|
3376
|
+
}
|
|
3377
|
+
const audit = auditPhaseHandoff(phase, feature);
|
|
3378
|
+
// The active handoff's own auto-externalized documents are the elided
|
|
3379
|
+
// content this task exists to surface (see HANDOFF_AUTO_EXTERNALIZED_DOCUMENT_DESCRIPTION).
|
|
3380
|
+
// A document the agent linked deliberately has a different description
|
|
3381
|
+
// and is already named in the persisted capsule's own text; only the
|
|
3382
|
+
// tooling's own silent moves are read back here. Skipped entirely (no
|
|
3383
|
+
// I/O) when there is no prior handoff or nothing was ever externalized
|
|
3384
|
+
// from it — the common case.
|
|
3385
|
+
const autoExternalizedDocuments = (phase.handoffAudit?.supportingDocuments ?? [])
|
|
3386
|
+
.filter(isAutoExternalizedHandoffDocument);
|
|
3387
|
+
if (autoExternalizedDocuments.length === 0)
|
|
3388
|
+
return audit;
|
|
3389
|
+
const externalizedHandoffDocuments = await Promise.all(autoExternalizedDocuments.map(async (document) => {
|
|
3390
|
+
const content = await this.readExternalizedHandoffDocument(document.path);
|
|
3391
|
+
return {
|
|
3392
|
+
path: document.path,
|
|
3393
|
+
description: document.description,
|
|
3394
|
+
headings: content ? extractHandoffSectionHeadings(content) : [],
|
|
3395
|
+
content,
|
|
3396
|
+
contentLength: content?.length ?? document.contentLength,
|
|
3397
|
+
missing: content === null,
|
|
3398
|
+
};
|
|
3399
|
+
}));
|
|
3400
|
+
// A handoff already archived (handoff_clear) has phase.handoff === "" but
|
|
3401
|
+
// may still carry stale handoffAudit.supportingDocuments referencing its
|
|
3402
|
+
// old externalized file(s) — nothing left to reconcile against once
|
|
3403
|
+
// archived, so priorSectionHeadings stays whatever auditPhaseHandoff
|
|
3404
|
+
// already derived from the (empty) compact capsule: [].
|
|
3405
|
+
const priorSectionHeadings = phase.handoff.trim()
|
|
3406
|
+
? [...new Set([...audit.priorSectionHeadings, ...externalizedHandoffDocuments.flatMap((document) => document.headings)])]
|
|
3407
|
+
: audit.priorSectionHeadings;
|
|
3408
|
+
return { ...audit, externalizedHandoffDocuments, priorSectionHeadings };
|
|
3090
3409
|
}
|
|
3091
3410
|
/** Refresh the single active handoff and synchronize durable task/phase/feature
|
|
3092
3411
|
* context. Unlike setPhaseHandoff(), this deliberately does not archive the
|
|
3093
3412
|
* previous active body: callers must reconcile it using the optimistic
|
|
3094
|
-
* handoffUpdatedAt token returned by preparePhaseHandoff().
|
|
3413
|
+
* handoffUpdatedAt token returned by preparePhaseHandoff().
|
|
3414
|
+
*
|
|
3415
|
+
* `content` is optional. When omitted, the body submitted on the last
|
|
3416
|
+
* failed write against this exact phase + expectedHandoffUpdatedAt token
|
|
3417
|
+
* is reused (throws HANDOFF_RETAINED_CONTENT_NOT_FOUND when none is
|
|
3418
|
+
* retained). This is what makes a retry after any late write failure —
|
|
3419
|
+
* stale token, unresolved placeholders, a deleted supporting document, a
|
|
3420
|
+
* persisted read-back hash mismatch, or anything else — cheap instead of
|
|
3421
|
+
* resending a capsule up to MAX_HANDOFF_CONTENT_CHARS: the body is sent
|
|
3422
|
+
* once, and every other field on a retry is already small. On any failure
|
|
3423
|
+
* below (other than the terminal-phase check, which discards instead —
|
|
3424
|
+
* a terminal phase can never accept a retry) the content actually used is
|
|
3425
|
+
* retained again for the next attempt; on success the entry for this
|
|
3426
|
+
* token is discarded. */
|
|
3095
3427
|
async refreshPhaseHandoff(phaseId, input) {
|
|
3096
3428
|
return this.runAsBatch(async () => {
|
|
3097
3429
|
const originalPhase = await this.loadPhase(phaseId);
|
|
3098
3430
|
if (this.terminalHandoffReason(originalPhase)) {
|
|
3431
|
+
await this.discardRetainedHandoffDrafts(phaseId);
|
|
3099
3432
|
throw new PlanStoreError(`Cannot write a handoff on ${originalPhase.status} phase ${phaseId}; terminal phases have no pending handoff.`);
|
|
3100
3433
|
}
|
|
3101
3434
|
if (!originalPhase.featureId)
|
|
3102
3435
|
throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
|
|
3103
|
-
const
|
|
3104
|
-
|
|
3105
|
-
if (
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
|
|
3114
|
-
const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
|
|
3115
|
-
if (externalized.externalized) {
|
|
3116
|
-
const docsDir = resolve(this.root, "docs");
|
|
3117
|
-
await mkdir(docsDir, { recursive: true });
|
|
3118
|
-
const target = resolve(this.root, autoPath.slice(".planner/".length));
|
|
3119
|
-
if (!target.startsWith(`${docsDir}${sep}`)) {
|
|
3120
|
-
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
|
|
3121
|
-
}
|
|
3122
|
-
await writeFile(target, externalized.extendedContent, "utf8");
|
|
3123
|
-
const autoDoc = externalized.supportingDocument;
|
|
3124
|
-
effectiveInput = {
|
|
3125
|
-
...input,
|
|
3126
|
-
content: externalized.content,
|
|
3127
|
-
supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
|
|
3128
|
-
};
|
|
3129
|
-
}
|
|
3436
|
+
const submittedContent = input.content?.trim() ? input.content : undefined;
|
|
3437
|
+
let content;
|
|
3438
|
+
if (submittedContent !== undefined) {
|
|
3439
|
+
content = submittedContent;
|
|
3440
|
+
}
|
|
3441
|
+
else {
|
|
3442
|
+
const retained = await this.getRetainedHandoffDraft(phaseId, input.expectedHandoffUpdatedAt);
|
|
3443
|
+
if (!retained)
|
|
3444
|
+
throw retainedHandoffContentNotFoundError(phaseId, input.expectedHandoffUpdatedAt);
|
|
3445
|
+
content = retained.content;
|
|
3130
3446
|
}
|
|
3131
|
-
const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
|
|
3132
|
-
const verifiedInput = {
|
|
3133
|
-
...effectiveInput,
|
|
3134
|
-
verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
|
|
3135
|
-
verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
|
|
3136
|
-
};
|
|
3137
|
-
const originalFeature = originalFeatures.features[featureIndex];
|
|
3138
|
-
const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
|
|
3139
3447
|
try {
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
const
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3148
|
-
if (
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3448
|
+
const originalFeatures = await this.loadFeatures();
|
|
3449
|
+
const featureIndex = originalFeatures.features.findIndex((candidate) => candidate.id === originalPhase.featureId);
|
|
3450
|
+
if (featureIndex < 0)
|
|
3451
|
+
throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
|
|
3452
|
+
const timestamp = nowISO();
|
|
3453
|
+
const existingCreatedAt = originalPhase.handoff.match(/^Created at:\s*(\S+)\s*$/im)?.[1] ?? timestamp;
|
|
3454
|
+
const materializedContent = materializeHandoffMetadata(content, input.reason, existingCreatedAt, timestamp);
|
|
3455
|
+
let effectiveInput = { ...input, content: materializedContent };
|
|
3456
|
+
if (materializedContent.length > TARGET_HANDOFF_CONTENT_CHARS) {
|
|
3457
|
+
const stamp = timestamp.replace(/[:.]/g, "-");
|
|
3458
|
+
const autoPath = `.planner/docs/handoff-p${String(originalPhase.number).padStart(3, "0")}-${stamp}.md`;
|
|
3459
|
+
const externalized = externalizeOversizedHandoffContent(materializedContent, autoPath);
|
|
3460
|
+
if (externalized.externalized) {
|
|
3461
|
+
const docsDir = resolve(this.root, "docs");
|
|
3462
|
+
await mkdir(docsDir, { recursive: true });
|
|
3463
|
+
const target = resolve(this.root, autoPath.slice(".planner/".length));
|
|
3464
|
+
if (!target.startsWith(`${docsDir}${sep}`)) {
|
|
3465
|
+
throw new HandoffContractError("HANDOFF_SUPPORTING_DOCUMENT_INVALID", `Auto-externalized supporting document escapes .planner/docs/: ${autoPath}`, { path: autoPath });
|
|
3466
|
+
}
|
|
3467
|
+
await writeFile(target, externalized.extendedContent, "utf8");
|
|
3468
|
+
const autoDoc = externalized.supportingDocument;
|
|
3469
|
+
effectiveInput = {
|
|
3470
|
+
...input,
|
|
3471
|
+
content: externalized.content,
|
|
3472
|
+
// The agent linked its documents in the body it submitted; this
|
|
3473
|
+
// rewrite may have moved those links into the external file. Carry
|
|
3474
|
+
// the original so validation judges the author's own linking.
|
|
3475
|
+
submittedContent: materializedContent,
|
|
3476
|
+
supportingDocuments: [...(input.supportingDocuments ?? []), autoDoc],
|
|
3477
|
+
};
|
|
3478
|
+
}
|
|
3153
3479
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3480
|
+
const verifiedSupportingDocuments = await this.validateHandoffSupportingDocuments(effectiveInput.supportingDocuments ?? []);
|
|
3481
|
+
// Derived from the live originalPhase, not a copy captured at
|
|
3482
|
+
// prepare time — see priorHandoffSectionHeadings for why that is
|
|
3483
|
+
// exactly what "the prior capsule the author was shown" means even
|
|
3484
|
+
// across an intervening handoff_clear.
|
|
3485
|
+
const priorSectionHeadings = await this.priorHandoffSectionHeadings(originalPhase);
|
|
3486
|
+
const verifiedInput = {
|
|
3487
|
+
...effectiveInput,
|
|
3488
|
+
verifiedSupportingDocuments: verifiedSupportingDocuments.metadata,
|
|
3489
|
+
verifiedSupportingDocumentContents: verifiedSupportingDocuments.contents,
|
|
3490
|
+
priorSectionHeadings,
|
|
3160
3491
|
};
|
|
3492
|
+
const originalFeature = originalFeatures.features[featureIndex];
|
|
3493
|
+
const applied = applyHandoffContextSync(originalPhase, originalFeature, verifiedInput, timestamp);
|
|
3494
|
+
try {
|
|
3495
|
+
// Keep the transaction granular: only the parent feature and target phase
|
|
3496
|
+
// belong to this handoff refresh. Rewriting the whole feature document can
|
|
3497
|
+
// disturb unrelated feature containment metadata.
|
|
3498
|
+
await this.saveFeature(applied.feature);
|
|
3499
|
+
await this.savePhase(applied.phase);
|
|
3500
|
+
const phase = await this.loadPhase(phaseId);
|
|
3501
|
+
const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === originalPhase.featureId);
|
|
3502
|
+
const persistedHash = handoffContentHash(phase.handoff);
|
|
3503
|
+
if (phase.handoff !== applied.phase.handoff
|
|
3504
|
+
|| !phase.handoffAudit
|
|
3505
|
+
|| phase.handoffAudit.contentHash !== persistedHash
|
|
3506
|
+
|| phase.handoffAudit.contentLength !== phase.handoff.length) {
|
|
3507
|
+
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 });
|
|
3508
|
+
}
|
|
3509
|
+
await this.discardRetainedHandoffDrafts(phaseId, input.expectedHandoffUpdatedAt);
|
|
3510
|
+
return {
|
|
3511
|
+
phase,
|
|
3512
|
+
feature,
|
|
3513
|
+
updatedTaskIds: applied.updatedTaskIds,
|
|
3514
|
+
handoffUpdatedAt: phase.handoffUpdatedAt,
|
|
3515
|
+
handoffAudit: phase.handoffAudit,
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
catch (error) {
|
|
3519
|
+
const rollbackErrors = [];
|
|
3520
|
+
// Restore the exact parsed snapshots directly. Going through saveFeature /
|
|
3521
|
+
// savePhase would restamp description freshness against the failed write.
|
|
3522
|
+
await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
|
|
3523
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
3524
|
+
await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
|
|
3525
|
+
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
3526
|
+
if (rollbackErrors.length > 0) {
|
|
3527
|
+
throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
|
|
3528
|
+
}
|
|
3529
|
+
throw error;
|
|
3530
|
+
}
|
|
3161
3531
|
}
|
|
3162
3532
|
catch (error) {
|
|
3163
|
-
const
|
|
3164
|
-
|
|
3165
|
-
// savePhase would restamp description freshness against the failed write.
|
|
3166
|
-
await atomicWriteJson(this.featurePath(originalFeature.id), FeatureSchema.parse(originalFeature), this.root)
|
|
3167
|
-
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
3168
|
-
await atomicWriteJson(this.phasePath(originalPhase.id), PhaseSchema.parse(originalPhase), this.root)
|
|
3169
|
-
.catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
3170
|
-
if (rollbackErrors.length > 0) {
|
|
3171
|
-
throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
|
|
3172
|
-
}
|
|
3533
|
+
const failureReason = error instanceof Error ? error.message : String(error);
|
|
3534
|
+
await this.retainHandoffDraft(phaseId, input.expectedHandoffUpdatedAt, content, failureReason);
|
|
3173
3535
|
throw error;
|
|
3174
3536
|
}
|
|
3175
3537
|
});
|
|
@@ -3261,6 +3623,9 @@ export class PlanStore {
|
|
|
3261
3623
|
* "manual" | "superseded" | "imported". */
|
|
3262
3624
|
async clearPhaseHandoff(phaseId, reason = "manual") {
|
|
3263
3625
|
await this.migrateLegacyHandoffArchive();
|
|
3626
|
+
// Any draft retained against a failed write for this phase is now moot,
|
|
3627
|
+
// whether or not there was an active handoff body to archive below.
|
|
3628
|
+
await this.discardRetainedHandoffDrafts(phaseId);
|
|
3264
3629
|
const phase = await this.loadPhase(phaseId).catch(() => null);
|
|
3265
3630
|
if (!phase || phase.handoff === "")
|
|
3266
3631
|
return; // nothing to archive
|
|
@@ -3291,7 +3656,14 @@ export class PlanStore {
|
|
|
3291
3656
|
let archived = 0;
|
|
3292
3657
|
for (const phase of phases) {
|
|
3293
3658
|
const reason = this.terminalHandoffReason(phase);
|
|
3294
|
-
if (reason
|
|
3659
|
+
if (!reason)
|
|
3660
|
+
continue;
|
|
3661
|
+
// clearPhaseHandoff() already discards retained drafts, but only runs
|
|
3662
|
+
// below when there is a body to archive; a terminal phase that never
|
|
3663
|
+
// had a successful write (failed attempts only) still needs its
|
|
3664
|
+
// retained draft swept here.
|
|
3665
|
+
await this.discardRetainedHandoffDrafts(phase.id);
|
|
3666
|
+
if (phase.handoff) {
|
|
3295
3667
|
await this.clearPhaseHandoff(phase.id, reason);
|
|
3296
3668
|
archived += 1;
|
|
3297
3669
|
}
|