@agent-plan/core 0.2.21 → 0.2.22-next.1

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,9 +14,10 @@ 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";
20
21
  function nowISO() {
21
22
  return new Date().toISOString();
22
23
  }
@@ -417,6 +418,41 @@ export async function migrateToGlobalSequence(store) {
417
418
  return { migrated: true, phases: newPhases.length, tasks: numberedTasks.length, features: newFeatures.length };
418
419
  });
419
420
  }
421
+ /** Map legacy/removed status strings to their canonical replacements before
422
+ * schema validation. Canonical task status "paused" was removed from the
423
+ * domain; persisted entities (tasks, statusLog entries) may still carry it.
424
+ * Rewriting on read keeps legacy data loadable without a one-time migration
425
+ * pass, and the normalized value is persisted on the next save. Scoped to
426
+ * status-bearing fields so unrelated string values are never touched. */
427
+ function migrateLegacyStatuses(value) {
428
+ if (Array.isArray(value))
429
+ return value.map(migrateLegacyStatuses);
430
+ if (value && typeof value === "object") {
431
+ const out = {};
432
+ for (const [key, child] of Object.entries(value)) {
433
+ if ((key === "status" || key === "fromStatus" || key === "toStatus") && child === "paused") {
434
+ out[key] = "planned";
435
+ }
436
+ else {
437
+ out[key] = migrateLegacyStatuses(child);
438
+ }
439
+ }
440
+ return out;
441
+ }
442
+ return value;
443
+ }
444
+ /** Walk up from `path` to find the owning `.planner` root, so crash-recovery
445
+ * can locate `.local/backups/<rel>.bak` without every readJson caller threading
446
+ * `root`. Returns undefined if no `.planner` ancestor exists. */
447
+ function findPlannerRoot(path) {
448
+ let dir = dirname(path);
449
+ while (dir !== dirname(dir)) {
450
+ if (basename(dir) === ".planner")
451
+ return dir;
452
+ dir = dirname(dir);
453
+ }
454
+ return undefined;
455
+ }
420
456
  async function readJson(path, schema) {
421
457
  let backupTried = false;
422
458
  let backupFailed = false;
@@ -424,20 +460,36 @@ async function readJson(path, schema) {
424
460
  try {
425
461
  const raw = await readFile(path, "utf-8");
426
462
  rawPreview = raw.slice(0, 240);
427
- return schema.parse(JSON.parse(raw));
463
+ return schema.parse(migrateLegacyStatuses(JSON.parse(raw)));
428
464
  }
429
465
  catch (cause) {
430
- // Try the .bak backup before giving up (recover from external-write corruption).
466
+ // Recover from the previous-version backup before giving up:
467
+ // 1) legacy inline `<file>.bak` (next to the source), then
468
+ // 2) the relocated `.planner/.local/backups/<rel>.bak` (P043).
431
469
  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
- }
470
+ const tryBackup = async (bakPath) => {
471
+ try {
472
+ const bak = await readFile(bakPath, "utf-8");
473
+ rawPreview = bak.slice(0, 240);
474
+ return schema.parse(migrateLegacyStatuses(JSON.parse(bak)));
475
+ }
476
+ catch {
477
+ return undefined;
478
+ }
479
+ };
480
+ const inline = await tryBackup(`${path}.bak`);
481
+ if (inline !== undefined)
482
+ return inline;
483
+ let local;
484
+ const plannerRoot = findPlannerRoot(path);
485
+ if (plannerRoot) {
486
+ const rel = path.slice(plannerRoot.length).replace(/^\//, "");
487
+ local = await tryBackup(join(plannerRoot, ".local", "backups", rel + ".bak"));
488
+ }
489
+ if (local !== undefined)
490
+ return local;
491
+ backupFailed = true;
492
+ // fall through to original error
441
493
  const details = {
442
494
  path,
443
495
  operation: "readJson",
@@ -518,11 +570,43 @@ export class PlanStore {
518
570
  normalizeTasks(tasks) {
519
571
  // Numbers are a STABLE global sequence (assigned once at create from project.nextTaskNumber).
520
572
  // 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);
573
+ const normalized = tasks.map((task) => {
574
+ const descriptionUpdatedAt = task.description && !task.descriptionUpdatedAt
575
+ ? task.createdAt
576
+ : task.descriptionUpdatedAt;
577
+ const normalizedStatus = task.status === "paused" ? "planned" : task.status;
578
+ const pauseSnapshot = this.isTerminalTaskStatus(normalizedStatus) ? null : task.pauseSnapshot;
579
+ const statusLog = (task.statusLog ?? []).map((entry) => ({
580
+ ...entry,
581
+ fromStatus: entry.fromStatus === "paused" ? "planned" : entry.fromStatus,
582
+ toStatus: entry.toStatus === "paused" ? "planned" : entry.toStatus,
583
+ }));
584
+ if (descriptionUpdatedAt !== task.descriptionUpdatedAt
585
+ || normalizedStatus !== task.status
586
+ || pauseSnapshot !== task.pauseSnapshot
587
+ || statusLog.some((entry, index) => entry !== task.statusLog[index])) {
588
+ return { ...task, descriptionUpdatedAt, status: normalizedStatus, pauseSnapshot, statusLog };
589
+ }
590
+ return task;
591
+ });
524
592
  return { tasks: normalized, changed: normalized.some((task, index) => task !== tasks[index]) };
525
593
  }
594
+ isTerminalTaskStatus(status) {
595
+ return status === "done" || status === "canceled" || status === "rejected";
596
+ }
597
+ async pruneObsoleteWorkDeviations() {
598
+ const phases = await this.loadAllPhases();
599
+ const taskStatusById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task.status])));
600
+ const current = await this.loadWorkDeviations();
601
+ const next = current.filter((deviation) => {
602
+ const resumeStatus = taskStatusById.get(deviation.resumeTaskId);
603
+ return Boolean(resumeStatus) && !this.isTerminalTaskStatus(resumeStatus);
604
+ });
605
+ if (next.length === current.length)
606
+ return;
607
+ await this.saveWorkDeviations(next);
608
+ await this.refreshResume();
609
+ }
526
610
  /** Stamp description edits independently from generic entity mutations.
527
611
  * Legacy entities cannot reveal their historical description-edit time, so
528
612
  * their creation time is the earliest truthful fallback. */
@@ -734,8 +818,28 @@ export class PlanStore {
734
818
  return join(this.localRoot(), "activity.json");
735
819
  }
736
820
  localRoot() {
821
+ // Worktree-local, git-ignored runtime state. The shared-vs-local boundary
822
+ // and the invariants every harness relies on are documented in
823
+ // packages/plan-core/docs/runtime-boundaries.md.
737
824
  return join(this.root, ".local");
738
825
  }
826
+ deviationsPath() {
827
+ return join(this.localRoot(), "deviations.json");
828
+ }
829
+ /** Runtime work deviations, persisted in worktree-local `.local/deviations.json`
830
+ * (never in shared `project.json`). Falls back to `[]` on a missing/corrupt file. */
831
+ async loadWorkDeviations() {
832
+ try {
833
+ return await readJson(this.deviationsPath(), WorkDeviationSchema.array());
834
+ }
835
+ catch {
836
+ return [];
837
+ }
838
+ }
839
+ async saveWorkDeviations(deviations) {
840
+ await atomicWriteJson(this.deviationsPath(), deviations, this.root);
841
+ await this.maybeAutoSync();
842
+ }
739
843
  timestampPath() {
740
844
  return join(this.localRoot(), "timestamp.json");
741
845
  }
@@ -822,6 +926,9 @@ export class PlanStore {
822
926
  };
823
927
  await atomicWriteJson(this.manifestPath(), manifest, this.root);
824
928
  await atomicWriteJson(this.timestampPath(), { updatedAt: manifest.updatedAt }, this.root);
929
+ // Seed the worktree-local runtime-deviation store (T299). Empty for new
930
+ // projects; migrateWorkDeviations (loadProject) backfills legacy data.
931
+ await atomicWriteJson(this.deviationsPath(), [], this.root);
825
932
  await this.saveProject({
826
933
  name: projectName,
827
934
  goal: "",
@@ -860,6 +967,11 @@ export class PlanStore {
860
967
  guardBypassUntil: "",
861
968
  });
862
969
  await this.writeGenerated();
970
+ // Write the STATIC planner extension rules. Content is fixed (no timestamps,
971
+ // no dynamic data) so the file is identical across worktrees/branches and
972
+ // never causes git conflicts. The canonical source is plan-core; this file
973
+ // is the per-project copy / override point, loaded at planner startup.
974
+ await writeFile(join(this.root, "rules.json"), JSON.stringify({ extensionRules: PLANNER_EXTENSION_RULES }, null, 2), "utf-8");
863
975
  // Write a README stub
864
976
  const readme = [
865
977
  "# Project Plan",
@@ -884,6 +996,15 @@ export class PlanStore {
884
996
  // - generated/: auto-regenerated markdown views (derived from JSON; churn)
885
997
  await writeFile(join(this.root, ".gitignore"), PLANNER_GITIGNORE, "utf-8");
886
998
  }
999
+ /**
1000
+ * Planner extension rules — the agent-behavior contract for every project
1001
+ * using the extension. Loaded from the static .planner/rules.json if present,
1002
+ * otherwise the canonical code set. Static (no timestamps), safe across
1003
+ * worktrees/branches.
1004
+ */
1005
+ async extensionRules() {
1006
+ return loadExtensionRules(this.root);
1007
+ }
887
1008
  /** Idempotently ensure `.planner/.gitignore` ignores `.local/` (and the
888
1009
  * canonical transient/derived patterns). Projects initialized before the
889
1010
  * `.local/` move either have no `.planner/.gitignore` or one with stale
@@ -924,7 +1045,19 @@ export class PlanStore {
924
1045
  return timestamp ? { ...manifest, updatedAt: timestamp.updatedAt } : manifest;
925
1046
  }
926
1047
  async loadProject() {
927
- return readJson(this.projectPath(), ProjectSchema);
1048
+ const project = await readJson(this.projectPath(), ProjectSchema);
1049
+ // One-time migration (T299): move runtime workDeviations from the shared
1050
+ // project.json into worktree-local .local/deviations.json, then clear them
1051
+ // in project.json so runtime state never churns shared metadata.
1052
+ // Idempotent: a non-empty .local file is never overwritten.
1053
+ if (project.workDeviations.length > 0 && (await this.loadWorkDeviations()).length === 0) {
1054
+ await this.saveWorkDeviations(project.workDeviations);
1055
+ await atomicUpdateJson(this.projectPath(), ProjectSchema, (p) => ({ ...p, workDeviations: [] }), this.root);
1056
+ }
1057
+ // Read-view merge: readers keep using `project.workDeviations`, but the
1058
+ // values now come from worktree-local storage.
1059
+ const deviations = await this.loadWorkDeviations();
1060
+ return { ...project, workDeviations: deviations };
928
1061
  }
929
1062
  /**
930
1063
  * Reserve immutable human identifiers without touching tracked `project.json`.
@@ -1362,6 +1495,45 @@ export class PlanStore {
1362
1495
  catch { /* ignore */ }
1363
1496
  }
1364
1497
  }
