agent-trajectories 0.5.8 → 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.
@@ -666,17 +666,27 @@ 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";
680
690
  function expandPath(path) {
681
691
  if (path.startsWith("~")) {
682
692
  return join(process.env.HOME ?? "", path.slice(1));
@@ -697,22 +707,11 @@ function describeReadFailure(reason, error) {
697
707
  if (error instanceof Error) return error.message;
698
708
  return String(error);
699
709
  }
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
710
  var FileStorage = class {
711
711
  baseDir;
712
712
  trajectoriesDir;
713
713
  activeDir;
714
714
  completedDir;
715
- indexPath;
716
715
  lastReconcileSummary;
717
716
  constructor(baseDir) {
718
717
  this.baseDir = baseDir ?? process.cwd();
@@ -724,7 +723,6 @@ var FileStorage = class {
724
723
  }
725
724
  this.activeDir = join(this.trajectoriesDir, "active");
726
725
  this.completedDir = join(this.trajectoriesDir, "completed");
727
- this.indexPath = join(this.trajectoriesDir, "index.json");
728
726
  }
729
727
  /**
730
728
  * Initialize storage directories
@@ -733,28 +731,22 @@ var FileStorage = class {
733
731
  await mkdir(this.trajectoriesDir, { recursive: true });
734
732
  await mkdir(this.activeDir, { recursive: true });
735
733
  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
- }
734
+ await this.migrateLegacyIndexCompactionMarkers();
735
+ await rm(join(this.trajectoriesDir, "index.json"), { force: true });
743
736
  await this.reconcileIndex();
744
737
  }
745
738
  /**
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.
739
+ * Scan active/ and completed/ recursively and report trajectory files
740
+ * that can be loaded plus files that should be surfaced by doctor.
749
741
  *
750
742
  * Handles three on-disk layouts in completed/:
751
743
  * - flat: completed/{id}.json (legacy workforce data)
752
- * - 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)
753
746
  * - nested: completed/.../{id}.json (defensive — any depth)
754
747
  *
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.
748
+ * The method name is kept for callers such as `trail doctor`, but no
749
+ * shared index file is written.
758
750
  */
759
751
  async reconcileIndex() {
760
752
  const summary = {
@@ -766,58 +758,29 @@ var FileStorage = class {
766
758
  skippedIoError: 0,
767
759
  failures: []
768
760
  };
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));
778
- }
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;
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;
805
772
  }
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);
773
+ summary.failures.push({
774
+ path: result.path,
775
+ reason: result.reason,
776
+ message: describeReadFailure(result.reason, result.error)
777
+ });
778
+ continue;
817
779
  }
818
- });
780
+ summary.added += 1;
781
+ }
819
782
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
820
- if (summary.added > 0 || hadSkips) {
783
+ if (hadSkips) {
821
784
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
822
785
  if (summary.skippedMalformedJson > 0) {
823
786
  parts.push(`malformed: ${summary.skippedMalformedJson}`);
@@ -899,7 +862,7 @@ var FileStorage = class {
899
862
  return dest;
900
863
  }
901
864
  /**
902
- * Recursively collect all .json file paths under `dir` into `out`.
865
+ * Recursively collect trajectory JSON file paths under `dir` into `out`.
903
866
  * Silently treats a missing directory as empty.
904
867
  */
905
868
  async walkJsonFilesInto(dir, out) {
@@ -914,7 +877,7 @@ var FileStorage = class {
914
877
  const entryPath = join(dir, entry.name);
915
878
  if (entry.isDirectory()) {
916
879
  await this.walkJsonFilesInto(entryPath, out);
917
- } else if (entry.isFile() && entry.name.endsWith(".json")) {
880
+ } else if (entry.isFile() && isTrajectoryJsonFile(entry.name)) {
918
881
  out.push(entryPath);
919
882
  }
920
883
  }
@@ -938,56 +901,43 @@ var FileStorage = class {
938
901
  }
939
902
  const trajectory2 = validation.data;
940
903
  const isCompleted = trajectory2.status === "completed" || trajectory2.status === "abandoned";
941
- let filePath;
904
+ const existingPaths = await this.findTrajectoryFilePaths(trajectory2.id);
905
+ let trajectoryDir;
942
906
  if (isCompleted) {
943
907
  const date = new Date(trajectory2.completedAt ?? trajectory2.startedAt);
944
908
  const monthDir = join(
945
909
  this.completedDir,
946
910
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
947
911
  );
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");
912
+ trajectoryDir = join(monthDir, trajectory2.id);
957
913
  } else {
958
- 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");
959
922
  }
960
923
  await writeFile(filePath, JSON.stringify(trajectory2, null, 2), "utf-8");
961
- await this.updateIndex(trajectory2, filePath);
962
924
  }
963
925
  /**
964
926
  * Get a trajectory by ID
965
927
  */
966
928
  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
- }
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;
987
934
  }
988
- } catch (error) {
989
- if (error.code !== "ENOENT") {
990
- 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;
991
941
  }
992
942
  }
