@agent-plan/core 0.2.22 → 0.2.23-next.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.
@@ -14,12 +14,26 @@ 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, } from "./schema.js";
17
+ import { CodebaseProfileSchema, FeatureSchema, FeaturesDocumentSchema, ManifestSchema, PhaseSchema, TaskPauseSnapshotSchema, ProjectSchema, RequirementsDocumentSchema, ResumeFocusSchema, ActivityLogSchema, TimestampSchema, WorkDeviationSchema, } from "./schema.js";
18
18
  import { createFeatureId, createPhaseId, createRequirementId, createShortId, createStatusLogEntryId, createTaskId, formatPhaseRef, isLegacyPhaseId } from "./naming.js";
19
19
  import { deriveParentDisplay, fromCanonicalStatus } from "./display-status.js";
20
+ import { loadExtensionRules, PLANNER_EXTENSION_RULES } from "./planner-rules.js";
21
+ import { applyHandoffContextSync, auditPhaseHandoff, } from "./handoff-context.js";
20
22
  function nowISO() {
21
23
  return new Date().toISOString();
22
24
  }
25
+ const MAX_SESSION_INFO_ENTRIES = 16;
26
+ function upsertSessionInfo(entity, sessionId, createdAt) {
27
+ const nextInfo = [
28
+ ...entity.sessionInfo.filter((entry) => entry.sessionId !== sessionId),
29
+ { sessionId, createdAt },
30
+ ]
31
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
32
+ .slice(0, MAX_SESSION_INFO_ENTRIES);
33
+ const changed = nextInfo.length !== entity.sessionInfo.length
34
+ || nextInfo.some((entry, index) => entry.sessionId !== entity.sessionInfo[index]?.sessionId || entry.createdAt !== entity.sessionInfo[index]?.createdAt);
35
+ return changed ? { entity: { ...entity, sessionInfo: nextInfo }, changed: true } : { entity, changed: false };
36
+ }
23
37
  function resolveStoredFeatureId(features, ref) {
24
38
  const raw = ref?.trim();
25
39
  if (!raw)
@@ -417,6 +431,41 @@ export async function migrateToGlobalSequence(store) {
417
431
  return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
418
432
  });
419
433
  }