1498
+ const backupsRoot = join(this.root, ".local", "backups");
1499
+ const walkBackups = async (dir) => {
1500
+ let entries = [];
1501
+ try {
1502
+ entries = await readdir(dir);
1503
+ }
1504
+ catch {
1505
+ return;
1506
+ }
1507
+ for (const name of entries) {
1508
+ const full = join(dir, name);
1509
+ let st;
1510
+ try {
1511
+ st = await stat(full);
1512
+ }
1513
+ catch {
1514
+ continue;
1515
+ }
1516
+ if (st.isDirectory()) {
1517
+ await walkBackups(full);
1518
+ continue;
1519
+ }
1520
+ if (!name.endsWith(".json.bak"))
1521
+ continue;
1522
+ const rel = full.slice(backupsRoot.length).replace(/^\//, "");
1523
+ const mainPath = join(this.root, rel.slice(0, -".bak".length));
1524
+ try {
1525
+ await stat(mainPath);
1526
+ }
1527
+ catch {
1528
+ try {
1529
+ await unlink(full);
1530
+ removed += 1;
1531
+ }
1532
+ catch { /* ignore */ }
1533
+ }
1534
+ }
1535
+ };
1536
+ await walkBackups(backupsRoot);
1365
1537
  }
1366
1538
  catch { /* best-effort */ }
