agent-trajectories 0.5.7 → 0.5.9

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.
@@ -2,7 +2,7 @@
2
2
  import { spawn } from "child_process";
3
3
  import { existsSync as existsSync2, readFileSync } from "fs";
4
4
  import { createRequire } from "module";
5
- import { dirname, resolve as resolvePath } from "path";
5
+ import { dirname as dirname2, resolve as resolvePath } from "path";
6
6
 
7
7
  // src/core/id.ts
8
8
  import { webcrypto } from "crypto";
@@ -666,39 +666,53 @@ function formatTime(isoString) {
666
666
  }
667
667
 
668
668
  // src/storage/file.ts
669
- import { randomUUID } from "crypto";
670
669
  import { existsSync } from "fs";
671
670
  import {
672
671
  mkdir,
673
672
  readFile,
674
673
  readdir,
675
674
  rename,
676
- unlink,
675
+ rm,
677
676
  writeFile
678
677
  } from "fs/promises";
679
- import { join } from "path";
678
+ import {
679
+ basename,
680
+ dirname,
681
+ isAbsolute,
682
+ join,
683
+ relative,
684
+ resolve
685
+ } from "path";
686
+ var TRAJECTORY_FILE = "trajectory.json";
687
+ var SUMMARY_FILE = "summary.md";
688
+ var COMPACTION_FILE = "compaction.json";
689
+ var LEGACY_COMPACTION_SUFFIX = ".compaction.json";
680
690
  function expandPath(path) {
681
691
  if (path.startsWith("~")) {
682
692
  return join(process.env.HOME ?? "", path.slice(1));
683
693
  }
684
694
  return path;
685
695
  }