434
+ /** Map legacy/removed status strings to their canonical replacements before
435
+ * schema validation. Canonical task status "paused" was removed from the
436
+ * domain; persisted entities (tasks, statusLog entries) may still carry it.
437
+ * Rewriting on read keeps legacy data loadable without a one-time migration
438
+ * pass, and the normalized value is persisted on the next save. Scoped to
439
+ * status-bearing fields so unrelated string values are never touched. */
440
+ function migrateLegacyStatuses(value) {
441
+ if (Array.isArray(value))
442
+ return value.map(migrateLegacyStatuses);
443
+ if (value && typeof value === "object") {
444
+ const out = {};
445
+ for (const [key, child] of Object.entries(value)) {
446
+ if ((key === "status" || key === "fromStatus" || key === "toStatus") && child === "paused") {
447
+ out[key] = "planned";
448
+ }
449
+ else {
450
+ out[key] = migrateLegacyStatuses(child);
451
+ }
452
+ }
453
+ return out;
454
+ }
455
+ return value;
456
+ }
457
+ /** Walk up from `path` to find the owning `.planner` root, so crash-recovery
458
+ * can locate `.local/backups/<rel>.bak` without every readJson caller threading
459
+ * `root`. Returns undefined if no `.planner` ancestor exists. */
460
+ function findPlannerRoot(path) {
461
+ let dir = dirname(path);
462
+ while (dir !== dirname(dir)) {
463
+ if (basename(dir) === ".planner")
464
+ return dir;
465
+ dir = dirname(dir);
466
+ }
467
+ return undefined;
468
+ }
420
469
  async function readJson(path, schema) {
421
470
  let backupTried = false;
422
471
  let backupFailed = false;
@@ -424,20 +473,36 @@ async function readJson(path, schema) {
424
473
  try {
425
474
  const raw = await readFile(path, "utf-8");
426
475
  rawPreview = raw.slice(0, 240);
427
- return schema.parse(JSON.parse(raw));
476
+ return schema.parse(migrateLegacyStatuses(JSON.parse(raw)));
428
477
  }
429
478
  catch (cause) {
430
- // Try the .bak backup before giving up (recover from external-write corruption).
479
+ // Recover from the previous-version backup before giving up:
480
+ // 1) legacy inline `<file>.bak` (next to the source), then
481
+ // 2) the relocated `.planner/.local/backups/<rel>.bak` (P043).
431
482
  backupTried = true;
432
- try {
433
- const bak = await readFile(`${path}.bak`, "utf-8");
434
- rawPreview = bak.slice(0, 240);
435
- return schema.parse(JSON.parse(bak));
436
- }
437
- catch {
438
- backupFailed = true;
439
- // fall through to original error
440
- }
483
+ const tryBackup = async (bakPath) => {
484
+ try {
485
+ const bak = await readFile(bakPath, "utf-8");
486
+ rawPreview = bak.slice(0, 240);
487
+ return schema.parse(migrateLegacyStatuses(JSON.parse(bak)));
488
+ }
489
+ catch {
490
+ return undefined;
491
+ }
492
+ };
493
+ const inline = await tryBackup(`${path}.bak`);
494
+ if (inline !== undefined)
495
+ return inline;
496
+ let local;
497
+ const plannerRoot = findPlannerRoot(path);
498
+ if (plannerRoot) {
499
+ const rel = path.slice(plannerRoot.length).replace(/^\//, "");
500
+ local = await tryBackup(join(plannerRoot, ".local", "backups", rel + ".bak"));
501
+ }
502
+ if (local !== undefined)
503
+ return local;
504
+ backupFailed = true;
505
+ // fall through to original error
441
506
  const details = {
442
507
  path,
443
508
  operation: "readJson",
@@ -518,11 +583,43 @@ export class PlanStore {
518
583
  normalizeTasks(tasks) {
519
584
  // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
520
585
  // Do NOT renumber here — renumbering would break references after deletions.
521
- const normalized = tasks.map((task) => task.description && !task.descriptionUpdatedAt
522
- ? { ...task, descriptionUpdatedAt: task.createdAt }
523
- : task);
586
+ const normalized = tasks.map((task) => {
587
+ const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
588
+ ? task.createdAt
589
+ : task.descriptionUpdatedAt;
590
+ const normalizedStatus = task.status === "paused" ? "planned" : task.status;
591
+ const pauseSnapshot = this.isTerminalTaskStatus(normalizedStatus) ? null : task.pauseSnapshot;
592
+ const statusLog = (task.statusLog ?? []).map((entry) => ({
593
+ ...entry,
594
+ fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
595
+ toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
596
+ }));
597
+ if (descriptionUpdatedAt !== task.descriptionUpdatedAt
598
+ || normalizedStatus !== task.status
599
+ || pauseSnapshot !== task.pauseSnapshot
600
+ || statusLog.some((entry, index) => entry !== task.statusLog[index])) {
601
+ return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
602
+ }
603
+ return task;
604
+ });
524
605
  return { tasks: normalized, changed: normalized.some((task, index) => task !== tasks[index]) };
525
606
  }
607
+ isTerminalTaskStatus(status) {
608
+ return status === "done" || status === "canceled" || status === "rejected";
609
+ }
610
+ async pruneObsoleteWorkDeviations() {
611
+ const phases = await this.loadAllPhases();
612
+ const taskStatusById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task.status])));
613
+ const current = await this.loadWorkDeviations();
614
+ const next = current.filter((deviation) => {
615
+ const resumeStatus = taskStatusById.get(deviation.resumeTaskId);
616
+ return Boolean(resumeStatus) && !this.isTerminalTaskStatus(resumeStatus);
617
+ });
618
+ if (next.length === current.length)
619
+ return;
620
+ await this.saveWorkDeviations(next);
621
+ await this.refreshResume();
622
+ }
526
623
  /** Stamp description edits independently from generic entity mutations.
527
624
  * Legacy entities cannot reveal their historical description-edit time, so
528
625
  * their creation time is the earliest truthful fallback. */
@@ -734,8 +831,28 @@ export class PlanStore {
734
831
  return join(this.localRoot(), "activity.json");
735
832
  }
736
833
  localRoot() {
834
+ // Worktree-local, git-ignored runtime state. The shared-vs-local boundary
835
+ // and the invariants every harness relies on are documented in
836
+ // packages/plan-core/docs/runtime-boundaries.md.
737
837
  return join(this.root, ".local");
738
838
  }
839
+ deviationsPath() {
840
+ return join(this.localRoot(), "deviations.json");
841
+ }
842
+ /** Runtime work deviations, persisted in worktree-local `.local/deviations.json`
843
+ * (never in shared `project.json`). Falls back to `[]` on a missing/corrupt file. */
844
+ async loadWorkDeviations() {
845
+ try {
846
+ return await readJson(this.deviationsPath(), WorkDeviationSchema.array());
847
+ }
848
+ catch {
849
+ return [];
850
+ }
851
+ }
852
+ async saveWorkDeviations(deviations) {
853
+ await atomicWriteJson(this.deviationsPath(), deviations, this.root);
854
+ await this.maybeAutoSync();
855
+ }
739
856
  timestampPath() {
740
857
  return join(this.localRoot(), "timestamp.json");
741
858
  }
@@ -822,6 +939,9 @@ export class PlanStore {
822
939
  };
823
940
  await atomicWriteJson(this.manifestPath(), manifest, this.root);
824
941
  await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
942
+ // Seed the worktree-local runtime-deviation store (T299). Empty for new
943
+ // projects; migrateWorkDeviations (loadProject) backfills legacy data.
944
+ await atomicWriteJson(this.deviationsPath(), [], this.root);
825
945
  await this.saveProject({
826
946
  name: projectName,
827
947
  goal: "",
@@ -860,6 +980,11 @@ export class PlanStore {
860
980
  guardBypassUntil: "",
861
981
  });
862
982
  await this.writeGenerated();
983
+ // Write the STATIC planner extension rules. Content is fixed (no timestamps,
984
+ // no dynamic data) so the file is identical across worktrees/branches and
985
+ // never causes git conflicts. The canonical source is plan-core; this file
986
+ // is the per-project copy / override point, loaded at planner startup.
987
+ await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
863
988
  // Write a README stub
864
989
  const readme = [
865
990
  "# Project Plan",
@@ -884,6 +1009,15 @@ export class PlanStore {
884
1009
  // - generated/: auto-regenerated markdown views (derived from JSON; churn)
885
1010
  await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
886
1011
  }
1012
+ /**
1013
+ * Planner extension rules — the agent-behavior contract for every project
1014
+ * using the extension. Loaded from the static .planner/rules.json if present,
1015
+ * otherwise the canonical code set. Static (no timestamps), safe across
1016
+ * worktrees/branches.
1017
+ */
1018
+ async extensionRules() {
1019
+ return loadExtensionRules(this.root);
1020
+ }
887
1021
  /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
888
1022
  * canonical transient/derived patterns). Projects initialized before the
889
1023
  * `.local/` move either have no `.planner/.gitignore` or one with stale
@@ -924,7 +1058,19 @@ export class PlanStore {
924
1058
  return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
925
1059
  }
926
1060
  async loadProject() {
927
- return readJson(this.projectPath(), ProjectSchema);
1061
+ const project = await readJson(this.projectPath(), ProjectSchema);
1062
+ // One-time migration (T299): move runtime workDeviations from the shared
1063
+ // project.json into worktree-local .local/deviations.json, then clear them
1064
+ // in project.json so runtime state never churns shared metadata.
1065
+ // Idempotent: a non-empty .local file is never overwritten.
1066
+ if (project.workDeviations.length > 0 && (await this.loadWorkDeviations()).length === 0) {
1067
+ await this.saveWorkDeviations(project.workDeviations);
1068
+ await atomicUpdateJson(this.projectPath(), ProjectSchema, (p) => ({ ...p, workDeviations: [] }), this.root);
1069
+ }
1070
+ // Read-view merge: readers keep using `project.workDeviations`, but the
1071
+ // values now come from worktree-local storage.
1072
+ const deviations = await this.loadWorkDeviations();
1073
+ return { ...project, workDeviations: deviations };
928
1074
  }
929
1075
  /**
930
1076
  * Reserve immutable human identifiers without touching tracked `project.json`.
@@ -1144,12 +1290,13 @@ export class PlanStore {
1144
1290
  async refreshResume(notes, lastSessionSummary) {
1145
1291
  const workspace = await this.loadAll();
1146
1292
  const inProgressPhases = workspace.phases.filter((p) => p.status === "in-progress");
1293
+ const activePhase = workspace.phases.find((phase) => phase.tasks.some((task) => task.status === "in-progress"));
1147
1294
  const inProgressTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "in-progress"));
1148
1295
  const blockedTasks = workspace.phases.flatMap((p) => p.tasks.filter((t) => t.status === "blocked"));
1149
1296
  const existing = await this.loadResume();
1150
1297
  const resume = {
1151
1298
  updatedAt: nowISO(),
1152
- currentPhaseId: inProgressPhases[0]?.id ?? existing?.currentPhaseId ?? "",
1299
+ currentPhaseId: activePhase?.id ?? (existing?.currentPhaseId || inProgressPhases[0]?.id || ""),
1153
1300
  inProgressTaskIds: inProgressTasks.map((t) => t.id),
1154
1301
  nextSteps: existing?.nextSteps ?? [],
1155
1302
  nextStepsUpdatedAt: existing?.nextStepsUpdatedAt ?? "",
@@ -1362,6 +1509,45 @@ export class PlanStore {
1362
1509
  catch { /* ignore */ }
1363
1510
  }
1364
1511
  }