1367
1539
  return { removed };
@@ -1592,18 +1764,13 @@ export class PlanStore {
1592
1764
  return "done";
1593
1765
  const hasDone = meaningful.some((s) => s === "done");
1594
1766
  const hasActive = meaningful.some((s) => s === "in-progress");
1595
- const hasPaused = meaningful.some((s) => s === "paused");
1596
1767
  const hasPlanned = meaningful.some((s) => s === "planned");
1597
1768
  const hasBlocked = meaningful.some((s) => s === "blocked");
1598
1769
  const hasWaiting = meaningful.some((s) => s === "waiting");
1599
1770
  const hasDeferred = meaningful.some((s) => s === "deferred");
1600
1771
  if (hasActive)
1601
1772
  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)
1773
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1607
1774
  return "deferred";
1608
1775
  // Partial completion with remaining planned/blocked/waiting work still means
1609
1776
  // the phase has genuinely started and is not terminal yet.
@@ -1616,8 +1783,6 @@ export class PlanStore {
1616
1783
  return "waiting";
1617
1784
  if (hasDeferred)
1618
1785
  return "deferred";
1619
- if (hasPaused)
1620
- return "paused";
1621
1786
  return "planned";
1622
1787
  }
1623
1788
  deriveFeatureStatus(featureId, phases) {
@@ -1633,17 +1798,13 @@ export class PlanStore {
1633
1798
  return "done";
1634
1799
  const hasDone = meaningful.some((s) => s === "done");
1635
1800
  const hasActive = meaningful.some((s) => s === "discovery" || s === "in-progress");
1636
- const hasPaused = meaningful.some((s) => s === "paused");
1637
1801
  const hasPlanned = meaningful.some((s) => s === "planned");
1638
1802
  const hasBlocked = meaningful.some((s) => s === "blocked");
1639
1803
  const hasWaiting = meaningful.some((s) => s === "waiting");
1640
1804
  const hasDeferred = meaningful.some((s) => s === "deferred");
1641
1805
  if (hasActive)
1642
1806
  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)
1807
+ if (hasDone && !hasPlanned && !hasBlocked && !hasWaiting && hasDeferred)
1647
1808
  return "deferred";
1648
1809
  if (hasDone)
1649
1810
  return "in-progress";
@@ -1654,8 +1815,6 @@ export class PlanStore {
1654
1815
  return "waiting";
1655
1816
  if (hasDeferred)
1656
1817
  return "deferred";
1657
- if (hasPaused)
1658
- return "paused";
1659
1818
  return "planned";
1660
1819
  }
1661
1820
  async syncStatuses() {
@@ -1740,24 +1899,23 @@ export class PlanStore {
1740
1899
  }
1741
1900
  /** Persist an explicitly approved work deviation without coupling it to a harness. */
1742
1901
  async addWorkDeviation(deviation) {
1743
- return this.updateProject((project) => ({
1744
- ...project,
1745
- workDeviations: [...project.workDeviations, deviation],
1746
- }));
1902
+ const deviations = [...(await this.loadWorkDeviations()), deviation];
1903
+ await this.saveWorkDeviations(deviations);
1904
+ return this.loadProject();
1747
1905
  }
1748
1906
  /** Advance a deviation while retaining its complete audit and return stack. */
1749
1907
  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
- }));
1908
+ const deviations = await this.loadWorkDeviations();
1909
+ const next = deviations.map((deviation) => deviation.id !== id ? deviation : {
1910
+ ...deviation,
1911
+ state,
1912
+ activatedAt: state === "active" ? timestamp : deviation.activatedAt,
1913
+ resumeRequiredAt: state === "resume-required" ? timestamp : deviation.resumeRequiredAt,
1914
+ resolvedAt: state === "resolved" || state === "canceled" ? timestamp : deviation.resolvedAt,
1915
+ resumedAt: state === "resumed" ? timestamp : deviation.resumedAt,
1916
+ });
1917
+ await this.saveWorkDeviations(next);
1918
+ return this.loadProject();
1761
1919
  }
