agent-trajectories 0.5.8 → 0.6.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.
@@ -666,17 +666,35 @@ 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 { basename, dirname, isAbsolute, join, relative } 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";
690
+ var DEFAULT_TRAJECTORY_DATA_DIR = join(
691
+ ".agentworkforce",
692
+ "trajectories"
693
+ );
694
+ var LEGACY_TRAJECTORY_DATA_DIR = ".trajectories";
695
+ function getDefaultTrajectoryDataDir(baseDir = process.cwd()) {
696
+ return join(baseDir, DEFAULT_TRAJECTORY_DATA_DIR);
697
+ }
680
698
  function expandPath(path) {
681
699
  if (path.startsWith("~")) {
682
700
  return join(process.env.HOME ?? "", path.slice(1));
@@ -697,64 +715,60 @@ function describeReadFailure(reason, error) {
697
715
  if (error instanceof Error) return error.message;
698
716
  return String(error);
699
717
  }
700
- var indexLocks = /* @__PURE__ */ new Map();
701
- function withIndexLock(path, task) {
702
- const prev = indexLocks.get(path) ?? Promise.resolve();
703
- const next = prev.then(task, task);
704
- indexLocks.set(
705
- path,
706
- next.catch(() => void 0)
707
- );
708
- return next;
709
- }
710
718
  var FileStorage = class {
711
719
  baseDir;
712
720
  trajectoriesDir;
713
721
  activeDir;
714
722
  completedDir;
715
- indexPath;
716
723
  lastReconcileSummary;
724
+ shouldMigrateLegacyDefault = false;
717
725
  constructor(baseDir) {
718
726
  this.baseDir = baseDir ?? process.cwd();
719
727
  const dataDir = process.env.TRAJECTORIES_DATA_DIR;
720
728
  if (dataDir) {
721
729
  this.trajectoriesDir = expandPath(dataDir);
722
730
  } else {
723
- this.trajectoriesDir = join(this.baseDir, ".trajectories");
731
+ this.trajectoriesDir = getDefaultTrajectoryDataDir(this.baseDir);
732
+ this.shouldMigrateLegacyDefault = true;
724
733
  }
725
734
  this.activeDir = join(this.trajectoriesDir, "active");
726
735
  this.completedDir = join(this.trajectoriesDir, "completed");
727
- this.indexPath = join(this.trajectoriesDir, "index.json");
728
736
  }
729
737
  /**
730
738
  * Initialize storage directories
731
739
  */
732
740
  async initialize() {
741
+ await this.migrateLegacyDefaultDir();
733
742
  await mkdir(this.trajectoriesDir, { recursive: true });
734
743
  await mkdir(this.activeDir, { recursive: true });
735
744
  await mkdir(this.completedDir, { recursive: true });
736
- if (!existsSync(this.indexPath)) {
737
- await withIndexLock(this.indexPath, async () => {
738
- if (!existsSync(this.indexPath)) {
739
- await this.saveIndex(this.emptyIndex());
740
- }
741
- });
742
- }
745
+ await this.migrateLegacyIndexCompactionMarkers();
746
+ await rm(join(this.trajectoriesDir, "index.json"), { force: true });
743
747
  await this.reconcileIndex();
744
748
  }
749
+ async migrateLegacyDefaultDir() {
750
+ if (!this.shouldMigrateLegacyDefault) {
751
+ return;
752
+ }
753
+ const legacyDir = join(this.baseDir, LEGACY_TRAJECTORY_DATA_DIR);
754
+ if (!existsSync(legacyDir) || existsSync(this.trajectoriesDir)) {
755
+ return;
756
+ }
757
+ await mkdir(dirname(this.trajectoriesDir), { recursive: true });
758
+ await rename(legacyDir, this.trajectoriesDir);
759
+ }
745
760
  /**
746
- * Scan active/ and completed/ recursively and add any trajectory files
747
- * missing from the index. Existing entries are preserved reconcile
748
- * only adds, never removes.
761
+ * Scan active/ and completed/ recursively and report trajectory files
762
+ * that can be loaded plus files that should be surfaced by doctor.
749
763
  *
750
764
  * Handles three on-disk layouts in completed/:
751
765
  * - flat: completed/{id}.json (legacy workforce data)
752
- * - monthly: completed/YYYY-MM/{id}.json (current save() writes)
766
+ * - monthly: completed/YYYY-MM/{id}.json (legacy monthly layout)
767
+ * - directory: completed/YYYY-MM/{id}/trajectory.json (current layout)
753
768
  * - nested: completed/.../{id}.json (defensive — any depth)
754
769
  *
755
- * Returns a ReconcileSummary so tests and CLI wrappers can observe
756
- * outcomes without parsing logs. Only writes the index if anything was
757
- * added.
770
+ * The method name is kept for callers such as `trail doctor`, but no
771
+ * shared index file is written.
758
772
  */
759
773
  async reconcileIndex() {
760
774
  const summary = {
@@ -766,58 +780,29 @@ var FileStorage = class {
766
780
  skippedIoError: 0,
767
781
  failures: []
768
782
  };
769
- await withIndexLock(this.indexPath, async () => {
770
- const index = await this.loadIndex();
771
- const before = Object.keys(index.trajectories).length;
772
- const discovered = [];
773
- try {
774
- const activeFiles = await readdir(this.activeDir);
775
- for (const file of activeFiles) {
776
- if (!file.endsWith(".json")) continue;
777
- discovered.push(join(this.activeDir, file));
783
+ const discovered = await this.listTrajectoryFiles();
784
+ for (const filePath of discovered) {
785
+ summary.scanned += 1;
786
+ const result = await this.readTrajectoryFile(filePath);
787
+ if (!result.ok) {
788
+ if (result.reason === "malformed_json") {
789
+ summary.skippedMalformedJson += 1;
790
+ } else if (result.reason === "schema_violation") {
791
+ summary.skippedSchemaViolation += 1;
792
+ } else {
793
+ summary.skippedIoError += 1;
778
794
  }
779
- } catch (error) {
780
- if (error.code !== "ENOENT") throw error;
781
- }
782
- await this.walkJsonFilesInto(this.completedDir, discovered);
783
- for (const filePath of discovered) {
784
- summary.scanned += 1;
785
- const result = await this.readTrajectoryFile(filePath);
786
- if (!result.ok) {
787
- if (result.reason === "malformed_json") {
788
- summary.skippedMalformedJson += 1;
789
- } else if (result.reason === "schema_violation") {
790
- summary.skippedSchemaViolation += 1;
791
- } else {
792
- summary.skippedIoError += 1;
793
- }
794
- summary.failures.push({
795
- path: result.path,
796
- reason: result.reason,
797
- message: describeReadFailure(result.reason, result.error)
798
- });
799
- continue;
800
- }
801
- const trajectory2 = result.trajectory;
802
- if (index.trajectories[trajectory2.id]) {
803
- summary.alreadyIndexed += 1;
804
- continue;
805
- }
806
- index.trajectories[trajectory2.id] = {
807
- title: trajectory2.task.title,
808
- status: trajectory2.status,
809
- startedAt: trajectory2.startedAt,
810
- completedAt: trajectory2.completedAt,
811
- path: filePath
812
- };
813
- summary.added += 1;
814
- }
815
- if (Object.keys(index.trajectories).length !== before) {
816
- await this.saveIndex(index);
795
+ summary.failures.push({
796
+ path: result.path,
797
+ reason: result.reason,
798
+ message: describeReadFailure(result.reason, result.error)
799
+ });
800
+ continue;
817
801
  }
818
- });
802
+ summary.added += 1;
803
+ }
819
804
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
820
- if (summary.added > 0 || hadSkips) {
805
+ if (hadSkips) {
821
806
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
822
807
  if (summary.skippedMalformedJson > 0) {
823
808
  parts.push(`malformed: ${summary.skippedMalformedJson}`);
@@ -842,7 +827,7 @@ var FileStorage = class {
842
827
  return this.lastReconcileSummary;
843
828
  }
844
829
  /**
845
- * Move trajectory files that fail to load into `.trajectories/invalid/`
830
+ * Move trajectory files that fail to load into `.agentworkforce/trajectories/invalid/`
846
831
  * so reconcile no longer scans them. Only quarantines parse and schema
847
832
  * failures — transient io_error failures are left in place because the
848
833
  * file may load fine on the next attempt.
@@ -899,7 +884,7 @@ var FileStorage = class {
899
884
  return dest;
900
885
  }
901
886
  /**
902
- * Recursively collect all .json file paths under `dir` into `out`.
887
+ * Recursively collect trajectory JSON file paths under `dir` into `out`.
903
888
  * Silently treats a missing directory as empty.
904
889
  */
905
890
  async walkJsonFilesInto(dir, out) {
@@ -914,7 +899,7 @@ var FileStorage = class {
914
899
  const entryPath = join(dir, entry.name);
915
900
  if (entry.isDirectory()) {
916
901
  await this.walkJsonFilesInto(entryPath, out);
917
- } else if (entry.isFile() && entry.name.endsWith(".json")) {
902
+ } else if (entry.isFile() && isTrajectoryJsonFile(entry.name)) {
918
903
  out.push(entryPath);
919
904
  }
920
905
  }
@@ -938,56 +923,43 @@ var FileStorage = class {
938
923
  }
939
924
  const trajectory2 = validation.data;
940
925
  const isCompleted = trajectory2.status === "completed" || trajectory2.status === "abandoned";
941
- let filePath;
926
+ const existingPaths = await this.findTrajectoryFilePaths(trajectory2.id);
927
+ let trajectoryDir;
942
928
  if (isCompleted) {
943
929
  const date = new Date(trajectory2.completedAt ?? trajectory2.startedAt);
944
930
  const monthDir = join(
945
931
  this.completedDir,
946
932
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
947
933
  );
948
- await mkdir(monthDir, { recursive: true });
949
- filePath = join(monthDir, `${trajectory2.id}.json`);
950
- const activePath = join(this.activeDir, `${trajectory2.id}.json`);
951
- if (existsSync(activePath)) {
952
- await unlink(activePath);
953
- }
954
- const mdPath = join(monthDir, `${trajectory2.id}.md`);
955
- const markdown = exportToMarkdown(trajectory2);
956
- await writeFile(mdPath, markdown, "utf-8");
934
+ trajectoryDir = join(monthDir, trajectory2.id);
957
935
  } else {
958
- filePath = join(this.activeDir, `${trajectory2.id}.json`);
936
+ trajectoryDir = join(this.activeDir, trajectory2.id);
937
+ }
938
+ const filePath = join(trajectoryDir, TRAJECTORY_FILE);
939
+ await this.removeTrajectoryFiles(existingPaths, filePath);
940
+ await mkdir(trajectoryDir, { recursive: true });
941
+ if (isCompleted) {
942
+ const markdown = exportToMarkdown(trajectory2);
943
+ await writeFile(join(trajectoryDir, SUMMARY_FILE), markdown, "utf-8");
959
944
  }
960
945
  await writeFile(filePath, JSON.stringify(trajectory2, null, 2), "utf-8");
961
- await this.updateIndex(trajectory2, filePath);
962
946
  }
963
947
  /**
964
948
  * Get a trajectory by ID
965
949
  */
966
950
  async get(id) {
967
- const activePath = join(this.activeDir, `${id}.json`);
968
- if (existsSync(activePath)) {
969
- return this.readTrajectoryOrNull(activePath);
970
- }
971
- const index = await this.loadIndex();
972
- const entry = index.trajectories[id];
973
- if (entry?.path && existsSync(entry.path)) {
974
- return this.readTrajectoryOrNull(entry.path);
975
- }
976
- try {
977
- const flatPath = join(this.completedDir, `${id}.json`);
978
- if (existsSync(flatPath)) {
979
- return this.readTrajectoryOrNull(flatPath);
980
- }
981
- const months = await readdir(this.completedDir);
982
- for (const month of months) {
983
- const filePath = join(this.completedDir, month, `${id}.json`);
984
- if (existsSync(filePath)) {
985
- return this.readTrajectoryOrNull(filePath);
986
- }
951
+ for (const filePath of this.getActiveCandidatePaths(id)) {
952
+ if (!existsSync(filePath)) continue;
953
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
954
+ if (trajectory2?.id === id) {
955
+ return trajectory2;
987
956
  }
988
- } catch (error) {
989
- if (error.code !== "ENOENT") {
990
- console.error("Error searching completed trajectories:", error);
957
+ }
958
+ const paths = await this.findTrajectoryFilePaths(id);
959
+ for (const filePath of paths) {
960
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
961
+ if (trajectory2?.id === id) {
962
+ return trajectory2;
991
963
  }
992
964
  }
993
965
  return null;
@@ -996,107 +968,61 @@ var FileStorage = class {
996
968
  * Get the currently active trajectory
997
969
  */
998
970
  async getActive() {
999
- try {
1000
- const files = await readdir(this.activeDir);
1001
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
1002
- if (jsonFiles.length === 0) {
1003
- return null;
1004
- }
1005
- let mostRecent = null;
1006
- let mostRecentTime = 0;
1007
- for (const file of jsonFiles) {
1008
- const trajectory2 = await this.readTrajectoryOrNull(
1009
- join(this.activeDir, file)
1010
- );
1011
- if (trajectory2) {
1012
- const startTime = new Date(trajectory2.startedAt).getTime();
1013
- if (startTime > mostRecentTime) {
1014
- mostRecentTime = startTime;
1015
- mostRecent = trajectory2;
1016
- }
1017
- }
1018
- }
1019
- return mostRecent;
1020
- } catch (error) {
1021
- if (error.code === "ENOENT") {
1022
- return null;
1023
- }
1024
- console.error("Error reading active trajectories:", error);
971
+ const activeFiles = await this.collectTrajectoryFiles(this.activeDir);
972
+ if (activeFiles.length === 0) {
1025
973
  return null;
1026
974
  }
975
+ let mostRecent = null;
976
+ let mostRecentTime = 0;
977
+ for (const filePath of activeFiles) {
978
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
979
+ if (trajectory2?.status !== "active") continue;
980
+ const startTime = new Date(trajectory2.startedAt).getTime();
981
+ if (startTime > mostRecentTime) {
982
+ mostRecentTime = startTime;
983
+ mostRecent = trajectory2;
984
+ }
985
+ }
986
+ return mostRecent;
1027
987
  }
1028
988
  /**
1029
989
  * List trajectories with optional filtering
1030
990
  */
1031
991
  async list(query) {
1032
- const index = await this.loadIndex();
1033
- let entries = Object.entries(index.trajectories);
992
+ let trajectories = await this.loadAllTrajectories();
1034
993
  if (query.status) {
1035
- entries = entries.filter(([, entry]) => entry.status === query.status);
994
+ trajectories = trajectories.filter((t) => t.status === query.status);
1036
995
  }
1037
996
  if (query.since) {
1038
997
  const sinceTime = new Date(query.since).getTime();
1039
- entries = entries.filter(
1040
- ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime
998
+ trajectories = trajectories.filter(
999
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() >= sinceTime
1041
1000
  );
1042
1001
  }
1043
1002
  if (query.until) {
1044
1003
  const untilTime = new Date(query.until).getTime();
1045
- entries = entries.filter(
1046
- ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime
1004
+ trajectories = trajectories.filter(
1005
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() <= untilTime
1047
1006
  );
1048
1007
  }
1049
1008
  const sortBy = query.sortBy ?? "startedAt";
1050
1009
  const sortOrder = query.sortOrder ?? "desc";
1051
- entries.sort((a, b) => {
1052
- const aVal = a[1][sortBy] ?? "";
1053
- const bVal = b[1][sortBy] ?? "";
1010
+ trajectories.sort((a, b) => {
1011
+ const aVal = this.getSortValue(a, sortBy);
1012
+ const bVal = this.getSortValue(b, sortBy);
1054
1013
  const cmp = String(aVal).localeCompare(String(bVal));
1055
1014
  return sortOrder === "asc" ? cmp : -cmp;
1056
1015
  });
1057
1016
  const offset = query.offset ?? 0;
1058
1017
  const limit = query.limit ?? 500;
1059
- entries = entries.slice(offset, offset + limit);
1060
- return Promise.all(
1061
- entries.map(async ([id, entry]) => {
1062
- const trajectory2 = await this.get(id);
1063
- return {
1064
- id,
1065
- title: entry.title,
1066
- status: entry.status,
1067
- startedAt: entry.startedAt,
1068
- completedAt: entry.completedAt,
1069
- confidence: trajectory2?.retrospective?.confidence,
1070
- chapterCount: trajectory2?.chapters.length ?? 0,
1071
- decisionCount: trajectory2?.chapters.reduce(
1072
- (count, chapter) => count + chapter.events.filter((e) => e.type === "decision").length,
1073
- 0
1074
- ) ?? 0
1075
- };
1076
- })
1077
- );
1018
+ trajectories = trajectories.slice(offset, offset + limit);
1019
+ return trajectories.map((trajectory2) => this.toSummary(trajectory2));
1078
1020
  }
1079
1021
  /**
1080
1022
  * Delete a trajectory
1081
1023
  */
1082
1024
  async delete(id) {
1083
- const activePath = join(this.activeDir, `${id}.json`);
1084
- if (existsSync(activePath)) {
1085
- await unlink(activePath);
1086
- }
1087
- await withIndexLock(this.indexPath, async () => {
1088
- const index = await this.loadIndex();
1089
- const entry = index.trajectories[id];
1090
- if (entry?.path && existsSync(entry.path)) {
1091
- await unlink(entry.path);
1092
- const mdPath = entry.path.replace(".json", ".md");
1093
- if (existsSync(mdPath)) {
1094
- await unlink(mdPath);
1095
- }
1096
- }
1097
- delete index.trajectories[id];
1098
- await this.saveIndex(index);
1099
- });
1025
+ await this.deleteWithSummary(id);
1100
1026
  }
1101
1027
  /**
1102
1028
  * Search trajectories by text
@@ -1129,12 +1055,395 @@ var FileStorage = class {
1129
1055
  }
1130
1056
  return matches;
1131
1057
  }
1058
+ /**
1059
+ * Mark a trajectory as compacted without writing to a shared index.
1060
+ */
1061
+ async markCompacted(id, compactedInto) {
1062
+ const markedIds = await this.markCompactedMany([id], compactedInto);
1063
+ return markedIds.has(id);
1064
+ }
1065
+ /**
1066
+ * Mark multiple trajectories as compacted with one filesystem scan.
1067
+ */
1068
+ async markCompactedMany(ids, compactedInto) {
1069
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
1070
+ const markedIds = /* @__PURE__ */ new Set();
1071
+ const compactedAt = (/* @__PURE__ */ new Date()).toISOString();
1072
+ const writes = [];
1073
+ for (const [id, paths] of pathsById.entries()) {
1074
+ if (paths.length === 0) {
1075
+ continue;
1076
+ }
1077
+ markedIds.add(id);
1078
+ const marker = {
1079
+ trajectoryId: id,
1080
+ compactedInto,
1081
+ compactedAt
1082
+ };
1083
+ for (const filePath of paths) {
1084
+ writes.push(
1085
+ writeFile(
1086
+ this.getCompactionMarkerPath(filePath, id),
1087
+ JSON.stringify(marker, null, 2),
1088
+ "utf-8"
1089
+ )
1090
+ );
1091
+ }
1092
+ }
1093
+ await Promise.all(writes);
1094
+ return markedIds;
1095
+ }
1096
+ /**
1097
+ * Return trajectory IDs that have a per-trajectory compaction marker.
1098
+ */
1099
+ async getCompactedTrajectoryIds() {
1100
+ const markerPaths = await this.listCompactionMarkerFiles();
1101
+ const compactedIds = /* @__PURE__ */ new Set();
1102
+ for (const markerPath of markerPaths) {
1103
+ try {
1104
+ const marker = JSON.parse(
1105
+ await readFile(markerPath, "utf-8")
1106
+ );
1107
+ const trajectoryId = typeof marker.trajectoryId === "string" ? marker.trajectoryId : this.getTrajectoryIdFromCompactionMarkerPath(markerPath);
1108
+ if (trajectoryId && typeof marker.compactedInto === "string") {
1109
+ compactedIds.add(trajectoryId);
1110
+ }
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ return compactedIds;
1115
+ }
1116
+ /**
1117
+ * Delete a trajectory and return file counts for CLI reporting.
1118
+ */
1119
+ async deleteWithSummary(id) {
1120
+ return this.deleteManyWithSummary([id]);
1121
+ }
1122
+ /**
1123
+ * Delete multiple trajectories with one filesystem scan.
1124
+ */
1125
+ async deleteManyWithSummary(ids) {
1126
+ const summary = {
1127
+ removedTrajectories: 0,
1128
+ deletedJsonFiles: 0,
1129
+ deletedMarkdownFiles: 0,
1130
+ deletedTraceFiles: 0,
1131
+ deletedCompactionFiles: 0
1132
+ };
1133
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
1134
+ const deletedPaths = /* @__PURE__ */ new Set();
1135
+ for (const paths of pathsById.values()) {
1136
+ for (const filePath of paths) {
1137
+ if (deletedPaths.has(filePath)) {
1138
+ continue;
1139
+ }
1140
+ deletedPaths.add(filePath);
1141
+ await this.removeTrajectoryFile(filePath, summary);
1142
+ }
1143
+ }
1144
+ return summary;
1145
+ }
1132
1146
  /**
1133
1147
  * Close storage (no-op for file storage)
1134
1148
  */
1135
1149
  async close() {
1136
1150
  }
1137
1151
  // Private helpers
1152
+ getActiveCandidatePaths(id) {
1153
+ if (!isSafeTrajectoryId(id)) {
1154
+ return [];
1155
+ }
1156
+ return [
1157
+ join(this.activeDir, id, TRAJECTORY_FILE),
1158
+ // Legacy layout from v0.5.x and earlier.
1159
+ join(this.activeDir, `${id}.json`)
1160
+ ];
1161
+ }
1162
+ async loadAllTrajectories() {
1163
+ const files = await this.listTrajectoryFiles();
1164
+ const trajectories = /* @__PURE__ */ new Map();
1165
+ for (const filePath of files) {
1166
+ const trajectory2 = await this.readTrajectoryOrNull(filePath);
1167
+ if (!trajectory2) {
1168
+ continue;
1169
+ }
1170
+ const current = trajectories.get(trajectory2.id);
1171
+ if (!current || this.isNewerTrajectory(trajectory2, current)) {
1172
+ trajectories.set(trajectory2.id, trajectory2);
1173
+ }
1174
+ }
1175
+ return Array.from(trajectories.values());
1176
+ }
1177
+ async listTrajectoryFiles() {
1178
+ const [activeFiles, completedFiles] = await Promise.all([
1179
+ this.collectTrajectoryFiles(this.activeDir),
1180
+ this.collectTrajectoryFiles(this.completedDir)
1181
+ ]);
1182
+ return [...activeFiles, ...completedFiles];
1183
+ }
1184
+ async collectTrajectoryFiles(dir) {
1185
+ const files = [];
1186
+ await this.walkJsonFilesInto(dir, files);
1187
+ return files;
1188
+ }
1189
+ async findTrajectoryFilePaths(id) {
1190
+ const pathsById = await this.findTrajectoryFilePathsForIds([id]);
1191
+ return pathsById.get(id) ?? [];
1192
+ }
1193
+ async findTrajectoryFilePathsForIds(ids) {
1194
+ const targetIds = new Set(Array.from(ids).filter(isSafeTrajectoryId));
1195
+ const pathsById = new Map(
1196
+ Array.from(targetIds).map((id) => [id, []])
1197
+ );
1198
+ if (targetIds.size === 0) {
1199
+ return pathsById;
1200
+ }
1201
+ const allFiles = await this.listTrajectoryFiles();
1202
+ for (const filePath of allFiles) {
1203
+ const trajectoryId = this.getTrajectoryIdFromPath(filePath);
1204
+ if (!trajectoryId || !targetIds.has(trajectoryId)) {
1205
+ continue;
1206
+ }
1207
+ pathsById.get(trajectoryId)?.push(filePath);
1208
+ }
1209
+ return pathsById;
1210
+ }
1211
+ getTrajectoryIdFromPath(filePath) {
1212
+ if (basename(filePath) === TRAJECTORY_FILE) {
1213
+ const id = basename(dirname(filePath));
1214
+ return isSafeTrajectoryId(id) ? id : void 0;
1215
+ }
1216
+ const name = basename(filePath);
1217
+ if (name.endsWith(".json")) {
1218
+ const id = name.slice(0, -".json".length);
1219
+ return isSafeTrajectoryId(id) ? id : void 0;
1220
+ }
1221
+ return void 0;
1222
+ }
1223
+ async removeTrajectoryFiles(paths, exceptPath) {
1224
+ const summary = this.emptyDeleteSummary();
1225
+ for (const filePath of paths) {
1226
+ if (filePath === exceptPath) {
1227
+ continue;
1228
+ }
1229
+ await this.removeTrajectoryFile(filePath, summary);
1230
+ }
1231
+ }
1232
+ async removeTrajectoryFile(filePath, summary) {
1233
+ if (basename(filePath) === TRAJECTORY_FILE) {
1234
+ const trajectoryDir = dirname(filePath);
1235
+ await this.countDirectoryTrajectoryFiles(trajectoryDir, summary);
1236
+ await this.removeFileIfExists(
1237
+ join(dirname(trajectoryDir), `${basename(trajectoryDir)}.trace.json`),
1238
+ "trace",
1239
+ summary
1240
+ );
1241
+ await rm(trajectoryDir, { recursive: true, force: true });
1242
+ return;
1243
+ }
1244
+ await this.removeFileIfExists(filePath, "json", summary);
1245
+ await this.removeFileIfExists(
1246
+ getMarkdownOutputPath(filePath),
1247
+ "markdown",
1248
+ summary
1249
+ );
1250
+ await this.removeFileIfExists(
1251
+ getTraceOutputPath(filePath),
1252
+ "trace",
1253
+ summary
1254
+ );
1255
+ await this.removeFileIfExists(
1256
+ getLegacyCompactionMarkerPath(filePath),
1257
+ "compaction",
1258
+ summary
1259
+ );
1260
+ }
1261
+ async countDirectoryTrajectoryFiles(trajectoryDir, summary) {
1262
+ await this.countFileIfExists(
1263
+ join(trajectoryDir, TRAJECTORY_FILE),
1264
+ "json",
1265
+ summary
1266
+ );
1267
+ await this.countFileIfExists(
1268
+ join(trajectoryDir, SUMMARY_FILE),
1269
+ "markdown",
1270
+ summary
1271
+ );
1272
+ await this.countFileIfExists(
1273
+ join(trajectoryDir, `${basename(trajectoryDir)}.trace.json`),
1274
+ "trace",
1275
+ summary
1276
+ );
1277
+ await this.countFileIfExists(
1278
+ join(trajectoryDir, "trace.json"),
1279
+ "trace",
1280
+ summary
1281
+ );
1282
+ await this.countFileIfExists(
1283
+ join(trajectoryDir, COMPACTION_FILE),
1284
+ "compaction",
1285
+ summary
1286
+ );
1287
+ }
1288
+ async removeFileIfExists(path, kind, summary) {
1289
+ if (!existsSync(path)) {
1290
+ return;
1291
+ }
1292
+ await rm(path, { force: true });
1293
+ this.incrementDeleteSummary(kind, summary);
1294
+ }
1295
+ async countFileIfExists(path, kind, summary) {
1296
+ if (existsSync(path)) {
1297
+ this.incrementDeleteSummary(kind, summary);
1298
+ }
1299
+ }
1300
+ incrementDeleteSummary(kind, summary) {
1301
+ if (kind === "json") {
1302
+ summary.deletedJsonFiles += 1;
1303
+ summary.removedTrajectories += 1;
1304
+ } else if (kind === "markdown") {
1305
+ summary.deletedMarkdownFiles += 1;
1306
+ } else if (kind === "trace") {
1307
+ summary.deletedTraceFiles += 1;
1308
+ } else {
1309
+ summary.deletedCompactionFiles += 1;
1310
+ }
1311
+ }
1312
+ emptyDeleteSummary() {
1313
+ return {
1314
+ removedTrajectories: 0,
1315
+ deletedJsonFiles: 0,
1316
+ deletedMarkdownFiles: 0,
1317
+ deletedTraceFiles: 0,
1318
+ deletedCompactionFiles: 0
1319
+ };
1320
+ }
1321
+ async listCompactionMarkerFiles() {
1322
+ const markerPaths = [];
1323
+ await this.walkFilesInto(
1324
+ this.activeDir,
1325
+ markerPaths,
1326
+ isCompactionMarkerFile
1327
+ );
1328
+ await this.walkFilesInto(
1329
+ this.completedDir,
1330
+ markerPaths,
1331
+ isCompactionMarkerFile
1332
+ );
1333
+ return markerPaths;
1334
+ }
1335
+ getCompactionMarkerPath(filePath, id) {
1336
+ if (basename(filePath) === TRAJECTORY_FILE) {
1337
+ return join(dirname(filePath), COMPACTION_FILE);
1338
+ }
1339
+ return join(dirname(filePath), `${id}${LEGACY_COMPACTION_SUFFIX}`);
1340
+ }
1341
+ getTrajectoryIdFromCompactionMarkerPath(markerPath) {
1342
+ if (basename(markerPath) === COMPACTION_FILE) {
1343
+ const id = basename(dirname(markerPath));
1344
+ return id.startsWith("traj_") ? id : void 0;
1345
+ }
1346
+ const markerName = basename(markerPath);
1347
+ return markerName.endsWith(LEGACY_COMPACTION_SUFFIX) ? markerName.slice(0, -LEGACY_COMPACTION_SUFFIX.length) : void 0;
1348
+ }
1349
+ async migrateLegacyIndexCompactionMarkers() {
1350
+ const indexPath = join(this.trajectoriesDir, "index.json");
1351
+ if (!existsSync(indexPath)) {
1352
+ return;
1353
+ }
1354
+ let parsed;
1355
+ try {
1356
+ parsed = JSON.parse(await readFile(indexPath, "utf-8"));
1357
+ } catch {
1358
+ return;
1359
+ }
1360
+ if (parsed === null || typeof parsed !== "object") {
1361
+ return;
1362
+ }
1363
+ const trajectories = parsed.trajectories;
1364
+ if (trajectories === null || typeof trajectories !== "object" || Array.isArray(trajectories)) {
1365
+ return;
1366
+ }
1367
+ await Promise.all(
1368
+ Object.entries(trajectories).map(async ([id, entry]) => {
1369
+ if (entry === null || typeof entry !== "object" || !isSafeTrajectoryId(id)) {
1370
+ return;
1371
+ }
1372
+ const compactedInto = entry.compactedInto;
1373
+ const path = entry.path;
1374
+ if (typeof compactedInto !== "string") {
1375
+ return;
1376
+ }
1377
+ const paths = typeof path === "string" && existsSync(path) && this.isPathInsideTrajectoriesDir(path) ? [path] : await this.findTrajectoryFilePaths(id);
1378
+ if (paths.length === 0) return;
1379
+ const marker = {
1380
+ trajectoryId: id,
1381
+ compactedInto,
1382
+ compactedAt: (/* @__PURE__ */ new Date()).toISOString()
1383
+ };
1384
+ await Promise.all(
1385
+ paths.map(
1386
+ (filePath) => writeFile(
1387
+ this.getCompactionMarkerPath(filePath, id),
1388
+ JSON.stringify(marker, null, 2),
1389
+ "utf-8"
1390
+ )
1391
+ )
1392
+ );
1393
+ })
1394
+ );
1395
+ }
1396
+ isPathInsideTrajectoriesDir(path) {
1397
+ const rel = relative(resolve(this.trajectoriesDir), resolve(path));
1398
+ return Boolean(rel && !rel.startsWith("..") && !isAbsolute(rel));
1399
+ }
1400
+ async walkFilesInto(dir, out, predicate) {
1401
+ let entries;
1402
+ try {
1403
+ entries = await readdir(dir, { withFileTypes: true });
1404
+ } catch (error) {
1405
+ if (error.code === "ENOENT") return;
1406
+ throw error;
1407
+ }
1408
+ for (const entry of entries) {
1409
+ const entryPath = join(dir, entry.name);
1410
+ if (entry.isDirectory()) {
1411
+ await this.walkFilesInto(entryPath, out, predicate);
1412
+ } else if (entry.isFile() && predicate(entry.name)) {
1413
+ out.push(entryPath);
1414
+ }
1415
+ }
1416
+ }
1417
+ getSortValue(trajectory2, sortBy) {
1418
+ if (sortBy === "title") {
1419
+ return trajectory2.task.title;
1420
+ }
1421
+ return trajectory2[sortBy] ?? "";
1422
+ }
1423
+ toSummary(trajectory2) {
1424
+ return {
1425
+ id: trajectory2.id,
1426
+ title: trajectory2.task.title,
1427
+ status: trajectory2.status,
1428
+ startedAt: trajectory2.startedAt,
1429
+ completedAt: trajectory2.completedAt,
1430
+ confidence: trajectory2.retrospective?.confidence,
1431
+ chapterCount: trajectory2.chapters.length,
1432
+ decisionCount: trajectory2.chapters.reduce(
1433
+ (count, chapter) => count + chapter.events.filter((event) => event.type === "decision").length,
1434
+ 0
1435
+ )
1436
+ };
1437
+ }
1438
+ isNewerTrajectory(candidate, current) {
1439
+ const candidateTime = new Date(
1440
+ candidate.completedAt ?? candidate.startedAt
1441
+ ).getTime();
1442
+ const currentTime = new Date(
1443
+ current.completedAt ?? current.startedAt
1444
+ ).getTime();
1445
+ return candidateTime > currentTime;
1446
+ }
1138
1447
  /**
1139
1448
  * Read a trajectory file and return a tagged result so callers can
1140
1449
  * distinguish missing files, malformed JSON, and schema violations.
@@ -1174,82 +1483,25 @@ var FileStorage = class {
1174
1483
  const result = await this.readTrajectoryFile(path);
1175
1484
  return result.ok ? result.trajectory : null;
1176
1485
  }
1177
- /**
1178
- * Read and parse the on-disk index.
1179
- *
1180
- * Tolerances (belt-and-braces against the read/write race):
1181
- * - ENOENT: first-run, return an empty index silently.
1182
- * - Empty file: a concurrent writer truncated index.json in "w" mode
1183
- * right before we read. Return an empty index silently — this is
1184
- * not a real corruption, just an interleaving the mutex + atomic
1185
- * rename should already prevent. Logging here would be noise.
1186
- * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1187
- * edit, disk error, etc). Log it and return an empty index so the
1188
- * caller can recover, but keep the log so the problem is visible.
1189
- */
1190
- async loadIndex() {
1191
- let content;
1192
- try {
1193
- content = await readFile(this.indexPath, "utf-8");
1194
- } catch (error) {
1195
- if (error.code !== "ENOENT") {
1196
- console.error(
1197
- "Error loading trajectory index, using empty index:",
1198
- error
1199
- );
1200
- }
1201
- return this.emptyIndex();
1202
- }
1203
- if (content.length === 0) {
1204
- return this.emptyIndex();
1205
- }
1206
- try {
1207
- return JSON.parse(content);
1208
- } catch (error) {
1209
- console.error(
1210
- "Error loading trajectory index, using empty index:",
1211
- error
1212
- );
1213
- return this.emptyIndex();
1214
- }
1215
- }
1216
- emptyIndex() {
1217
- return {
1218
- version: 1,
1219
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1220
- trajectories: {}
1221
- };
1222
- }
1223
- /**
1224
- * Atomic write: stage into a process-unique temp path in the same directory
1225
- * and then rename over the live file. `rename` is atomic on POSIX, so
1226
- * concurrent readers in any process either see the old complete file or
1227
- * the new complete file — never a half-written / zero-byte state.
1228
- *
1229
- * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1230
- * read-modify-write cycle stays serialized; the unique temp name also keeps
1231
- * parallel writers in other processes from colliding on a shared tmp path.
1232
- */
1233
- async saveIndex(index) {
1234
- index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
1235
- const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1236
- await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1237
- await rename(tmpPath, this.indexPath);
1238
- }
1239
- async updateIndex(trajectory2, filePath) {
1240
- await withIndexLock(this.indexPath, async () => {
1241
- const index = await this.loadIndex();
1242
- index.trajectories[trajectory2.id] = {
1243
- title: trajectory2.task.title,
1244
- status: trajectory2.status,
1245
- startedAt: trajectory2.startedAt,
1246
- completedAt: trajectory2.completedAt,
1247
- path: filePath
1248
- };
1249
- await this.saveIndex(index);
1250
- });
1251
- }
1252
1486
  };
1487
+ function isTrajectoryJsonFile(name) {
1488
+ return name === TRAJECTORY_FILE || name.endsWith(".json") && name !== "index.json" && !name.endsWith(".trace.json") && !name.endsWith(LEGACY_COMPACTION_SUFFIX) && name !== COMPACTION_FILE;
1489
+ }
1490
+ function isSafeTrajectoryId(id) {
1491
+ return id.length > 0 && !id.includes("..") && !id.includes("/") && !id.includes("\\");
1492
+ }
1493
+ function isCompactionMarkerFile(name) {
1494
+ return name === COMPACTION_FILE || name.endsWith(LEGACY_COMPACTION_SUFFIX);
1495
+ }
1496
+ function getMarkdownOutputPath(outputPath) {
1497
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".md") : `${outputPath}.md`;
1498
+ }
1499
+ function getTraceOutputPath(outputPath) {
1500
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".trace.json") : `${outputPath}.trace.json`;
1501
+ }
1502
+ function getLegacyCompactionMarkerPath(outputPath) {
1503
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(LEGACY_COMPACTION_SUFFIX) : `${outputPath}${LEGACY_COMPACTION_SUFFIX}`;
1504
+ }
1253
1505
 
1254
1506
  // src/sdk/client.ts
1255
1507
  var require2 = createRequire(import.meta.url);
@@ -1337,7 +1589,7 @@ async function compactWorkflow(workflowId, options) {
1337
1589
  if (options?.discardSources) {
1338
1590
  args.push("--discard-sources");
1339
1591
  }
1340
- return new Promise((resolve, reject) => {
1592
+ return new Promise((resolve2, reject) => {
1341
1593
  const child = spawn(cli.command, args, {
1342
1594
  cwd: options?.cwd,
1343
1595
  stdio: ["ignore", "pipe", "pipe"]
@@ -1365,7 +1617,7 @@ async function compactWorkflow(workflowId, options) {
1365
1617
  }
1366
1618
  try {
1367
1619
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
1368
- resolve(parseCompactWorkflowOutput(stdout));
1620
+ resolve2(parseCompactWorkflowOutput(stdout));
1369
1621
  } catch (error) {
1370
1622
  reject(
1371
1623
  error instanceof Error ? error : new Error("compactWorkflow failed: unable to parse CLI output")
@@ -2177,6 +2429,9 @@ export {
2177
2429
  exportToMarkdown,
2178
2430
  exportToPRSummary,
2179
2431
  exportToTimeline,
2432
+ DEFAULT_TRAJECTORY_DATA_DIR,
2433
+ LEGACY_TRAJECTORY_DATA_DIR,
2434
+ getDefaultTrajectoryDataDir,
2180
2435
  FileStorage,
2181
2436
  compactWorkflow,
2182
2437
  TrajectorySession,
@@ -2190,4 +2445,4 @@ export {
2190
2445
  getCommitsBetween,
2191
2446
  getFilesChangedBetween
2192
2447
  };
2193
- //# sourceMappingURL=chunk-WMJRBQB4.js.map
2448
+ //# sourceMappingURL=chunk-ENWKFNUD.js.map