1512
+ const backupsRoot = join(this.root, ".local", "backups");
1513
+ const walkBackups = async (dir) => {
1514
+ let entries = [];
1515
+ try {
1516
+ entries = await readdir(dir);
1517
+ }
1518
+ catch {
1519
+ return;
1520
+ }
1521
+ for (const name of entries) {
1522
+ const full = join(dir, name);
1523
+ let st;
1524
+ try {
1525
+ st = await stat(full);
1526
+ }
1527
+ catch {
1528
+ continue;
1529
+ }
1530
+ if (st.isDirectory()) {
1531
+ await walkBackups(full);
1532
+ continue;
1533
+ }
1534
+ if (!name.endsWith(".json.bak"))
1535
+ continue;
1536
+ const rel = full.slice(backupsRoot.length).replace(/^\//, "");
1537
+ const mainPath = join(this.root, rel.slice(0, -".bak".length));
1538
+ try {
1539
+ await stat(mainPath);
1540
+ }
1541
+ catch {
1542
+ try {
1543
+ await unlink(full);
1544
+ removed += 1;
1545
+ }
1546
+ catch { /* ignore */ }
1547
+ }
1548
+ }
1549
+ };
1550
+ await walkBackups(backupsRoot);
1365
1551
  }
1366
1552
  catch { /* best-effort */ }