1762
1920
  async updateFeatures(updater) {
1763
1921
  const updated = await this.withFeaturesLock(async () => {
@@ -1784,7 +1942,9 @@ export class PlanStore {
1784
1942
  return updated;
1785
1943
  }
1786
1944
  async saveProject(project) {
1787
- const parsed = ProjectSchema.parse(project);
1945
+ // Runtime workDeviations live in .local/deviations.json (T299); never
1946
+ // persist them here so shared project.json stays stable across worktrees.
1947
+ const parsed = ProjectSchema.parse({ ...project, workDeviations: [] });
1788
1948
  await atomicWriteJson(this.projectPath(), parsed, this.root);
1789
1949
  await this.touchTimestamp();
1790
1950
  await this.maybeAutoSync();
@@ -1896,11 +2056,12 @@ export class PlanStore {
1896
2056
  ? { ...timestamped, featureId: resolvedFeatureId }
1897
2057
  : timestamped;
1898
2058
  return this.normalizePhaseDocument(normalizedInput).phase;
1899
- });
2059
+ }, this.root);
2060
+ await this.pruneObsoleteWorkDeviations();
1900
2061
  await this.maybeAutoSync();
1901
2062
  return { ...raw, status: this.derivePhaseStatus(raw.tasks) };
1902
2063
  }