686
- var indexLocks = /* @__PURE__ */ new Map();
687
- function withIndexLock(path, task) {
688
- const prev = indexLocks.get(path) ?? Promise.resolve();
689
- const next = prev.then(task, task);
690
- indexLocks.set(
691
- path,
692
- next.catch(() => void 0)
693
- );
694
- return next;
696
+ function describeReadFailure(reason, error) {
697
+ if (reason === "schema_violation" && error && typeof error === "object" && "issues" in error) {
698
+ const issues = error.issues ?? [];
699
+ if (issues.length > 0) {
700
+ const first = issues[0];
701
+ const where = first.path.length > 0 ? first.path.join(".") : "root";
702
+ const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
703
+ return `${where}: ${first.message}${extra}`;
704
+ }
705
+ return "schema validation failed";
706
+ }
707
+ if (error instanceof Error) return error.message;
708
+ return String(error);
695
709
  }
696
710
  var FileStorage = class {
697
711
  baseDir;
698
712
  trajectoriesDir;
699
713
  activeDir;
700
714
  completedDir;
701
- indexPath;
715
+ lastReconcileSummary;
702
716
  constructor(baseDir) {
703
717
  this.baseDir = baseDir ?? process.cwd();
704
718
  const dataDir = process.env.TRAJECTORIES_DATA_DIR;
@@ -709,7 +723,6 @@ var FileStorage = class {
709
723
  }
710
724
  this.activeDir = join(this.trajectoriesDir, "active");
711
725
  this.completedDir = join(this.trajectoriesDir, "completed");
712
- this.indexPath = join(this.trajectoriesDir, "index.json");
713
726
  }
714
727
  /**
715
728
  * Initialize storage directories
@@ -718,28 +731,22 @@ var FileStorage = class {
718
731
  await mkdir(this.trajectoriesDir, { recursive: true });
719
732
  await mkdir(this.activeDir, { recursive: true });
720
733
  await mkdir(this.completedDir, { recursive: true });
721
- if (!existsSync(this.indexPath)) {
722
- await withIndexLock(this.indexPath, async () => {
723
- if (!existsSync(this.indexPath)) {
724
- await this.saveIndex(this.emptyIndex());
725
- }
726
- });
727
- }
734
+ await this.migrateLegacyIndexCompactionMarkers();
735
+ await rm(join(this.trajectoriesDir, "index.json"), { force: true });
728
736
  await this.reconcileIndex();
729
737
  }
730
738
  /**
731
- * Scan active/ and completed/ recursively and add any trajectory files
732
- * missing from the index. Existing entries are preserved reconcile
733
- * only adds, never removes.
739
+ * Scan active/ and completed/ recursively and report trajectory files
740
+ * that can be loaded plus files that should be surfaced by doctor.
734
741
  *
735
742
  * Handles three on-disk layouts in completed/:
736
743
  * - flat: completed/{id}.json (legacy workforce data)
737
- * - monthly: completed/YYYY-MM/{id}.json (current save() writes)
744
+ * - monthly: completed/YYYY-MM/{id}.json (legacy monthly layout)
745
+ * - directory: completed/YYYY-MM/{id}/trajectory.json (current layout)
738
746
  * - nested: completed/.../{id}.json (defensive — any depth)
739
747
  *
740
- * Returns a ReconcileSummary so tests and CLI wrappers can observe
741
- * outcomes without parsing logs. Only writes the index if anything was
742
- * added.
748
+ * The method name is kept for callers such as `trail doctor`, but no
749
+ * shared index file is written.
743
750
  */
744
751
  async reconcileIndex() {
745
752
  const summary = {
@@ -748,55 +755,32 @@ var FileStorage = class {
748
755
  alreadyIndexed: 0,
749
756
  skippedMalformedJson: 0,
750
757
  skippedSchemaViolation: 0,
751
- skippedIoError: 0
758
+ skippedIoError: 0,
759
+ failures: []
752
760
  };
753
- await withIndexLock(this.indexPath, async () => {
754
- const index = await this.loadIndex();
755
- const before = Object.keys(index.trajectories).length;
756
- const discovered = [];
757
- try {
758
- const activeFiles = await readdir(this.activeDir);
759
- for (const file of activeFiles) {
760
- if (!file.endsWith(".json")) continue;
761
- discovered.push(join(this.activeDir, file));
762
- }
763
- } catch (error) {
764
- if (error.code !== "ENOENT") throw error;
765
- }
766
- await this.walkJsonFilesInto(this.completedDir, discovered);
767
- for (const filePath of discovered) {
768
- summary.scanned += 1;
769
- const result = await this.readTrajectoryFile(filePath);
770
- if (!result.ok) {
771
- if (result.reason === "malformed_json") {
772
- summary.skippedMalformedJson += 1;
773
- } else if (result.reason === "schema_violation") {
774
- summary.skippedSchemaViolation += 1;
775
- } else {
776
- summary.skippedIoError += 1;
777
- }
778
- continue;
779
- }
780
- const trajectory2 = result.trajectory;
781
- if (index.trajectories[trajectory2.id]) {
782
- summary.alreadyIndexed += 1;
783
- continue;
761
+ const discovered = await this.listTrajectoryFiles();
762
+ for (const filePath of discovered) {
763
+ summary.scanned += 1;
764
+ const result = await this.readTrajectoryFile(filePath);
765
+ if (!result.ok) {
766
+ if (result.reason === "malformed_json") {
767
+ summary.skippedMalformedJson += 1;
768
+ } else if (result.reason === "schema_violation") {
769
+ summary.skippedSchemaViolation += 1;
770
+ } else {
771
+ summary.skippedIoError += 1;
784
772
  }
785
- index.trajectories[trajectory2.id] = {
786
- title: trajectory2.task.title,
787
- status: trajectory2.status,
788
- startedAt: trajectory2.startedAt,
789
- completedAt: trajectory2.completedAt,
790
- path: filePath
791
- };
792
- summary.added += 1;
793
- }
794
- if (Object.keys(index.trajectories).length !== before) {
795
- await this.saveIndex(index);
773
+ summary.failures.push({
774
+ path: result.path,
775
+ reason: result.reason,
776
+ message: describeReadFailure(result.reason, result.error)
777
+ });
778
+ continue;
796
779
  }
797
- });
780
+ summary.added += 1;
781
+ }
798
782
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
799
- if (summary.added > 0 || hadSkips) {
783
+ if (hadSkips) {
800
784
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
801
785
  if (summary.skippedMalformedJson > 0) {
802
786
  parts.push(`malformed: ${summary.skippedMalformedJson}`);
@@ -809,10 +793,76 @@ var FileStorage = class {
809
793
  }
810
794
  console.warn(`[trajectories] ${parts.join(", ")}`);
811
795
  }
796
+ this.lastReconcileSummary = summary;
812
797
  return summary;
813
798
  }
814
799
  /**
815
- * Recursively collect all .json file paths under `dir` into `out`.
800
+ * Returns the most recent reconcile summary, if any. Lets the CLI
801
+ * inspect the failures collected during `initialize()` without having
802
+ * to re-walk the directory tree (and re-emit the warn line).
803
+ */
804
+ getLastReconcileSummary() {
805
+ return this.lastReconcileSummary;
806
+ }
807
+ /**
808
+ * Move trajectory files that fail to load into `.trajectories/invalid/`
809
+ * so reconcile no longer scans them. Only quarantines parse and schema
810
+ * failures — transient io_error failures are left in place because the
811
+ * file may load fine on the next attempt.
812
+ *
813
+ * Returns the list of files that were moved (with their original paths
814
+ * and the destination directory) so the caller can report what changed.
815
+ */
816
+ async quarantineInvalid() {
817
+ const summary = await this.reconcileIndex();
818
+ const targetDir = join(this.trajectoriesDir, "invalid");
819
+ const candidates = summary.failures.filter((f) => f.reason !== "io_error");
820
+ if (candidates.length === 0) {
821
+ return { moved: [], targetDir };
822
+ }
823
+ await mkdir(targetDir, { recursive: true });
824
+ const moved = [];
825
+ for (const failure of candidates) {
826
+ const dest = await this.resolveQuarantineDest(failure.path, targetDir);
827
+ try {
828
+ await mkdir(dirname(dest), { recursive: true });
829
+ await rename(failure.path, dest);
830
+ moved.push(failure);
831
+ } catch (error) {
832
+ console.warn(
833
+ `[trajectories] failed to quarantine ${failure.path}: ${error instanceof Error ? error.message : String(error)}`
834
+ );
835
+ }
836
+ }
837
+ return { moved, targetDir };
838
+ }
839
+ /**
840
+ * Pick a destination path under `targetDir` for a quarantined file.
841
+ *
842
+ * Preserves the file's relative location under the trajectories root
843
+ * (e.g. `completed/2026-04/foo.json` → `invalid/completed/2026-04/foo.json`)
844
+ * so two invalid files that share a basename across `active/` and
845
+ * `completed/` don't collapse onto each other and silently overwrite.
846
+ *
847
+ * Falls back to a numeric-suffix scheme for paths that live outside
848
+ * the trajectories directory or that, after relative resolution, would
849
+ * still collide with something already quarantined.
850
+ */
851
+ async resolveQuarantineDest(sourcePath, targetDir) {
852
+ const rel = relative(this.trajectoriesDir, sourcePath);
853
+ const safeRel = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : basename(sourcePath);
854
+ let dest = join(targetDir, safeRel);
855
+ if (!existsSync(dest)) return dest;
856
+ const ext = safeRel.endsWith(".json") ? ".json" : "";
857
+ const stem = ext ? safeRel.slice(0, -ext.length) : safeRel;
858
+ for (let i = 1; i < 1e3; i += 1) {
859
+ dest = join(targetDir, `${stem}.${i}${ext}`);
860
+ if (!existsSync(dest)) return dest;
861
+ }
862
+ return dest;
863
+ }
864
+ /**
865
+ * Recursively collect trajectory JSON file paths under `dir` into `out`.
816
866
  * Silently treats a missing directory as empty.
817
867
  */
818
868
  async walkJsonFilesInto(dir, out) {
@@ -827,7 +877,7 @@ var FileStorage = class {
827
877
  const entryPath = join(dir, entry.name);
828
878
  if (entry.isDirectory()) {
829
879
  await this.walkJsonFilesInto(entryPath, out);
830
- } else if (entry.isFile() && entry.name.endsWith(".json")) {
880
+ } else if (entry.isFile() && isTrajectoryJsonFile(entry.name)) {
831
881
  out.push(entryPath);
832
882
  }
833
883
  }
@@ -851,56 +901,43 @@ var FileStorage = class {
851
901
  }
852
902
  const trajectory2 = validation.data;
853
903
  const isCompleted = trajectory2.status === "completed" || trajectory2.status === "abandoned";
854
- let filePath;
904
+ const existingPaths = await this.findTrajectoryFilePaths(trajectory2.id);
905
+ let trajectoryDir;
855
906
  if (isCompleted) {
856
907
  const date = new Date(trajectory2.completedAt ?? trajectory2.startedAt);
857
908
  const monthDir = join(
858
909
  this.completedDir,
859
910
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
860
911
  );
861
- await mkdir(monthDir, { recursive: true });
862
- filePath = join(monthDir, `${trajectory2.id}.json`);
863
- const activePath = join(this.activeDir, `${trajectory2.id}.json`);
864
- if (existsSync(activePath)) {
865
- await unlink(activePath);
866
- }
867
- const mdPath = join(monthDir, `${trajectory2.id}.md`);
868
- const markdown = exportToMarkdown(trajectory2);
869
- await writeFile(mdPath, markdown, "utf-8");
912
+ trajectoryDir = join(monthDir, trajectory2.id);
870
913
  } else {
871
- filePath = join(this.activeDir, `${trajectory2.id}.json`);
914
+ trajectoryDir = join(this.activeDir, trajectory2.id);
915
+ }
916
+ const filePath = join(trajectoryDir, TRAJECTORY_FILE);
917
+ await this.removeTrajectoryFiles(existingPaths, filePath);
918
+ await mkdir(trajectoryDir, { recursive: true });
919
+ if (isCompleted) {
920
+ const markdown = exportToMarkdown(trajectory2);
921
+ await writeFile(join(trajectoryDir, SUMMARY_FILE), markdown, "utf-8");
872
922
  }
873
923
  await writeFile(filePath, JSON.stringify(trajectory2, null, 2), "utf-8");
874
- await this.updateIndex(trajectory2, filePath);
875
924
  }
876
925
  /**
877
926
  * Get a trajectory by ID
878
927
  */
879
928
  async get(id) {
880
- const activePath = join(this.activeDir, `${id}.json`);
881
- if (existsSync(activePath)) {
882
- return this.readTrajectoryOrNull(activePath);
883
- }
884
- const index = await this.loadIndex();
885
- const entry = index.trajectories[id];
886
- if (entry?.path && existsSync(entry.path)) {
887
- return this.readTrajectoryOrNull(entry.path);
888
- }
889
- try {
890
- const flatPath = join(this.completedDir, `${id}.json`);
891
- if (existsSync(flatPath)) {
892
- return this.readTrajectoryOrNull(flatPath);
929
+ for (const filePath of this.getActiveCandidatePaths(id)) {
930
+ if (!existsSync(filePath)) continue;
931
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
932
+ if (trajectory2?.id === id) {
933
+ return trajectory2;
893
934
  }
894
- const months = await readdir(this.completedDir);
895
- for (const month of months) {
896
- const filePath = join(this.completedDir, month, `${id}.json`);
897
- if (existsSync(filePath)) {
898
- return this.readTrajectoryOrNull(filePath);
899
- }
900
- }
901
- } catch (error) {
902
- if (error.code !== "ENOENT") {
903
- console.error("Error searching completed trajectories:", error);
935
+ }
936
+ const paths = await this.findTrajectoryFilePaths(id);
937
+ for (const filePath of paths) {
938
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
939
+ if (trajectory2?.id === id) {
940
+ return trajectory2;
904
941
  }
905
942
  }
906
943
  return null;
@@ -909,107 +946,61 @@ var FileStorage = class {
909
946
  * Get the currently active trajectory
910
947
  */
911
948
  async getActive() {
912
- try {
913
- const files = await readdir(this.activeDir);
914
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
915
- if (jsonFiles.length === 0) {
916
- return null;
917
- }
918
- let mostRecent = null;
919
- let mostRecentTime = 0;
920
- for (const file of jsonFiles) {
921
- const trajectory2 = await this.readTrajectoryOrNull(
922
- join(this.activeDir, file)
923
- );
924
- if (trajectory2) {
925
- const startTime = new Date(trajectory2.startedAt).getTime();
926
- if (startTime > mostRecentTime) {
927
- mostRecentTime = startTime;
928
- mostRecent = trajectory2;
929
- }
930
- }
931
- }
932
- return mostRecent;
933
- } catch (error) {
934
- if (error.code === "ENOENT") {
935
- return null;
936
- }
937
- console.error("Error reading active trajectories:", error);
949
+ const activeFiles = await this.collectTrajectoryFiles(this.activeDir);
950
+ if (activeFiles.length === 0) {
938
951
  return null;
939
952
  }
953
+ let mostRecent = null;
954
+ let mostRecentTime = 0;
955
+ for (const filePath of activeFiles) {
956
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
957
+ if (trajectory2?.status !== "active") continue;
958
+ const startTime = new Date(trajectory2.startedAt).getTime();
959
+ if (startTime > mostRecentTime) {
960
+ mostRecentTime = startTime;
961
+ mostRecent = trajectory2;
962
+ }
963
+ }
964
+ return mostRecent;
940
965
  }
941
966
  /**
942
967
  * List trajectories with optional filtering
943
968
  */
944
969
  async list(query) {
945
- const index = await this.loadIndex();
946
- let entries = Object.entries(index.trajectories);
970
+ let trajectories = await this.loadAllTrajectories();
947
971
  if (query.status) {
948
- entries = entries.filter(([, entry]) => entry.status === query.status);
972
+ trajectories = trajectories.filter((t) => t.status === query.status);
949
973
  }
950
974
  if (query.since) {
951
975
  const sinceTime = new Date(query.since).getTime();
952
- entries = entries.filter(
953
- ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime
976
+ trajectories = trajectories.filter(
977
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() >= sinceTime
954
978
  );
955
979
  }
956
980
  if (query.until) {
957
981
  const untilTime = new Date(query.until).getTime();
958
- entries = entries.filter(
959
- ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime
982
+ trajectories = trajectories.filter(
983
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() <= untilTime
960
984
  );
961
985
  }
962
986
  const sortBy = query.sortBy ?? "startedAt";
963
987
  const sortOrder = query.sortOrder ?? "desc";
964
- entries.sort((a, b) => {
965
- const aVal = a[1][sortBy] ?? "";
966
- const bVal = b[1][sortBy] ?? "";
988
+ trajectories.sort((a, b) => {
989
+ const aVal = this.getSortValue(a, sortBy);
990
+ const bVal = this.getSortValue(b, sortBy);
967
991
  const cmp = String(aVal).localeCompare(String(bVal));
968
992
  return sortOrder === "asc" ? cmp : -cmp;
969
993
  });
970
994
  const offset = query.offset ?? 0;
971
995
  const limit = query.limit ?? 500;
972
- entries = entries.slice(offset, offset + limit);
973
- return Promise.all(
974
- entries.map(async ([id, entry]) => {
975
- const trajectory2 = await this.get(id);
976
- return {
977
- id,
978
- title: entry.title,
979
- status: entry.status,
980
- startedAt: entry.startedAt,
981
- completedAt: entry.completedAt,
982
- confidence: trajectory2?.retrospective?.confidence,
983
- chapterCount: trajectory2?.chapters.length ?? 0,
984
- decisionCount: trajectory2?.chapters.reduce(
985
- (count, chapter) => count + chapter.events.filter((e) => e.type === "decision").length,
986
- 0
987
- ) ?? 0
988
- };
989
- })
990
- );
996
+ trajectories = trajectories.slice(offset, offset + limit);
997
+ return trajectories.map((trajectory2) => this.toSummary(trajectory2));
991
998
  }
992
999
  /**
993
1000
  * Delete a trajectory
994
1001
  */
995
1002
  async delete(id) {
996
- const activePath = join(this.activeDir, `${id}.json`);
997
- if (existsSync(activePath)) {
998
- await unlink(activePath);
999
- }
1000
- await withIndexLock(this.indexPath, async () => {
1001
- const index = await this.loadIndex();
1002
- const entry = index.trajectories[id];
1003
- if (entry?.path && existsSync(entry.path)) {
1004
- await unlink(entry.path);
1005
- const mdPath = entry.path.replace(".json", ".md");
1006
- if (existsSync(mdPath)) {
1007
- await unlink(mdPath);
1008
- }
1009
- }
1010
- delete index.trajectories[id];
1011
- await this.saveIndex(index);
1012
- });
1003
+ await this.deleteWithSummary(id);
1013
1004
  }
1014
1005
  /**
1015
1006
  * Search trajectories by text
@@ -1042,12 +1033,395 @@ var FileStorage = class {
1042
1033
  }
1043
1034
  return matches;
1044
1035
  }
1036
+ /**
1037
+ * Mark a trajectory as compacted without writing to a shared index.
1038
+ */
1039
+ async markCompacted(id, compactedInto) {
1040
+ const markedIds = await this.markCompactedMany([id], compactedInto);
1041
+ return markedIds.has(id);
1042
+ }
1043
+ /**
1044
+ * Mark multiple trajectories as compacted with one filesystem scan.
1045
+ */
1046
+ async markCompactedMany(ids, compactedInto) {
1047
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
1048
+ const markedIds = /* @__PURE__ */ new Set();
1049
+ const compactedAt = (/* @__PURE__ */ new Date()).toISOString();
1050
+ const writes = [];
1051
+ for (const [id, paths] of pathsById.entries()) {
1052
+ if (paths.length === 0) {
1053
+ continue;
1054
+ }
1055
+ markedIds.add(id);
1056
+ const marker = {
1057
+ trajectoryId: id,
1058
+ compactedInto,
1059
+ compactedAt
1060
+ };
1061
+ for (const filePath of paths) {
1062
+ writes.push(
1063
+ writeFile(
1064
+ this.getCompactionMarkerPath(filePath, id),
1065
+ JSON.stringify(marker, null, 2),
1066
+ "utf-8"
1067
+ )
1068
+ );
1069
+ }
1070
+ }
1071
+ await Promise.all(writes);
1072
+ return markedIds;
1073
+ }
1074
+ /**
1075
+ * Return trajectory IDs that have a per-trajectory compaction marker.
1076
+ */
1077
+ async getCompactedTrajectoryIds() {
1078
+ const markerPaths = await this.listCompactionMarkerFiles();
1079
+ const compactedIds = /* @__PURE__ */ new Set();
1080
+ for (const markerPath of markerPaths) {
1081
+ try {
1082
+ const marker = JSON.parse(
1083
+ await readFile(markerPath, "utf-8")
1084
+ );
1085
+ const trajectoryId = typeof marker.trajectoryId === "string" ? marker.trajectoryId : this.getTrajectoryIdFromCompactionMarkerPath(markerPath);
1086
+ if (trajectoryId && typeof marker.compactedInto === "string") {
1087
+ compactedIds.add(trajectoryId);
1088
+ }
1089
+ } catch {
1090
+ }
1091
+ }
1092
+ return compactedIds;
1093
+ }
1094
+ /**
1095
+ * Delete a trajectory and return file counts for CLI reporting.
1096
+ */
1097
+ async deleteWithSummary(id) {
1098
+ return this.deleteManyWithSummary([id]);
1099
+ }
1100
+ /**
1101
+ * Delete multiple trajectories with one filesystem scan.
1102
+ */
1103
+ async deleteManyWithSummary(ids) {
1104
+ const summary = {
1105
+ removedTrajectories: 0,
1106
+ deletedJsonFiles: 0,
1107
+ deletedMarkdownFiles: 0,
1108
+ deletedTraceFiles: 0,
1109
+ deletedCompactionFiles: 0
1110
+ };
1111
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
1112
+ const deletedPaths = /* @__PURE__ */ new Set();
1113
+ for (const paths of pathsById.values()) {
1114
+ for (const filePath of paths) {
1115
+ if (deletedPaths.has(filePath)) {
1116
+ continue;
1117
+ }
1118
+ deletedPaths.add(filePath);
1119
+ await this.removeTrajectoryFile(filePath, summary);
1120
+ }
1121
+ }
1122
+ return summary;
1123
+ }
1045
1124
  /**
1046
1125
  * Close storage (no-op for file storage)
1047
1126
  */
1048
1127
  async close() {
1049
1128
  }
1050
1129
  // Private helpers
1130
+ getActiveCandidatePaths(id) {
1131
+ if (!isSafeTrajectoryId(id)) {
1132
+ return [];
1133
+ }
1134
+ return [
1135
+ join(this.activeDir, id, TRAJECTORY_FILE),
1136
+ // Legacy layout from v0.5.x and earlier.
1137
+ join(this.activeDir, `${id}.json`)
1138
+ ];
1139
+ }
1140
+ async loadAllTrajectories() {
1141
+ const files = await this.listTrajectoryFiles();
1142
+ const trajectories = /* @__PURE__ */ new Map();
1143
+ for (const filePath of files) {
1144
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
1145
+ if (!trajectory2) {
1146
+ continue;
1147
+ }
1148
+ const current = trajectories.get(trajectory2.id);
1149
+ if (!current || this.isNewerTrajectory(trajectory2, current)) {
1150
+ trajectories.set(trajectory2.id, trajectory2);
1151
+ }
1152
+ }
1153
+ return Array.from(trajectories.values());
1154
+ }
1155
+ async listTrajectoryFiles() {
1156
+ const [activeFiles, completedFiles] = await Promise.all([
1157
+ this.collectTrajectoryFiles(this.activeDir),
1158
+ this.collectTrajectoryFiles(this.completedDir)
1159
+ ]);
1160
+ return [...activeFiles, ...completedFiles];
1161
+ }
1162
+ async collectTrajectoryFiles(dir) {
1163
+ const files = [];
1164
+ await this.walkJsonFilesInto(dir, files);
1165
+ return files;
1166
+ }
1167
+ async findTrajectoryFilePaths(id) {
1168
+ const pathsById = await this.findTrajectoryFilePathsForIds([id]);
1169
+ return pathsById.get(id) ?? [];
1170
+ }
1171
+ async findTrajectoryFilePathsForIds(ids) {
1172
+ const targetIds = new Set(Array.from(ids).filter(isSafeTrajectoryId));
1173
+ const pathsById = new Map(
1174
+ Array.from(targetIds).map((id) => [id, []])
1175
+ );
1176
+ if (targetIds.size === 0) {
1177
+ return pathsById;
1178
+ }
1179
+ const allFiles = await this.listTrajectoryFiles();
1180
+ for (const filePath of allFiles) {
1181
+ const trajectoryId = this.getTrajectoryIdFromPath(filePath);
1182
+ if (!trajectoryId || !targetIds.has(trajectoryId)) {
1183
+ continue;
1184
+ }
1185
+ pathsById.get(trajectoryId)?.push(filePath);
1186
+ }
1187
+ return pathsById;
1188
+ }
1189
+ getTrajectoryIdFromPath(filePath) {
1190
+ if (basename(filePath) === TRAJECTORY_FILE) {
1191
+ const id = basename(dirname(filePath));
1192
+ return isSafeTrajectoryId(id) ? id : void 0;
1193
+ }
1194
+ const name = basename(filePath);
1195
+ if (name.endsWith(".json")) {
1196
+ const id = name.slice(0, -".json".length);
1197
+ return isSafeTrajectoryId(id) ? id : void 0;
1198
+ }
1199
+ return void 0;
1200
+ }
1201
+ async removeTrajectoryFiles(paths, exceptPath) {
1202
+ const summary = this.emptyDeleteSummary();
1203
+ for (const filePath of paths) {
1204
+ if (filePath === exceptPath) {
1205
+ continue;
1206
+ }
1207
+ await this.removeTrajectoryFile(filePath, summary);
1208
+ }
1209
+ }
1210
+ async removeTrajectoryFile(filePath, summary) {
1211
+ if (basename(filePath) === TRAJECTORY_FILE) {
1212
+ const trajectoryDir = dirname(filePath);
1213
+ await this.countDirectoryTrajectoryFiles(trajectoryDir, summary);
1214
+ await this.removeFileIfExists(
1215
+ join(dirname(trajectoryDir), `${basename(trajectoryDir)}.trace.json`),
1216
+ "trace",
1217
+ summary
1218
+ );
1219
+ await rm(trajectoryDir, { recursive: true, force: true });
1220
+ return;
1221
+ }
1222
+ await this.removeFileIfExists(filePath, "json", summary);
1223
+ await this.removeFileIfExists(
1224
+ getMarkdownOutputPath(filePath),
1225
+ "markdown",
1226
+ summary
1227
+ );
1228
+ await this.removeFileIfExists(
1229
+ getTraceOutputPath(filePath),
1230
+ "trace",
1231
+ summary
1232
+ );
1233
+ await this.removeFileIfExists(
1234
+ getLegacyCompactionMarkerPath(filePath),
1235
+ "compaction",
1236
+ summary
1237
+ );
1238
+ }
1239
+ async countDirectoryTrajectoryFiles(trajectoryDir, summary) {
1240
+ await this.countFileIfExists(
1241
+ join(trajectoryDir, TRAJECTORY_FILE),
1242
+ "json",
1243
+ summary
1244
+ );
1245
+ await this.countFileIfExists(
1246
+ join(trajectoryDir, SUMMARY_FILE),
1247
+ "markdown",
1248
+ summary
1249
+ );
1250
+ await this.countFileIfExists(
1251
+ join(trajectoryDir, `${basename(trajectoryDir)}.trace.json`),
1252
+ "trace",
1253
+ summary
1254
+ );
1255
+ await this.countFileIfExists(
1256
+ join(trajectoryDir, "trace.json"),
1257
+ "trace",
1258
+ summary
1259
+ );
1260
+ await this.countFileIfExists(
1261
+ join(trajectoryDir, COMPACTION_FILE),
1262
+ "compaction",
1263
+ summary
1264
+ );
1265
+ }
1266
+ async removeFileIfExists(path, kind, summary) {
1267
+ if (!existsSync(path)) {
1268
+ return;
1269
+ }
1270
+ await rm(path, { force: true });
1271
+ this.incrementDeleteSummary(kind, summary);
1272
+ }
1273
+ async countFileIfExists(path, kind, summary) {
1274
+ if (existsSync(path)) {
1275
+ this.incrementDeleteSummary(kind, summary);
1276
+ }
1277
+ }
1278
+ incrementDeleteSummary(kind, summary) {
1279
+ if (kind === "json") {
1280
+ summary.deletedJsonFiles += 1;
1281
+ summary.removedTrajectories += 1;
1282
+ } else if (kind === "markdown") {
1283
+ summary.deletedMarkdownFiles += 1;
1284
+ } else if (kind === "trace") {
1285
+ summary.deletedTraceFiles += 1;
1286
+ } else {
1287
+ summary.deletedCompactionFiles += 1;
1288
+ }
1289
+ }
1290
+ emptyDeleteSummary() {
1291
+ return {
1292
+ removedTrajectories: 0,
1293
+ deletedJsonFiles: 0,
1294
+ deletedMarkdownFiles: 0,
1295
+ deletedTraceFiles: 0,
1296
+ deletedCompactionFiles: 0
1297
+ };
1298
+ }
1299
+ async listCompactionMarkerFiles() {
1300
+ const markerPaths = [];
1301
+ await this.walkFilesInto(
1302
+ this.activeDir,
1303
+ markerPaths,
1304
+ isCompactionMarkerFile
1305
+ );
1306
+ await this.walkFilesInto(
1307
+ this.completedDir,
1308
+ markerPaths,
1309
+ isCompactionMarkerFile
1310
+ );
1311
+ return markerPaths;
1312
+ }
1313
+ getCompactionMarkerPath(filePath, id) {
1314
+ if (basename(filePath) === TRAJECTORY_FILE) {
1315
+ return join(dirname(filePath), COMPACTION_FILE);
1316
+ }
1317
+ return join(dirname(filePath), `${id}${LEGACY_COMPACTION_SUFFIX}`);
1318
+ }
1319
+ getTrajectoryIdFromCompactionMarkerPath(markerPath) {
1320
+ if (basename(markerPath) === COMPACTION_FILE) {
1321
+ const id = basename(dirname(markerPath));
1322
+ return id.startsWith("traj_") ? id : void 0;
1323
+ }
1324
+ const markerName = basename(markerPath);
1325
+ return markerName.endsWith(LEGACY_COMPACTION_SUFFIX) ? markerName.slice(0, -LEGACY_COMPACTION_SUFFIX.length) : void 0;
1326
+ }
1327
+ async migrateLegacyIndexCompactionMarkers() {
1328
+ const indexPath = join(this.trajectoriesDir, "index.json");
1329
+ if (!existsSync(indexPath)) {
1330
+ return;
1331
+ }
1332
+ let parsed;
1333
+ try {
1334
+ parsed = JSON.parse(await readFile(indexPath, "utf-8"));
1335
+ } catch {
1336
+ return;
1337
+ }
1338
+ if (parsed === null || typeof parsed !== "object") {
1339
+ return;
1340
+ }
1341
+ const trajectories = parsed.trajectories;
1342
+ if (trajectories === null || typeof trajectories !== "object" || Array.isArray(trajectories)) {
1343
+ return;
1344
+ }
1345
+ await Promise.all(
1346
+ Object.entries(trajectories).map(async ([id, entry]) => {
1347
+ if (entry === null || typeof entry !== "object" || !isSafeTrajectoryId(id)) {
1348
+ return;
1349
+ }
1350
+ const compactedInto = entry.compactedInto;
1351
+ const path = entry.path;
1352
+ if (typeof compactedInto !== "string") {
1353
+ return;
1354
+ }
1355
+ const paths = typeof path === "string" && existsSync(path) && this.isPathInsideTrajectoriesDir(path) ? [path] : await this.findTrajectoryFilePaths(id);
1356
+ if (paths.length === 0) return;
1357
+ const marker = {
1358
+ trajectoryId: id,
1359
+ compactedInto,
1360
+ compactedAt: (/* @__PURE__ */ new Date()).toISOString()
1361
+ };
1362
+ await Promise.all(
1363
+ paths.map(
1364
+ (filePath) => writeFile(
1365
+ this.getCompactionMarkerPath(filePath, id),
1366
+ JSON.stringify(marker, null, 2),
1367
+ "utf-8"
1368
+ )
1369
+ )
1370
+ );
1371
+ })
1372
+ );
1373
+ }
1374
+ isPathInsideTrajectoriesDir(path) {
1375
+ const rel = relative(resolve(this.trajectoriesDir), resolve(path));
1376
+ return Boolean(rel && !rel.startsWith("..") && !isAbsolute(rel));
1377
+ }
1378
+ async walkFilesInto(dir, out, predicate) {
1379
+ let entries;
1380
+ try {
1381
+ entries = await readdir(dir, { withFileTypes: true });
1382
+ } catch (error) {
1383
+ if (error.code === "ENOENT") return;
1384
+ throw error;
1385
+ }
1386
+ for (const entry of entries) {
1387
+ const entryPath = join(dir, entry.name);
1388
+ if (entry.isDirectory()) {
1389
+ await this.walkFilesInto(entryPath, out, predicate);
1390
+ } else if (entry.isFile() && predicate(entry.name)) {
1391
+ out.push(entryPath);
1392
+ }
1393
+ }
1394
+ }
1395
+ getSortValue(trajectory2, sortBy) {
1396
+ if (sortBy === "title") {
1397
+ return trajectory2.task.title;
1398
+ }
1399
+ return trajectory2[sortBy] ?? "";
1400
+ }
1401
+ toSummary(trajectory2) {
1402
+ return {
1403
+ id: trajectory2.id,
1404
+ title: trajectory2.task.title,
1405
+ status: trajectory2.status,
1406
+ startedAt: trajectory2.startedAt,
1407
+ completedAt: trajectory2.completedAt,
1408
+ confidence: trajectory2.retrospective?.confidence,
1409
+ chapterCount: trajectory2.chapters.length,
1410
+ decisionCount: trajectory2.chapters.reduce(
1411
+ (count, chapter) => count + chapter.events.filter((event) => event.type === "decision").length,
1412
+ 0
1413
+ )
1414
+ };
1415
+ }
1416
+ isNewerTrajectory(candidate, current) {
1417
+ const candidateTime = new Date(
1418
+ candidate.completedAt ?? candidate.startedAt
1419
+ ).getTime();
1420
+ const currentTime = new Date(
1421
+ current.completedAt ?? current.startedAt
1422
+ ).getTime();
1423
+ return candidateTime > currentTime;
1424
+ }
1051
1425
  /**
1052
1426
  * Read a trajectory file and return a tagged result so callers can
1053
1427
  * distinguish missing files, malformed JSON, and schema violations.
@@ -1087,82 +1461,25 @@ var FileStorage = class {
1087
1461
  const result = await this.readTrajectoryFile(path);
1088
1462
  return result.ok ? result.trajectory : null;
1089
1463
  }
1090
- /**
1091
- * Read and parse the on-disk index.
1092
- *
1093
- * Tolerances (belt-and-braces against the read/write race):
1094
- * - ENOENT: first-run, return an empty index silently.
1095
- * - Empty file: a concurrent writer truncated index.json in "w" mode
1096
- * right before we read. Return an empty index silently — this is
1097
- * not a real corruption, just an interleaving the mutex + atomic
1098
- * rename should already prevent. Logging here would be noise.
1099
- * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1100
- * edit, disk error, etc). Log it and return an empty index so the
1101
- * caller can recover, but keep the log so the problem is visible.
1102
- */
1103
- async loadIndex() {
1104
- let content;
1105
- try {
1106
- content = await readFile(this.indexPath, "utf-8");
1107
- } catch (error) {
1108
- if (error.code !== "ENOENT") {
1109
- console.error(
1110
- "Error loading trajectory index, using empty index:",
1111
- error
1112
- );
1113
- }
1114
- return this.emptyIndex();
1115
- }
1116
- if (content.length === 0) {
1117
- return this.emptyIndex();
1118
- }
1119
- try {
1120
- return JSON.parse(content);
1121
- } catch (error) {
1122
- console.error(
1123
- "Error loading trajectory index, using empty index:",
1124
- error
1125
- );
1126
- return this.emptyIndex();
1127
- }
1128
- }
1129
- emptyIndex() {
1130
- return {
1131
- version: 1,
1132
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1133
- trajectories: {}
1134
- };
1135
- }
1136
- /**
1137
- * Atomic write: stage into a process-unique temp path in the same directory
1138
- * and then rename over the live file. `rename` is atomic on POSIX, so
1139
- * concurrent readers in any process either see the old complete file or
1140
- * the new complete file — never a half-written / zero-byte state.
1141
- *
1142
- * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1143
- * read-modify-write cycle stays serialized; the unique temp name also keeps
1144
- * parallel writers in other processes from colliding on a shared tmp path.
1145
- */
1146
- async saveIndex(index) {
1147
- index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
1148
- const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1149
- await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1150
- await rename(tmpPath, this.indexPath);
1151
- }
1152
- async updateIndex(trajectory2, filePath) {
1153
- await withIndexLock(this.indexPath, async () => {
1154
- const index = await this.loadIndex();
1155
- index.trajectories[trajectory2.id] = {
1156
- title: trajectory2.task.title,
1157
- status: trajectory2.status,
1158
- startedAt: trajectory2.startedAt,
1159
- completedAt: trajectory2.completedAt,
1160
- path: filePath
1161
- };
1162
- await this.saveIndex(index);
1163
- });
1164
- }
1165
1464
  };
1465
+ function isTrajectoryJsonFile(name) {
1466
+ return name === TRAJECTORY_FILE || name.endsWith(".json") && name !== "index.json" && !name.endsWith(".trace.json") && !name.endsWith(LEGACY_COMPACTION_SUFFIX) && name !== COMPACTION_FILE;
1467
+ }
1468
+ function isSafeTrajectoryId(id) {
1469
+ return id.length > 0 && !id.includes("..") && !id.includes("/") && !id.includes("\\");
1470
+ }
1471
+ function isCompactionMarkerFile(name) {
1472
+ return name === COMPACTION_FILE || name.endsWith(LEGACY_COMPACTION_SUFFIX);
1473
+ }
1474
+ function getMarkdownOutputPath(outputPath) {
1475
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".md") : `${outputPath}.md`;
1476
+ }
1477
+ function getTraceOutputPath(outputPath) {
1478
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".trace.json") : `${outputPath}.trace.json`;
1479
+ }
1480
+ function getLegacyCompactionMarkerPath(outputPath) {
1481
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(LEGACY_COMPACTION_SUFFIX) : `${outputPath}${LEGACY_COMPACTION_SUFFIX}`;
1482
+ }
1166
1483
 
1167
1484
  // src/sdk/client.ts
1168
1485
  var require2 = createRequire(import.meta.url);
@@ -1204,7 +1521,7 @@ function resolveTrajectoryCliInvocation() {
1204
1521
  );
1205
1522
  const binEntry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.trail ?? (pkg.name ? pkg.bin?.[pkg.name] : void 0);
1206
1523
  if (binEntry) {
1207
- const cliPath = resolvePath(dirname(packageJsonPath), binEntry);
1524
+ const cliPath = resolvePath(dirname2(packageJsonPath), binEntry);
1208
1525
  if (existsSync2(cliPath)) {
1209
1526
  return { command: process.execPath, args: [cliPath] };
1210
1527
  }
@@ -1250,7 +1567,7 @@ async function compactWorkflow(workflowId, options) {
1250
1567
  if (options?.discardSources) {
1251
1568
  args.push("--discard-sources");
1252
1569
  }
1253
- return new Promise((resolve, reject) => {
1570
+ return new Promise((resolve2, reject) => {
1254
1571
  const child = spawn(cli.command, args, {
1255
1572
  cwd: options?.cwd,
1256
1573
  stdio: ["ignore", "pipe", "pipe"]
@@ -1278,7 +1595,7 @@ async function compactWorkflow(workflowId, options) {
1278
1595
  }
1279
1596
  try {
1280
1597
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
1281
- resolve(parseCompactWorkflowOutput(stdout));
1598
+ resolve2(parseCompactWorkflowOutput(stdout));
1282
1599
  } catch (error) {
1283
1600
  reject(
1284
1601
  error instanceof Error ? error : new Error("compactWorkflow failed: unable to parse CLI output")
@@ -2103,4 +2420,4 @@ export {
2103
2420
  getCommitsBetween,
2104
2421
  getFilesChangedBetween
2105
2422
  };
2106
- //# sourceMappingURL=chunk-27AQPWHK.js.map
2423
+ //# sourceMappingURL=chunk-JMH3Z5BB.js.map