1367
1553
  return { removed };
@@ -1592,18 +1778,13 @@ export class PlanStore {
1592
1778
  return "done";
1593
1779
  const hasDone = meaningful.some((s) => s === "done");
1594
1780
  const hasActive = meaningful.some((s) => s === "in-progress");
1595
- const hasPaused = meaningful.some((s) => s === "paused");
1596
1781
  const hasPlanned = meaningful.some((s) => s === "planned");
1597
1782
  const hasBlocked = meaningful.some((s) => s === "blocked");
1598
1783
  const hasWaiting = meaningful.some((s) => s === "waiting");
1599
1784
  const hasDeferred = meaningful.some((s) => s === "deferred");
1600
1785
  if (hasActive)
1601
1786
  return "in-progress";
1602
- // If completed work exists and the ONLY remaining meaningful work is paused
1603
- // or deferred, do not imply active execution.
1604
- if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasDeferred && hasPaused)
1605
- return "paused";
1606
- if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasPaused && hasDeferred)
1787
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1607
1788
  return "deferred";
1608
1789
  // Partial completion with remaining planned/blocked/waiting work still means
1609
1790
  // the phase has genuinely started and is not terminal yet.
@@ -1616,8 +1797,6 @@ export class PlanStore {
1616
1797
  return "waiting";
1617
1798
  if (hasDeferred)
1618
1799
  return "deferred";
1619
- if (hasPaused)
1620
- return "paused";
1621
1800
  return "planned";
1622
1801
  }
1623
1802
  deriveFeatureStatus(featureId, phases) {
@@ -1633,17 +1812,13 @@ export class PlanStore {
1633
1812
  return "done";
1634
1813
  const hasDone = meaningful.some((s) => s === "done");
1635
1814
  const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
1636
- const hasPaused = meaningful.some((s) => s === "paused");
1637
1815
  const hasPlanned = meaningful.some((s) => s === "planned");
1638
1816
  const hasBlocked = meaningful.some((s) => s === "blocked");
1639
1817
  const hasWaiting = meaningful.some((s) => s === "waiting");
1640
1818
  const hasDeferred = meaningful.some((s) => s === "deferred");
1641
1819
  if (hasActive)
1642
1820
  return "in-progress";
1643
- // Same rule as phases: done + paused/deferred-only remainder is non-active.
1644
- if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasDeferred && hasPaused)
1645
- return "paused";
1646
- if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && !hasPaused && hasDeferred)
1821
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1647
1822
  return "deferred";