1903
- /** Pause active work with a mandatory durable checkpoint. */
2064
+ /** Save a durable resume checkpoint without introducing a separate canonical task status. */
1904
2065
  async pauseTask(phaseId, taskId, input) {
1905
2066
  const snapshot = TaskPauseSnapshotSchema.parse(input);
1906
2067
  let paused;
@@ -1909,7 +2070,7 @@ export class PlanStore {
1909
2070
  if (!task)
1910
2071
  throw new PlanStoreError(`Task ${taskId} does not belong to phase ${phaseId}.`);
1911
2072
  if (task.status !== "in-progress") {
1912
- throw new PlanStoreError(`Task ${taskId} cannot be paused from ${task.status}; only in-progress work can be paused.`);
2073
+ throw new PlanStoreError(`Task ${taskId} cannot be checkpointed from ${task.status}; only in-progress work can capture a pause snapshot.`);
1913
2074
  }
1914
2075
  const description = [
1915
2076
  `Reason: ${snapshot.reason}`,
@@ -1919,15 +2080,15 @@ export class PlanStore {
1919
2080
  ].join("\n");
1920
2081
  paused = {
1921
2082
  ...task,
1922
- status: "paused",
2083
+ status: "planned",
1923
2084
  pauseSnapshot: snapshot,
1924
2085
  pauseHistory: [...task.pauseHistory, snapshot],
1925
2086
  statusLog: [...task.statusLog, {
1926
2087
  id: createStatusLogEntryId(),
1927
2088
  date: snapshot.pausedAt,
1928
2089
  fromStatus: "in-progress",
1929
- toStatus: "paused",
1930
- title: "in-progress → paused",
2090
+ toStatus: "planned",
2091
+ title: "in-progress → planned (checkpoint saved)",
1931
2092
  description,
1932
2093
  }],
1933
2094
  updatedAt: snapshot.pausedAt,
@@ -1937,15 +2098,18 @@ export class PlanStore {
1937
2098
  });
1938
2099
  return paused;
1939
2100
  }
1940
- /** Resume a paused task without resetting its original startedAt. */
2101
+ /** Resume a checkpointed task without resetting its original startedAt. */
1941
2102
  async resumeTask(phaseId, taskId, timestamp = nowISO()) {
1942
2103
  let resumed;
1943
2104
  await this.updatePhase(phaseId, (phase) => {
1944
2105
  const task = phase.tasks.find((candidate) => candidate.id === taskId);
1945
2106
  if (!task)
1946
2107
  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.`);
2108
+ if (!task.pauseSnapshot) {
2109
+ throw new PlanStoreError(`Task ${taskId} has no saved checkpoint to resume.`);
2110
+ }
2111
+ if (task.status === "done" || task.status === "canceled" || task.status === "rejected") {
2112
+ throw new PlanStoreError(`Task ${taskId} cannot be resumed from ${task.status}.`);
1949
2113
  }
1950
2114
  const snapshot = task.pauseSnapshot;
1951
2115
  resumed = {
@@ -1956,12 +2120,10 @@ export class PlanStore {
1956
2120
  statusLog: [...task.statusLog, {
1957
2121
  id: createStatusLogEntryId(),
1958
2122
  date: timestamp,
1959
- fromStatus: "paused",
2123
+ fromStatus: task.status,
1960
2124
  toStatus: "in-progress",
1961
- title: "paused → in-progress",
1962
- description: snapshot
1963
- ? `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`
1964
- : "Paused task resumed.",
2125
+ title: `${task.status} → in-progress (resume checkpoint)`,
2126
+ description: `Resumed from ${snapshot.resumeLocation}. ${snapshot.howToResume}`,
1965
2127
  }],
1966
2128
  updatedAt: timestamp,
1967
2129
  };
@@ -2186,16 +2348,16 @@ export class PlanStore {
2186
2348
  await this.unlinkPhaseFiles(phaseId);
2187
2349
  await this.touchTimestamp();
2188
2350
  }
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. */
2351
+ /** Remove a phase file and BOTH of its previous-version backups: the legacy
2352
+ * inline `phases/<id>.json.bak` (kept for backward compat) and the relocated
2353
+ * `.local/backups/phases/<id>.json.bak` (written once updatePhase passes root,
2354
+ * per P043). Leaving either behind would RESURRECT the deleted phase on the
2355
+ * next read, since readJson falls back to both locations. */
2196
2356
  async unlinkPhaseFiles(phaseId) {
2197
- await unlink(this.phasePath(phaseId)).catch(() => { });
2198
- await unlink(`${this.phasePath(phaseId)}.bak`).catch(() => { });
2357
+ const phaseFile = this.phasePath(phaseId);
2358
+ await unlink(phaseFile).catch(() => { });
2359
+ await unlink(`${phaseFile}.bak`).catch(() => { });
2360
+ await unlink(join(this.root, ".local", "backups", "phases", `${phaseId}.json.bak`)).catch(() => { });
2199
2361
  }
2200
2362
  // ── Workspace-level operations ─────────────────────────────────────
2201
2363
  /** Load the full workspace (manifest + phases + project + requirements + features) */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Canonical planner extension rules — the agent-behavior contract that applies
3
+ * to EVERY project using the Agent Plan extension (Pi, MCP / Claude Code / Codex,
4
+ * future harnesses). These are STATIC: no timestamps, no dynamic content, so the
5
+ * same text seeds every .planner/ and never diverges across worktrees or
6
+ * branches (no conflict from date/timestamp changes).
7
+ *
8
+ * AGENTS.md governs ONLY the development of the agent-plan extension and must
9
+ * not duplicate these rules.
10
+ */
11
+ export declare const PLANNER_EXTENSION_RULES: string[];
12
+ export interface ExtensionRulesFile {
13
+ extensionRules: string[];
14
+ }
15
+ /**
16
+ * Load the effective extension rules for a planner root. Returns the project's
17
+ * own .planner/rules.json (static, user-overridable) when present and non-empty,
18
+ * otherwise the canonical code set. Never returns timestamps or dynamic data.
19
+ */
20
+ export declare function loadExtensionRules(plannerRoot: string): Promise<string[]>;
21
+ //# sourceMappingURL=planner-rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"planner-rules.d.ts","sourceRoot":"","sources":["../src/planner-rules.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,EA2B3C,CAAC;AAEF,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAW/E"}
@@ -0,0 +1,58 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Canonical planner extension rules — the agent-behavior contract that applies
5
+ * to EVERY project using the Agent Plan extension (Pi, MCP / Claude Code / Codex,
6
+ * future harnesses). These are STATIC: no timestamps, no dynamic content, so the
7
+ * same text seeds every .planner/ and never diverges across worktrees or
8
+ * branches (no conflict from date/timestamp changes).
9
+ *
10
+ * AGENTS.md governs ONLY the development of the agent-plan extension and must
11
+ * not duplicate these rules.
12
+ */
13
+ export const PLANNER_EXTENSION_RULES = [
14
+ // §1 — source of truth
15
+ "Keep the planner as the single operational source of truth while working: read the relevant planner state before starting; update it when an activity starts, changes state, blocks, or concludes; and record next steps, blockers, and decisions in the relevant planner entities. Never leave work only in the conversation.",
16
+ // §2 — task lifecycle
17
+ "Respect the task lifecycle strictly. Always call task_start before touching code, and task_complete when a deliverable is done. Sync state changes (start/complete/block) to the planner at the exact moment they happen — never batch updates at session end. If the extension reports 'no active task', immediately start the correct task. A task marked in-progress means you are actually working on it; if you stop, close or block it with a motivation in statusLog. Derived feature/phase status is computed from tasks, not stored in JSON.",
18
+ // §3 — markdown not source of truth
19
+ "Do not treat markdown as the source of truth for the plan. The plan's primary source is structured data in .planner/; markdown is a generated, human/agent-readable view.",
20
+ // §5 — plan location
21
+ "The plan lives in .planner/ within the target project. Whether .planner/ is git-tracked is at the project's discretion.",
22
+ // §6 — discuss per phase
23
+ "Discuss the plan per phase: clarify objective, scope, non-scope, dependencies, risks, and outcomes before working a phase; detail implementation when the phase is actually worked, not up front.",
24
+ // §7 — naming
25
+ "Naming: phases and tasks use global project-wide numbering (P001, T001, …) with a slug derived from the title. Numbers are assigned once at creation from a monotonic global counter and never reused (deletes leave gaps).",
26
+ // §8 — status changes & motivation
27
+ "Every task status change is recorded in an incremental statusLog. Motivation is mandatory for blocked/canceled/rejected/deferred/waiting and for returning to planned from a non-planned status; not required for done or for normal in-progress-from-planned. Use task_update (not task_start/task_complete) for non-lifecycle status changes, with an exhaustive motivation.",
28
+ // §10 — references
29
+ "Reference entities with human, unique, composite IDs — Feature 'F001 - Name', Phase 'P001(F001) - Title', Task 'T003 - Number'. Short forms P003/T007 and the 5-char global shortId (e.g. UUXD1) are also valid. Never reference raw UUIDs. To locate an entity, use the compact list tools (feature_list/phase_list/task_list), not by reading .planner/*.json files.",
30
+ // §12 — handoff
31
+ "Handoff is per-phase (phase.handoff), not a file. Write it only on explicit user request; on resume, read it then clear it before starting work. A pending handoff never blocks task_start. Do not leave a handoff stale after processing it.",
32
+ // §12 — operational hygiene
33
+ "Operational hygiene: start the task (task_start) before thinking about implementation; complete it (task_complete) as part of delivering the deliverable, not after; motivate every block so a third party can understand the impediment.",
34
+ // Avvio del planner
35
+ "The planner and Web UI never start automatically. Do not start the Web UI or show its URL unless the user runs load/recap/web-status. The Web UI URL appears only in the recap after load, or on explicit web status.",
36
+ // Regola dettagli
37
+ "Write relevant points (decisions, constraints, current state, file:line refs, edge cases) into the task/phase/feature description or notes as soon as they emerge; read the task description and notes (and parent phase/feature) before starting work; cite entities with composite IDs, not bare UUIDs.",
38
+ // Comportamento atteso 2-5
39
+ "When you begin work: read the relevant planner entities (feature_get/phase_get/task_get/handoff_show), read the relevant documents, and update the planner before and after significant changes. If you change an architectural decision, document it explicitly.",
40
+ ];
41
+ /**
42
+ * Load the effective extension rules for a planner root. Returns the project's
43
+ * own .planner/rules.json (static, user-overridable) when present and non-empty,
44
+ * otherwise the canonical code set. Never returns timestamps or dynamic data.
45
+ */
46
+ export async function loadExtensionRules(plannerRoot) {
47
+ try {
48
+ const raw = await readFile(join(plannerRoot, "rules.json"), "utf8");
49
+ const parsed = JSON.parse(raw);
50
+ if (Array.isArray(parsed.extensionRules) && parsed.extensionRules.length > 0) {
51
+ return parsed.extensionRules.filter((r) => typeof r === "string" && r.length > 0);
52
+ }
53
+ }
54
+ catch {
55
+ // Missing or malformed rules.json → fall back to the canonical code set.
56
+ }
57
+ return PLANNER_EXTENSION_RULES;
58
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * T307 (P072/F005) — tracked, deduplicated parent read-enforcement.
3
+ *
4
+ * Analogous to the existing NO ACTIVE TASK advisory: an in-memory, per-session
5
+ * record of which feature/phase refs the agent has actually read (via the
6
+ * feature/phase/task get/show tools). Starting, resuming, or switching a task
7
+ * checks that the target's parent feature + phase are in the read set; a missing
8
+ * read yields an unavoidable (non-blocking) advisory instead of a silent skip.
9
+ *
10
+ * Dedupe: reading a phase (or any task inside it) records both its phase and
11
+ * feature, so 10 sibling tasks need a single feature + phase read. Switching to
12
+ * a different phase/feature naturally fails (that phase is not recorded). A
13
+ * pause invalidates the set, so resuming forces a fresh read.
14
+ *
15
+ * T319 (P072/F005) extends the set with requirements: starting/resuming/switching
16
+ * also requires the requirements linked to the phase (and, when present, the
17
+ * feature) to have been explicitly read. Requirements are NOT auto-recorded by a
18
+ * phase/feature/task read (per project decision: explicit separate read); they
19
+ * are recorded only via the requirement list tool (markRequirementRead). An empty
20
+ * linked-requirement list means nothing is required.
21
+ */
22
+ /** Record that a feature was read. Does NOT imply any phase was read. */
23
+ export declare function markFeatureRead(featureId: string): void;
24
+ /** Record that a phase (and, when known, its feature) was read. */
25
+ export declare function markPhaseRead(phaseId: string, featureId?: string): void;
26
+ /** Record that a task (and, by context, its parent phase + feature) was read. */
27
+ export declare function markTaskRead(_taskId: string, phaseId: string, featureId?: string): void;
28
+ /** Record that a requirement was explicitly read (via the requirement list tool). */
29
+ export declare function markRequirementRead(requirementId: string): void;
30
+ /**
31
+ * Whether the target task's parent feature + phase have both been read.
32
+ * `featureId` is optional: an orphan phase (no feature) only requires the phase.
33
+ */
34
+ export declare function hasReadParents(featureId: string | undefined, phaseId: string): boolean;
35
+ /** Whether every linked requirement (phase + feature) has been explicitly read. */
36
+ export declare function hasReadRequirements(requirementIds: string[]): boolean;
37
+ /** Clear the read set (e.g. on pause, so resuming forces a fresh read). */
38
+ export declare function invalidateReads(): void;
39
+ /**
40
+ * Non-blocking advisory (mirrors the NO ACTIVE TASK pattern): empty when the
41
+ * target's parent feature + phase have both been read, otherwise a loud
42
+ * READ REQUIRED notice the agent cannot silently skip.
43
+ */
44
+ export declare function parentReadAdvisory(featureId: string | undefined, phaseId: string): string;
45
+ /**
46
+ * Non-blocking advisory (mirrors parentReadAdvisory): empty when every linked
47
+ * requirement (phase + feature) has been explicitly read, otherwise a loud
48
+ * REQUIREMENTS READ REQUIRED notice. An empty list means the phase/feature has
49
+ * no linked requirements, so nothing is required.
50
+ */
51
+ export declare function requirementReadAdvisory(requirementIds: string[]): string;
52
+ /** Snapshot for diagnostics/tests. */
53
+ export declare function readTrackingSnapshot(): {
54
+ features: string[];
55
+ phases: string[];
56
+ requirements: string[];
57
+ };
58
+ //# sourceMappingURL=read-tracking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-tracking.d.ts","sourceRoot":"","sources":["../src/read-tracking.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAUH,yEAAyE;AACzE,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAGvE;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAGvF;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAE/D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAItF;AAED,mFAAmF;AACnF,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,OAAO,CAErE;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAGzF;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,MAAM,CAKxE;AAED,sCAAsC;AACtC,wBAAgB,oBAAoB,IAAI;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,EAAE,CAAA;CAAE,CAEvG"}