993
943
  return null;
@@ -996,107 +946,61 @@ var FileStorage = class {
996
946
  * Get the currently active trajectory
997
947
  */
998
948
  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);
949
+ const activeFiles = await this.collectTrajectoryFiles(this.activeDir);
950
+ if (activeFiles.length === 0) {
1025
951
  return null;
1026
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;
1027
965
  }
1028
966
  /**
1029
967
  * List trajectories with optional filtering
1030
968
  */
1031
969
  async list(query) {
1032
- const index = await this.loadIndex();
1033
- let entries = Object.entries(index.trajectories);
970
+ let trajectories = await this.loadAllTrajectories();
1034
971
  if (query.status) {
1035
- entries = entries.filter(([, entry]) => entry.status === query.status);
972
+ trajectories = trajectories.filter((t) => t.status === query.status);
1036
973
  }
1037
974
  if (query.since) {
1038
975
  const sinceTime = new Date(query.since).getTime();
1039
- entries = entries.filter(
1040
- ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime
976
+ trajectories = trajectories.filter(
977
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() >= sinceTime
1041
978
  );
1042
979
  }
1043
980
  if (query.until) {
1044
981
  const untilTime = new Date(query.until).getTime();
1045
- entries = entries.filter(
1046
- ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime
982
+ trajectories = trajectories.filter(
983
+ (trajectory2) => new Date(trajectory2.startedAt).getTime() <= untilTime
1047
984
  );
1048
985
  }
1049
986
  const sortBy = query.sortBy ?? "startedAt";
1050
987
  const sortOrder = query.sortOrder ?? "desc";
1051
- entries.sort((a, b) => {
1052
- const aVal = a[1][sortBy] ?? "";
1053
- const bVal = b[1][sortBy] ?? "";
988
+ trajectories.sort((a, b) => {
989
+ const aVal = this.getSortValue(a, sortBy);
990
+ const bVal = this.getSortValue(b, sortBy);
1054
991
  const cmp = String(aVal).localeCompare(String(bVal));
1055
992
  return sortOrder === "asc" ? cmp : -cmp;
1056
993
  });
1057
994
  const offset = query.offset ?? 0;
1058
995
  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
- );
996
+ trajectories = trajectories.slice(offset, offset + limit);
997
+ return trajectories.map((trajectory2) => this.toSummary(trajectory2));
1078
998
  }
1079
999
  /**
1080
1000
  * Delete a trajectory
1081
1001
  */
1082
1002
  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
- });
1003
+ await this.deleteWithSummary(id);
1100
1004
  }
1101
1005
  /**
1102
1006
  * Search trajectories by text
@@ -1129,12 +1033,395 @@ var FileStorage = class {
1129
1033
  }
1130
1034
  return matches;
1131
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
+ }
1132
1124
  /**
1133
1125
  * Close storage (no-op for file storage)
1134
1126
  */
1135
1127
  async close() {
1136
1128
  }
1137
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
+ }
1138
1425
  /**
1139
1426
  * Read a trajectory file and return a tagged result so callers can
1140
1427
  * distinguish missing files, malformed JSON, and schema violations.
@@ -1174,82 +1461,25 @@ var FileStorage = class {
1174
1461
  const result = await this.readTrajectoryFile(path);
1175
1462
  return result.ok ? result.trajectory : null;
1176
1463
  }
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
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
+ }
1253
1483
 
1254
1484
  // src/sdk/client.ts
1255
1485
  var require2 = createRequire(import.meta.url);
@@ -1337,7 +1567,7 @@ async function compactWorkflow(workflowId, options) {
1337
1567
  if (options?.discardSources) {
1338
1568
  args.push("--discard-sources");
1339
1569
  }
1340
- return new Promise((resolve, reject) => {
1570
+ return new Promise((resolve2, reject) => {
1341
1571
  const child = spawn(cli.command, args, {
1342
1572
  cwd: options?.cwd,
1343
1573
  stdio: ["ignore", "pipe", "pipe"]
@@ -1365,7 +1595,7 @@ async function compactWorkflow(workflowId, options) {
1365
1595
  }
1366
1596
  try {
1367
1597
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
1368
- resolve(parseCompactWorkflowOutput(stdout));
1598
+ resolve2(parseCompactWorkflowOutput(stdout));
1369
1599
  } catch (error) {
1370
1600
  reject(
1371
1601
  error instanceof Error ? error : new Error("compactWorkflow failed: unable to parse CLI output")
@@ -2190,4 +2420,4 @@ export {
2190
2420
  getCommitsBetween,
2191
2421
  getFilesChangedBetween
2192
2422
  };
2193
- //# sourceMappingURL=chunk-WMJRBQB4.js.map
2423
+ //# sourceMappingURL=chunk-JMH3Z5BB.js.map