1648
1823
  if (hasDone)
1649
1824
  return "in-progress";
@@ -1654,8 +1829,6 @@ export class PlanStore {
1654
1829
  return "waiting";
1655
1830
  if (hasDeferred)
1656
1831
  return "deferred";
1657
- if (hasPaused)
1658
- return "paused";
1659
1832
  return "planned";
1660
1833
  }
1661
1834
  async syncStatuses() {
@@ -1740,24 +1913,23 @@ export class PlanStore {
1740
1913
  }
1741
1914
  /** Persist an explicitly approved work deviation without coupling it to a harness. */
1742
1915
  async addWorkDeviation(deviation) {
1743
- return this.updateProject((project) => ({
1744
- ...project,
1745
- workDeviations: [...project.workDeviations, deviation],
1746
- }));
1916
+ const deviations = [...(await this.loadWorkDeviations()), deviation];
1917
+ await this.saveWorkDeviations(deviations);
1918
+ return this.loadProject();
1747
1919
  }
1748
1920
  /** Advance a deviation while retaining its complete audit and return stack. */
1749
1921
  async setWorkDeviationState(id, state, timestamp = nowISO()) {
1750
- return this.updateProject((project) => ({
1751
- ...project,
1752
- workDeviations: project.workDeviations.map((deviation) => deviation.id !== id ? deviation : {
1753
- ...deviation,
1754
- state,
1755
- activatedAt: state === "active" ? timestamp : deviation.activatedAt,
1756
- resumeRequiredAt: state === "resume-required" ? timestamp : deviation.resumeRequiredAt,
1757
- resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
1758
- resumedAt: state === "resumed" ? timestamp : deviation.resumedAt,
1759
- }),
1760
- }));
1922
+ const deviations = await this.loadWorkDeviations();
1923
+ const next = deviations.map((deviation) => deviation.id !== id ? deviation : {
1924
+ ...deviation,
1925
+ state,
1926
+ activatedAt: state === "active" ? timestamp : deviation.activatedAt,
1927
+ resumeRequiredAt: state === "resume-required" ? timestamp : deviation.resumeRequiredAt,
1928
+ resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
1929
+ resumedAt: state === "resumed" ? timestamp : deviation.resumedAt,
1930
+ });
1931
+ await this.saveWorkDeviations(next);
1932
+ return this.loadProject();
1761
1933
  }
1762
1934
  async updateFeatures(updater) {
1763
1935
  const updated = await this.withFeaturesLock(async () => {
@@ -1783,8 +1955,94 @@ export class PlanStore {
1783
1955
  await this.maybeAutoSync();
1784
1956
  return updated;
1785
1957
  }
1958
+ /**
1959
+ * Persist completion of one full task → phase → feature → requirements read
1960
+ * sequence. Session metadata is deliberately the only changed entity field:
1961
+ * semantic updatedAt/descriptionUpdatedAt values are preserved exactly.
1962
+ */
1963
+ async recordContextRead(input) {
1964
+ const sessionId = input.sessionId.trim();
1965
+ if (!sessionId)
1966
+ throw new PlanStoreError("Context-read attestation requires a non-empty sessionId.");
1967
+ const createdAt = TimestampSchema.parse(input.createdAt ?? nowISO());
1968
+ const requirementIds = [...new Set((input.requirementIds ?? []).map((id) => id.trim()).filter(Boolean))];
1969
+ return this.runAsBatch(async () => {
1970
+ const originalPhase = await this.loadPhase(input.phaseId);
1971
+ const taskIndex = originalPhase.tasks.findIndex((task) => task.id === input.taskId);
1972
+ if (taskIndex < 0)
1973
+ throw new PlanStoreError(`Task ${input.taskId} does not belong to phase ${input.phaseId}.`);
1974
+ const originalFeatures = await this.loadFeatures();
1975
+ const featureId = input.featureId?.trim() || originalPhase.featureId;
1976
+ const featureIndex = featureId
1977
+ ? originalFeatures.features.findIndex((feature) => feature.id === featureId)
1978
+ : -1;
1979
+ if (featureId && featureIndex < 0)
1980
+ throw new PlanStoreError(`Feature ${featureId} was not found for context-read attestation.`);
1981
+ const originalRequirements = await this.loadRequirements();
1982
+ const missingRequirement = requirementIds.find((id) => !originalRequirements.requirements.some((requirement) => requirement.id === id));
1983
+ if (missingRequirement)
1984
+ throw new PlanStoreError(`Requirement ${missingRequirement} was not found for context-read attestation.`);
1985
+ const nextPhase = structuredClone(originalPhase);
1986
+ const nextTask = upsertSessionInfo(nextPhase.tasks[taskIndex], sessionId, createdAt);
1987
+ nextPhase.tasks[taskIndex] = nextTask.entity;
1988
+ const nextPhaseInfo = upsertSessionInfo(nextPhase, sessionId, createdAt);
1989
+ const phaseChanged = nextTask.changed || nextPhaseInfo.changed;
1990
+ let nextFeature;
1991
+ let featureChanged = false;
1992
+ if (featureIndex >= 0) {
1993
+ const featureResult = upsertSessionInfo(structuredClone(originalFeatures.features[featureIndex]), sessionId, createdAt);
1994
+ nextFeature = featureResult.entity;
1995
+ featureChanged = featureResult.changed;
1996
+ }
1997
+ const nextRequirements = structuredClone(originalRequirements);
1998
+ let requirementsChanged = false;
1999
+ for (const requirement of nextRequirements.requirements) {
2000
+ if (!requirementIds.includes(requirement.id))
2001
+ continue;
2002
+ const result = upsertSessionInfo(requirement, sessionId, createdAt);
2003
+ if (result.changed) {
2004
+ Object.assign(requirement, result.entity);
2005
+ requirementsChanged = true;
2006
+ }
2007
+ }
2008
+ if (!phaseChanged && !featureChanged && !requirementsChanged) {
2009
+ return {
2010
+ phase: originalPhase,
2011
+ ...(featureIndex >= 0 ? { feature: originalFeatures.features[featureIndex] } : {}),
2012
+ requirements: originalRequirements.requirements.filter((requirement) => requirementIds.includes(requirement.id)),
2013
+ createdAt,
2014
+ };
2015
+ }
2016
+ try {
2017
+ if (featureChanged && nextFeature)
2018
+ await this.saveFeature(nextFeature);
2019
+ if (phaseChanged)
2020
+ await this.savePhase(nextPhaseInfo.entity);
2021
+ if (requirementsChanged)
2022
+ await this.saveRequirements(nextRequirements);
2023
+ }
2024
+ catch (error) {
2025
+ const rollbackErrors = [];
2026
+ if (featureChanged && nextFeature)
2027
+ await this.saveFeature(originalFeatures.features[featureIndex]).catch((rollbackError) => rollbackErrors.push(rollbackError));
2028
+ if (phaseChanged)
2029
+ await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
2030
+ if (requirementsChanged)
2031
+ await this.saveRequirements(originalRequirements).catch((rollbackError) => rollbackErrors.push(rollbackError));
2032
+ if (rollbackErrors.length > 0)
2033
+ throw new AggregateError([error, ...rollbackErrors], "Context-read attestation failed and rollback was incomplete.");
2034
+ throw error;
2035
+ }
2036
+ const phase = await this.loadPhase(input.phaseId);
2037
+ const feature = featureIndex >= 0 ? (await this.loadFeatures()).features.find((candidate) => candidate.id === featureId) : undefined;
2038
+ const requirements = (await this.loadRequirements()).requirements.filter((requirement) => requirementIds.includes(requirement.id));
2039
+ return { phase, ...(feature ? { feature } : {}), requirements, createdAt };
2040
+ });
2041
+ }
1786
2042
  async saveProject(project) {
1787
- const parsed = ProjectSchema.parse(project);
2043
+ // Runtime workDeviations live in .local/deviations.json (T299); never
2044
+ // persist them here so shared project.json stays stable across worktrees.
2045
+ const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
1788
2046
  await atomicWriteJson(this.projectPath(), parsed, this.root);
1789
2047
  await this.touchTimestamp();
1790
2048
  await this.maybeAutoSync();
@@ -1896,11 +2154,12 @@ export class PlanStore {
1896
2154
  ? { ...timestamped, featureId: resolvedFeatureId }
1897
2155
  : timestamped;
1898
2156
  return this.normalizePhaseDocument(normalizedInput).phase;
1899
- });
2157
+ }, this.root);
2158
+ await this.pruneObsoleteWorkDeviations();
1900
2159
  await this.maybeAutoSync();
1901
2160
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
1902
2161
  }
1903
- /** Pause active work with a mandatory durable checkpoint. */
2162
+ /** Save a durable resume checkpoint without introducing a separate canonical task status. */
1904
2163
  async pauseTask(phaseId, taskId, input) {
1905
2164
  const snapshot = TaskPauseSnapshotSchema.parse(input);
1906
2165
  let paused;
@@ -1909,7 +2168,7 @@ export class PlanStore {
1909
2168
  if (!task)
1910
2169
  throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
1911
2170
  if (task.status !== "in-progress") {
1912
- throw new PlanStoreError(`Task ${taskId} cannot be paused from ${task.status}; only in-progress work can be paused.`);
2171
+ throw new PlanStoreError(`Task ${taskId} cannot be checkpointed from ${task.status}; only in-progress work can capture a pause snapshot.`);
1913
2172
  }
1914
2173
  const description = [
1915
2174
  `Reason: ${snapshot.reason}`,
@@ -1919,15 +2178,15 @@ export class PlanStore {
1919
2178
  ].join("\n");
1920
2179
  paused = {
1921
2180
  ...task,
1922
- status: "paused",
2181
+ status: "planned",
1923
2182
  pauseSnapshot: snapshot,
1924
2183
  pauseHistory: [...task.pauseHistory, snapshot],
1925
2184
  statusLog: [...task.statusLog, {
1926
2185
  id: createStatusLogEntryId(),
1927
2186
  date: snapshot.pausedAt,
1928
2187
  fromStatus: "in-progress",
1929
- toStatus: "paused",
1930
- title: "in-progress → paused",
2188
+ toStatus: "planned",
2189
+ title: "in-progress → planned (checkpoint saved)",
1931
2190
  description,
1932
2191
  }],
1933
2192
  updatedAt: snapshot.pausedAt,
@@ -1937,15 +2196,18 @@ export class PlanStore {
1937
2196
  });
1938
2197
  return paused;
1939
2198
  }
1940
- /** Resume a paused task without resetting its original startedAt. */
2199
+ /** Resume a checkpointed task without resetting its original startedAt. */
1941
2200
  async resumeTask(phaseId, taskId, timestamp = nowISO()) {
1942
2201
  let resumed;
1943
2202
  await this.updatePhase(phaseId, (phase) => {
1944
2203
  const task = phase.tasks.find((candidate) => candidate.id === taskId);
1945
2204
  if (!task)
1946
2205
  throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
1947
- if (task.status !== "paused") {
1948
- throw new PlanStoreError(`Task ${taskId} cannot be resumed from ${task.status}; only paused work can be resumed.`);
2206
+ if (!task.pauseSnapshot) {
2207
+ throw new PlanStoreError(`Task ${taskId} has no saved checkpoint to resume.`);
2208
+ }
2209
+ if (task.status === "done" || task.status === "canceled" || task.status === "rejected") {
2210
+ throw new PlanStoreError(`Task ${taskId} cannot be resumed from ${task.status}.`);
1949
2211
  }
1950
2212
  const snapshot = task.pauseSnapshot;
1951
2213
  resumed = {
@@ -1956,12 +2218,10 @@ export class PlanStore {
1956
2218
  statusLog: [...task.statusLog, {
1957
2219
  id: createStatusLogEntryId(),
1958
2220
  date: timestamp,
1959
- fromStatus: "paused",
2221
+ fromStatus: task.status,
1960
2222
  toStatus: "in-progress",
1961
- title: "paused → in-progress",
1962
- description: snapshot
1963
- ? `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`
1964
- : "Paused task resumed.",
2223
+ title: `${task.status} → in-progress (resume checkpoint)`,
2224
+ description: `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`,
1965
2225
  }],
1966
2226
  updatedAt: timestamp,
1967
2227
  };
@@ -1975,6 +2235,51 @@ export class PlanStore {
1975
2235
  async getPhaseHandoff(phaseId) {
1976
2236
  return (await this.loadPhase(phaseId)).handoff;
1977
2237
  }
2238
+ /** Audit one exact phase before preparing a handoff refresh. */
2239
+ async preparePhaseHandoff(phaseId) {
2240
+ const phase = await this.loadPhase(phaseId);
2241
+ if (!phase.featureId)
2242
+ throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
2243
+ const feature = (await this.loadFeatures()).features.find((candidate) => candidate.id === phase.featureId);
2244
+ if (!feature)
2245
+ throw new PlanStoreError(`Parent feature ${phase.featureId} not found for phase ${phaseId}.`);
2246
+ return auditPhaseHandoff(phase, feature);
2247
+ }
2248
+ /** Refresh the single active handoff and synchronize durable task/phase/feature
2249
+ * context. Unlike setPhaseHandoff(), this deliberately does not archive the
2250
+ * previous active body: callers must reconcile it using the optimistic
2251
+ * handoffUpdatedAt token returned by preparePhaseHandoff(). */
2252
+ async refreshPhaseHandoff(phaseId, input) {
2253
+ return this.runAsBatch(async () => {
2254
+ const originalPhase = await this.loadPhase(phaseId);
2255
+ if (!originalPhase.featureId)
2256
+ throw new PlanStoreError(`Phase ${phaseId} has no parent feature; durable handoff context cannot be synchronized.`);
2257
+ const originalFeatures = await this.loadFeatures();
2258
+ const featureIndex = originalFeatures.features.findIndex((candidate) => candidate.id === originalPhase.featureId);
2259
+ if (featureIndex < 0)
2260
+ throw new PlanStoreError(`Parent feature ${originalPhase.featureId} not found for phase ${phaseId}.`);
2261
+ const timestamp = nowISO();
2262
+ const applied = applyHandoffContextSync(originalPhase, originalFeatures.features[featureIndex], input, timestamp);
2263
+ const nextFeatures = structuredClone(originalFeatures);
2264
+ nextFeatures.features[featureIndex] = applied.feature;
2265
+ try {
2266
+ await this.saveFeatures(nextFeatures);
2267
+ await this.savePhase(applied.phase);
2268
+ }
2269
+ catch (error) {
2270
+ const rollbackErrors = [];
2271
+ await this.saveFeatures(originalFeatures).catch((rollbackError) => rollbackErrors.push(rollbackError));
2272
+ await this.savePhase(originalPhase).catch((rollbackError) => rollbackErrors.push(rollbackError));
2273
+ if (rollbackErrors.length > 0) {
2274
+ throw new AggregateError([error, ...rollbackErrors], "Handoff refresh failed and rollback was incomplete.");
2275
+ }
2276
+ throw error;
2277
+ }
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
+ });
2282
+ }
1978
2283
  /** Set the handoff text for a phase + stamp handoffUpdatedAt. A completed or
1979
2284
  * canceled phase cannot receive a new operational handoff. Replacing an
1980
2285
  * existing handoff archives the previous content as `superseded` first. */
@@ -2186,16 +2491,16 @@ export class PlanStore {
2186
2491
  await this.unlinkPhaseFiles(phaseId);
2187
2492
  await this.touchTimestamp();
2188
2493
  }
2189
- /** Remove a phase file AND its inline .bak backup. atomicUpdateJson (used by
2190
- * updatePhase without root) writes the backup inline at phases/<id>.json.bak,
2191
- * and readJson falls back to `${path}.bak` on a missing main file — so a
2192
- * delete that leaves the .bak behind would RESURRECT the deleted phase on
2193
- * the next read. Feature backups (written with root) live under
2194
- * .local/backups/ and are never read by readJson, so only the inline .bak
2195
- * needs removing here. */
2494
+ /** Remove a phase file and BOTH of its previous-version backups: the legacy
2495
+ * inline `phases/<id>.json.bak` (kept for backward compat) and the relocated
2496
+ * `.local/backups/phases/<id>.json.bak` (written once updatePhase passes root,
2497
+ * per P043). Leaving either behind would RESURRECT the deleted phase on the
2498
+ * next read, since readJson falls back to both locations. */
2196
2499
  async unlinkPhaseFiles(phaseId) {
2197
- await unlink(this.phasePath(phaseId)).catch(() => { });
2198
- await unlink(`${this.phasePath(phaseId)}.bak`).catch(() => { });
2500
+ const phaseFile = this.phasePath(phaseId);
2501
+ await unlink(phaseFile).catch(() => { });
2502
+ await unlink(`${phaseFile}.bak`).catch(() => { });
2503
+ await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
2199
2504
  }
2200
2505
  // ── Workspace-level operations ─────────────────────────────────────
2201
2506
  /** Load the full workspace (manifest + phases + project + requirements + features) */