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.
package/dist/cli/index.js CHANGED
@@ -404,17 +404,23 @@ function abandonTrajectory(trajectory, reason) {
404
404
  }
405
405
 
406
406
  // src/storage/file.ts
407
- import { randomUUID } from "crypto";
408
407
  import { existsSync } from "fs";
409
408
  import {
410
409
  mkdir,
411
410
  readFile,
412
411
  readdir,
413
412
  rename,
414
- unlink,
413
+ rm,
415
414
  writeFile
416
415
  } from "fs/promises";
417
- import { join } from "path";
416
+ import {
417
+ basename,
418
+ dirname,
419
+ isAbsolute,
420
+ join,
421
+ relative,
422
+ resolve
423
+ } from "path";
418
424
 
419
425
  // src/export/markdown.ts
420
426
  function exportToMarkdown(trajectory) {
@@ -577,6 +583,10 @@ function extractDecisions(trajectory) {
577
583
  }
578
584
 
579
585
  // src/storage/file.ts
586
+ var TRAJECTORY_FILE = "trajectory.json";
587
+ var SUMMARY_FILE = "summary.md";
588
+ var COMPACTION_FILE = "compaction.json";
589
+ var LEGACY_COMPACTION_SUFFIX = ".compaction.json";
580
590
  function expandPath(path2) {
581
591
  if (path2.startsWith("~")) {
582
592
  return join(process.env.HOME ?? "", path2.slice(1));
@@ -594,22 +604,26 @@ function getSearchPaths() {
594
604
  }
595
605
  return [join(process.cwd(), ".trajectories")];
596
606
  }
597
- var indexLocks = /* @__PURE__ */ new Map();
598
- function withIndexLock(path2, task) {
599
- const prev = indexLocks.get(path2) ?? Promise.resolve();
600
- const next = prev.then(task, task);
601
- indexLocks.set(
602
- path2,
603
- next.catch(() => void 0)
604
- );
605
- return next;
607
+ function describeReadFailure(reason, error) {
608
+ if (reason === "schema_violation" && error && typeof error === "object" && "issues" in error) {
609
+ const issues = error.issues ?? [];
610
+ if (issues.length > 0) {
611
+ const first = issues[0];
612
+ const where = first.path.length > 0 ? first.path.join(".") : "root";
613
+ const extra = issues.length > 1 ? ` (+${issues.length - 1} more)` : "";
614
+ return `${where}: ${first.message}${extra}`;
615
+ }
616
+ return "schema validation failed";
617
+ }
618
+ if (error instanceof Error) return error.message;
619
+ return String(error);
606
620
  }
607
621
  var FileStorage = class {
608
622
  baseDir;
609
623
  trajectoriesDir;
610
624
  activeDir;
611
625
  completedDir;
612
- indexPath;
626
+ lastReconcileSummary;
613
627
  constructor(baseDir) {
614
628
  this.baseDir = baseDir ?? process.cwd();
615
629
  const dataDir = process.env.TRAJECTORIES_DATA_DIR;
@@ -620,7 +634,6 @@ var FileStorage = class {
620
634
  }
621
635
  this.activeDir = join(this.trajectoriesDir, "active");
622
636
  this.completedDir = join(this.trajectoriesDir, "completed");
623
- this.indexPath = join(this.trajectoriesDir, "index.json");
624
637
  }
625
638
  /**
626
639
  * Initialize storage directories
@@ -629,28 +642,22 @@ var FileStorage = class {
629
642
  await mkdir(this.trajectoriesDir, { recursive: true });
630
643
  await mkdir(this.activeDir, { recursive: true });
631
644
  await mkdir(this.completedDir, { recursive: true });
632
- if (!existsSync(this.indexPath)) {
633
- await withIndexLock(this.indexPath, async () => {
634
- if (!existsSync(this.indexPath)) {
635
- await this.saveIndex(this.emptyIndex());
636
- }
637
- });
638
- }
645
+ await this.migrateLegacyIndexCompactionMarkers();
646
+ await rm(join(this.trajectoriesDir, "index.json"), { force: true });
639
647
  await this.reconcileIndex();
640
648
  }
641
649
  /**
642
- * Scan active/ and completed/ recursively and add any trajectory files
643
- * missing from the index. Existing entries are preserved reconcile
644
- * only adds, never removes.
650
+ * Scan active/ and completed/ recursively and report trajectory files
651
+ * that can be loaded plus files that should be surfaced by doctor.
645
652
  *
646
653
  * Handles three on-disk layouts in completed/:
647
654
  * - flat: completed/{id}.json (legacy workforce data)
648
- * - monthly: completed/YYYY-MM/{id}.json (current save() writes)
655
+ * - monthly: completed/YYYY-MM/{id}.json (legacy monthly layout)
656
+ * - directory: completed/YYYY-MM/{id}/trajectory.json (current layout)
649
657
  * - nested: completed/.../{id}.json (defensive — any depth)
650
658
  *
651
- * Returns a ReconcileSummary so tests and CLI wrappers can observe
652
- * outcomes without parsing logs. Only writes the index if anything was
653
- * added.
659
+ * The method name is kept for callers such as `trail doctor`, but no
660
+ * shared index file is written.
654
661
  */
655
662
  async reconcileIndex() {
656
663
  const summary = {
@@ -659,55 +666,32 @@ var FileStorage = class {
659
666
  alreadyIndexed: 0,
660
667
  skippedMalformedJson: 0,
661
668
  skippedSchemaViolation: 0,
662
- skippedIoError: 0
669
+ skippedIoError: 0,
670
+ failures: []
663
671
  };
664
- await withIndexLock(this.indexPath, async () => {
665
- const index = await this.loadIndex();
666
- const before = Object.keys(index.trajectories).length;
667
- const discovered = [];
668
- try {
669
- const activeFiles = await readdir(this.activeDir);
670
- for (const file of activeFiles) {
671
- if (!file.endsWith(".json")) continue;
672
- discovered.push(join(this.activeDir, file));
673
- }
674
- } catch (error) {
675
- if (error.code !== "ENOENT") throw error;
676
- }
677
- await this.walkJsonFilesInto(this.completedDir, discovered);
678
- for (const filePath of discovered) {
679
- summary.scanned += 1;
680
- const result = await this.readTrajectoryFile(filePath);
681
- if (!result.ok) {
682
- if (result.reason === "malformed_json") {
683
- summary.skippedMalformedJson += 1;
684
- } else if (result.reason === "schema_violation") {
685
- summary.skippedSchemaViolation += 1;
686
- } else {
687
- summary.skippedIoError += 1;
688
- }
689
- continue;
690
- }
691
- const trajectory = result.trajectory;
692
- if (index.trajectories[trajectory.id]) {
693
- summary.alreadyIndexed += 1;
694
- continue;
672
+ const discovered = await this.listTrajectoryFiles();
673
+ for (const filePath of discovered) {
674
+ summary.scanned += 1;
675
+ const result = await this.readTrajectoryFile(filePath);
676
+ if (!result.ok) {
677
+ if (result.reason === "malformed_json") {
678
+ summary.skippedMalformedJson += 1;
679
+ } else if (result.reason === "schema_violation") {
680
+ summary.skippedSchemaViolation += 1;
681
+ } else {
682
+ summary.skippedIoError += 1;
695
683
  }
696
- index.trajectories[trajectory.id] = {
697
- title: trajectory.task.title,
698
- status: trajectory.status,
699
- startedAt: trajectory.startedAt,
700
- completedAt: trajectory.completedAt,
701
- path: filePath
702
- };
703
- summary.added += 1;
704
- }
705
- if (Object.keys(index.trajectories).length !== before) {
706
- await this.saveIndex(index);
684
+ summary.failures.push({
685
+ path: result.path,
686
+ reason: result.reason,
687
+ message: describeReadFailure(result.reason, result.error)
688
+ });
689
+ continue;
707
690
  }
708
- });
691
+ summary.added += 1;
692
+ }
709
693
  const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
710
- if (summary.added > 0 || hadSkips) {
694
+ if (hadSkips) {
711
695
  const parts = [`reconciled ${summary.added}/${summary.scanned}`];
712
696
  if (summary.skippedMalformedJson > 0) {
713
697
  parts.push(`malformed: ${summary.skippedMalformedJson}`);
@@ -720,10 +704,76 @@ var FileStorage = class {
720
704
  }
721
705
  console.warn(`[trajectories] ${parts.join(", ")}`);
722
706
  }
707
+ this.lastReconcileSummary = summary;
723
708
  return summary;
724
709
  }
725
710
  /**
726
- * Recursively collect all .json file paths under `dir` into `out`.
711
+ * Returns the most recent reconcile summary, if any. Lets the CLI
712
+ * inspect the failures collected during `initialize()` without having
713
+ * to re-walk the directory tree (and re-emit the warn line).
714
+ */
715
+ getLastReconcileSummary() {
716
+ return this.lastReconcileSummary;
717
+ }
718
+ /**
719
+ * Move trajectory files that fail to load into `.trajectories/invalid/`
720
+ * so reconcile no longer scans them. Only quarantines parse and schema
721
+ * failures — transient io_error failures are left in place because the
722
+ * file may load fine on the next attempt.
723
+ *
724
+ * Returns the list of files that were moved (with their original paths
725
+ * and the destination directory) so the caller can report what changed.
726
+ */
727
+ async quarantineInvalid() {
728
+ const summary = await this.reconcileIndex();
729
+ const targetDir = join(this.trajectoriesDir, "invalid");
730
+ const candidates = summary.failures.filter((f) => f.reason !== "io_error");
731
+ if (candidates.length === 0) {
732
+ return { moved: [], targetDir };
733
+ }
734
+ await mkdir(targetDir, { recursive: true });
735
+ const moved = [];
736
+ for (const failure of candidates) {
737
+ const dest = await this.resolveQuarantineDest(failure.path, targetDir);
738
+ try {
739
+ await mkdir(dirname(dest), { recursive: true });
740
+ await rename(failure.path, dest);
741
+ moved.push(failure);
742
+ } catch (error) {
743
+ console.warn(
744
+ `[trajectories] failed to quarantine ${failure.path}: ${error instanceof Error ? error.message : String(error)}`
745
+ );
746
+ }
747
+ }
748
+ return { moved, targetDir };
749
+ }
750
+ /**
751
+ * Pick a destination path under `targetDir` for a quarantined file.
752
+ *
753
+ * Preserves the file's relative location under the trajectories root
754
+ * (e.g. `completed/2026-04/foo.json` → `invalid/completed/2026-04/foo.json`)
755
+ * so two invalid files that share a basename across `active/` and
756
+ * `completed/` don't collapse onto each other and silently overwrite.
757
+ *
758
+ * Falls back to a numeric-suffix scheme for paths that live outside
759
+ * the trajectories directory or that, after relative resolution, would
760
+ * still collide with something already quarantined.
761
+ */
762
+ async resolveQuarantineDest(sourcePath, targetDir) {
763
+ const rel = relative(this.trajectoriesDir, sourcePath);
764
+ const safeRel = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : basename(sourcePath);
765
+ let dest = join(targetDir, safeRel);
766
+ if (!existsSync(dest)) return dest;
767
+ const ext = safeRel.endsWith(".json") ? ".json" : "";
768
+ const stem = ext ? safeRel.slice(0, -ext.length) : safeRel;
769
+ for (let i = 1; i < 1e3; i += 1) {
770
+ dest = join(targetDir, `${stem}.${i}${ext}`);
771
+ if (!existsSync(dest)) return dest;
772
+ }
773
+ return dest;
774
+ }
775
+ /**
776
+ * Recursively collect trajectory JSON file paths under `dir` into `out`.
727
777
  * Silently treats a missing directory as empty.
728
778
  */
729
779
  async walkJsonFilesInto(dir, out) {
@@ -738,7 +788,7 @@ var FileStorage = class {
738
788
  const entryPath = join(dir, entry.name);
739
789
  if (entry.isDirectory()) {
740
790
  await this.walkJsonFilesInto(entryPath, out);
741
- } else if (entry.isFile() && entry.name.endsWith(".json")) {
791
+ } else if (entry.isFile() && isTrajectoryJsonFile(entry.name)) {
742
792
  out.push(entryPath);
743
793
  }
744
794
  }
@@ -762,56 +812,43 @@ var FileStorage = class {
762
812
  }
763
813
  const trajectory = validation.data;
764
814
  const isCompleted = trajectory.status === "completed" || trajectory.status === "abandoned";
765
- let filePath;
815
+ const existingPaths = await this.findTrajectoryFilePaths(trajectory.id);
816
+ let trajectoryDir;
766
817
  if (isCompleted) {
767
818
  const date = new Date(trajectory.completedAt ?? trajectory.startedAt);
768
819
  const monthDir = join(
769
820
  this.completedDir,
770
821
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
771
822
  );
772
- await mkdir(monthDir, { recursive: true });
773
- filePath = join(monthDir, `${trajectory.id}.json`);
774
- const activePath = join(this.activeDir, `${trajectory.id}.json`);
775
- if (existsSync(activePath)) {
776
- await unlink(activePath);
777
- }
778
- const mdPath = join(monthDir, `${trajectory.id}.md`);
779
- const markdown = exportToMarkdown(trajectory);
780
- await writeFile(mdPath, markdown, "utf-8");
823
+ trajectoryDir = join(monthDir, trajectory.id);
781
824
  } else {
782
- filePath = join(this.activeDir, `${trajectory.id}.json`);
825
+ trajectoryDir = join(this.activeDir, trajectory.id);
826
+ }
827
+ const filePath = join(trajectoryDir, TRAJECTORY_FILE);
828
+ await this.removeTrajectoryFiles(existingPaths, filePath);
829
+ await mkdir(trajectoryDir, { recursive: true });
830
+ if (isCompleted) {
831
+ const markdown = exportToMarkdown(trajectory);
832
+ await writeFile(join(trajectoryDir, SUMMARY_FILE), markdown, "utf-8");
783
833
  }
784
834
  await writeFile(filePath, JSON.stringify(trajectory, null, 2), "utf-8");
785
- await this.updateIndex(trajectory, filePath);
786
835
  }
787
836
  /**
788
837
  * Get a trajectory by ID
789
838
  */
790
839
  async get(id) {
791
- const activePath = join(this.activeDir, `${id}.json`);
792
- if (existsSync(activePath)) {
793
- return this.readTrajectoryOrNull(activePath);
794
- }
795
- const index = await this.loadIndex();
796
- const entry = index.trajectories[id];
797
- if (entry?.path && existsSync(entry.path)) {
798
- return this.readTrajectoryOrNull(entry.path);
799
- }
800
- try {
801
- const flatPath = join(this.completedDir, `${id}.json`);
802
- if (existsSync(flatPath)) {
803
- return this.readTrajectoryOrNull(flatPath);
804
- }
805
- const months = await readdir(this.completedDir);
806
- for (const month of months) {
807
- const filePath = join(this.completedDir, month, `${id}.json`);
808
- if (existsSync(filePath)) {
809
- return this.readTrajectoryOrNull(filePath);
810
- }
840
+ for (const filePath of this.getActiveCandidatePaths(id)) {
841
+ if (!existsSync(filePath)) continue;
842
+ const trajectory = await this.readTrajectoryOrNull(filePath);
843
+ if (trajectory?.id === id) {
844
+ return trajectory;
811
845
  }
812
- } catch (error) {
813
- if (error.code !== "ENOENT") {
814
- console.error("Error searching completed trajectories:", error);
846
+ }
847
+ const paths = await this.findTrajectoryFilePaths(id);
848
+ for (const filePath of paths) {
849
+ const trajectory = await this.readTrajectoryOrNull(filePath);
850
+ if (trajectory?.id === id) {
851
+ return trajectory;
815
852
  }
816
853
  }
817
854
  return null;
@@ -820,107 +857,61 @@ var FileStorage = class {
820
857
  * Get the currently active trajectory
821
858
  */
822
859
  async getActive() {
823
- try {
824
- const files = await readdir(this.activeDir);
825
- const jsonFiles = files.filter((f) => f.endsWith(".json"));
826
- if (jsonFiles.length === 0) {
827
- return null;
828
- }
829
- let mostRecent = null;
830
- let mostRecentTime = 0;
831
- for (const file of jsonFiles) {
832
- const trajectory = await this.readTrajectoryOrNull(
833
- join(this.activeDir, file)
834
- );
835
- if (trajectory) {
836
- const startTime = new Date(trajectory.startedAt).getTime();
837
- if (startTime > mostRecentTime) {
838
- mostRecentTime = startTime;
839
- mostRecent = trajectory;
840
- }
841
- }
842
- }
843
- return mostRecent;
844
- } catch (error) {
845
- if (error.code === "ENOENT") {
846
- return null;
847
- }
848
- console.error("Error reading active trajectories:", error);
860
+ const activeFiles = await this.collectTrajectoryFiles(this.activeDir);
861
+ if (activeFiles.length === 0) {
849
862
  return null;
850
863
  }
864
+ let mostRecent = null;
865
+ let mostRecentTime = 0;
866
+ for (const filePath of activeFiles) {
867
+ const trajectory = await this.readTrajectoryOrNull(filePath);
868
+ if (trajectory?.status !== "active") continue;
869
+ const startTime = new Date(trajectory.startedAt).getTime();
870
+ if (startTime > mostRecentTime) {
871
+ mostRecentTime = startTime;
872
+ mostRecent = trajectory;
873
+ }
874
+ }
875
+ return mostRecent;
851
876
  }
852
877
  /**
853
878
  * List trajectories with optional filtering
854
879
  */
855
880
  async list(query) {
856
- const index = await this.loadIndex();
857
- let entries = Object.entries(index.trajectories);
881
+ let trajectories = await this.loadAllTrajectories();
858
882
  if (query.status) {
859
- entries = entries.filter(([, entry]) => entry.status === query.status);
883
+ trajectories = trajectories.filter((t) => t.status === query.status);
860
884
  }
861
885
  if (query.since) {
862
886
  const sinceTime = new Date(query.since).getTime();
863
- entries = entries.filter(
864
- ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime
887
+ trajectories = trajectories.filter(
888
+ (trajectory) => new Date(trajectory.startedAt).getTime() >= sinceTime
865
889
  );
866
890
  }
867
891
  if (query.until) {
868
892
  const untilTime = new Date(query.until).getTime();
869
- entries = entries.filter(
870
- ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime
893
+ trajectories = trajectories.filter(
894
+ (trajectory) => new Date(trajectory.startedAt).getTime() <= untilTime
871
895
  );
872
896
  }
873
897
  const sortBy = query.sortBy ?? "startedAt";
874
898
  const sortOrder = query.sortOrder ?? "desc";
875
- entries.sort((a, b) => {
876
- const aVal = a[1][sortBy] ?? "";
877
- const bVal = b[1][sortBy] ?? "";
899
+ trajectories.sort((a, b) => {
900
+ const aVal = this.getSortValue(a, sortBy);
901
+ const bVal = this.getSortValue(b, sortBy);
878
902
  const cmp = String(aVal).localeCompare(String(bVal));
879
903
  return sortOrder === "asc" ? cmp : -cmp;
880
904
  });
881
905
  const offset = query.offset ?? 0;
882
906
  const limit = query.limit ?? 500;
883
- entries = entries.slice(offset, offset + limit);
884
- return Promise.all(
885
- entries.map(async ([id, entry]) => {
886
- const trajectory = await this.get(id);
887
- return {
888
- id,
889
- title: entry.title,
890
- status: entry.status,
891
- startedAt: entry.startedAt,
892
- completedAt: entry.completedAt,
893
- confidence: trajectory?.retrospective?.confidence,
894
- chapterCount: trajectory?.chapters.length ?? 0,
895
- decisionCount: trajectory?.chapters.reduce(
896
- (count, chapter) => count + chapter.events.filter((e) => e.type === "decision").length,
897
- 0
898
- ) ?? 0
899
- };
900
- })
901
- );
907
+ trajectories = trajectories.slice(offset, offset + limit);
908
+ return trajectories.map((trajectory) => this.toSummary(trajectory));
902
909
  }
903
910
  /**
904
911
  * Delete a trajectory
905
912
  */
906
913
  async delete(id) {
907
- const activePath = join(this.activeDir, `${id}.json`);
908
- if (existsSync(activePath)) {
909
- await unlink(activePath);
910
- }
911
- await withIndexLock(this.indexPath, async () => {
912
- const index = await this.loadIndex();
913
- const entry = index.trajectories[id];
914
- if (entry?.path && existsSync(entry.path)) {
915
- await unlink(entry.path);
916
- const mdPath = entry.path.replace(".json", ".md");
917
- if (existsSync(mdPath)) {
918
- await unlink(mdPath);
919
- }
920
- }
921
- delete index.trajectories[id];
922
- await this.saveIndex(index);
923
- });
914
+ await this.deleteWithSummary(id);
924
915
  }
925
916
  /**
926
917
  * Search trajectories by text
@@ -953,12 +944,395 @@ var FileStorage = class {
953
944
  }
954
945
  return matches;
955
946
  }
947
+ /**
948
+ * Mark a trajectory as compacted without writing to a shared index.
949
+ */
950
+ async markCompacted(id, compactedInto) {
951
+ const markedIds = await this.markCompactedMany([id], compactedInto);
952
+ return markedIds.has(id);
953
+ }
954
+ /**
955
+ * Mark multiple trajectories as compacted with one filesystem scan.
956
+ */
957
+ async markCompactedMany(ids, compactedInto) {
958
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
959
+ const markedIds = /* @__PURE__ */ new Set();
960
+ const compactedAt = (/* @__PURE__ */ new Date()).toISOString();
961
+ const writes = [];
962
+ for (const [id, paths] of pathsById.entries()) {
963
+ if (paths.length === 0) {
964
+ continue;
965
+ }
966
+ markedIds.add(id);
967
+ const marker = {
968
+ trajectoryId: id,
969
+ compactedInto,
970
+ compactedAt
971
+ };
972
+ for (const filePath of paths) {
973
+ writes.push(
974
+ writeFile(
975
+ this.getCompactionMarkerPath(filePath, id),
976
+ JSON.stringify(marker, null, 2),
977
+ "utf-8"
978
+ )
979
+ );
980
+ }
981
+ }
982
+ await Promise.all(writes);
983
+ return markedIds;
984
+ }
985
+ /**
986
+ * Return trajectory IDs that have a per-trajectory compaction marker.
987
+ */
988
+ async getCompactedTrajectoryIds() {
989
+ const markerPaths = await this.listCompactionMarkerFiles();
990
+ const compactedIds = /* @__PURE__ */ new Set();
991
+ for (const markerPath of markerPaths) {
992
+ try {
993
+ const marker = JSON.parse(
994
+ await readFile(markerPath, "utf-8")
995
+ );
996
+ const trajectoryId = typeof marker.trajectoryId === "string" ? marker.trajectoryId : this.getTrajectoryIdFromCompactionMarkerPath(markerPath);
997
+ if (trajectoryId && typeof marker.compactedInto === "string") {
998
+ compactedIds.add(trajectoryId);
999
+ }
1000
+ } catch {
1001
+ }
1002
+ }
1003
+ return compactedIds;
1004
+ }
1005
+ /**
1006
+ * Delete a trajectory and return file counts for CLI reporting.
1007
+ */
1008
+ async deleteWithSummary(id) {
1009
+ return this.deleteManyWithSummary([id]);
1010
+ }
1011
+ /**
1012
+ * Delete multiple trajectories with one filesystem scan.
1013
+ */
1014
+ async deleteManyWithSummary(ids) {
1015
+ const summary = {
1016
+ removedTrajectories: 0,
1017
+ deletedJsonFiles: 0,
1018
+ deletedMarkdownFiles: 0,
1019
+ deletedTraceFiles: 0,
1020
+ deletedCompactionFiles: 0
1021
+ };
1022
+ const pathsById = await this.findTrajectoryFilePathsForIds(ids);
1023
+ const deletedPaths = /* @__PURE__ */ new Set();
1024
+ for (const paths of pathsById.values()) {
1025
+ for (const filePath of paths) {
1026
+ if (deletedPaths.has(filePath)) {
1027
+ continue;
1028
+ }
1029
+ deletedPaths.add(filePath);
1030
+ await this.removeTrajectoryFile(filePath, summary);
1031
+ }
1032
+ }
1033
+ return summary;
1034
+ }
956
1035
  /**
957
1036
  * Close storage (no-op for file storage)
958
1037
  */
959
1038
  async close() {
960
1039
  }
961
1040
  // Private helpers
1041
+ getActiveCandidatePaths(id) {
1042
+ if (!isSafeTrajectoryId(id)) {
1043
+ return [];
1044
+ }
1045
+ return [
1046
+ join(this.activeDir, id, TRAJECTORY_FILE),
1047
+ // Legacy layout from v0.5.x and earlier.
1048
+ join(this.activeDir, `${id}.json`)
1049
+ ];
1050
+ }
1051
+ async loadAllTrajectories() {
1052
+ const files = await this.listTrajectoryFiles();
1053
+ const trajectories = /* @__PURE__ */ new Map();
1054
+ for (const filePath of files) {
1055
+ const trajectory = await this.readTrajectoryOrNull(filePath);
1056
+ if (!trajectory) {
1057
+ continue;
1058
+ }
1059
+ const current = trajectories.get(trajectory.id);
1060
+ if (!current || this.isNewerTrajectory(trajectory, current)) {
1061
+ trajectories.set(trajectory.id, trajectory);
1062
+ }
1063
+ }
1064
+ return Array.from(trajectories.values());
1065
+ }
1066
+ async listTrajectoryFiles() {
1067
+ const [activeFiles, completedFiles] = await Promise.all([
1068
+ this.collectTrajectoryFiles(this.activeDir),
1069
+ this.collectTrajectoryFiles(this.completedDir)
1070
+ ]);
1071
+ return [...activeFiles, ...completedFiles];
1072
+ }
1073
+ async collectTrajectoryFiles(dir) {
1074
+ const files = [];
1075
+ await this.walkJsonFilesInto(dir, files);
1076
+ return files;
1077
+ }
1078
+ async findTrajectoryFilePaths(id) {
1079
+ const pathsById = await this.findTrajectoryFilePathsForIds([id]);
1080
+ return pathsById.get(id) ?? [];
1081
+ }
1082
+ async findTrajectoryFilePathsForIds(ids) {
1083
+ const targetIds = new Set(Array.from(ids).filter(isSafeTrajectoryId));
1084
+ const pathsById = new Map(
1085
+ Array.from(targetIds).map((id) => [id, []])
1086
+ );
1087
+ if (targetIds.size === 0) {
1088
+ return pathsById;
1089
+ }
1090
+ const allFiles = await this.listTrajectoryFiles();
1091
+ for (const filePath of allFiles) {
1092
+ const trajectoryId = this.getTrajectoryIdFromPath(filePath);
1093
+ if (!trajectoryId || !targetIds.has(trajectoryId)) {
1094
+ continue;
1095
+ }
1096
+ pathsById.get(trajectoryId)?.push(filePath);
1097
+ }
1098
+ return pathsById;
1099
+ }
1100
+ getTrajectoryIdFromPath(filePath) {
1101
+ if (basename(filePath) === TRAJECTORY_FILE) {
1102
+ const id = basename(dirname(filePath));
1103
+ return isSafeTrajectoryId(id) ? id : void 0;
1104
+ }
1105
+ const name = basename(filePath);
1106
+ if (name.endsWith(".json")) {
1107
+ const id = name.slice(0, -".json".length);
1108
+ return isSafeTrajectoryId(id) ? id : void 0;
1109
+ }
1110
+ return void 0;
1111
+ }
1112
+ async removeTrajectoryFiles(paths, exceptPath) {
1113
+ const summary = this.emptyDeleteSummary();
1114
+ for (const filePath of paths) {
1115
+ if (filePath === exceptPath) {
1116
+ continue;
1117
+ }
1118
+ await this.removeTrajectoryFile(filePath, summary);
1119
+ }
1120
+ }
1121
+ async removeTrajectoryFile(filePath, summary) {
1122
+ if (basename(filePath) === TRAJECTORY_FILE) {
1123
+ const trajectoryDir = dirname(filePath);
1124
+ await this.countDirectoryTrajectoryFiles(trajectoryDir, summary);
1125
+ await this.removeFileIfExists(
1126
+ join(dirname(trajectoryDir), `${basename(trajectoryDir)}.trace.json`),
1127
+ "trace",
1128
+ summary
1129
+ );
1130
+ await rm(trajectoryDir, { recursive: true, force: true });
1131
+ return;
1132
+ }
1133
+ await this.removeFileIfExists(filePath, "json", summary);
1134
+ await this.removeFileIfExists(
1135
+ getMarkdownOutputPath(filePath),
1136
+ "markdown",
1137
+ summary
1138
+ );
1139
+ await this.removeFileIfExists(
1140
+ getTraceOutputPath(filePath),
1141
+ "trace",
1142
+ summary
1143
+ );
1144
+ await this.removeFileIfExists(
1145
+ getLegacyCompactionMarkerPath(filePath),
1146
+ "compaction",
1147
+ summary
1148
+ );
1149
+ }
1150
+ async countDirectoryTrajectoryFiles(trajectoryDir, summary) {
1151
+ await this.countFileIfExists(
1152
+ join(trajectoryDir, TRAJECTORY_FILE),
1153
+ "json",
1154
+ summary
1155
+ );
1156
+ await this.countFileIfExists(
1157
+ join(trajectoryDir, SUMMARY_FILE),
1158
+ "markdown",
1159
+ summary
1160
+ );
1161
+ await this.countFileIfExists(
1162
+ join(trajectoryDir, `${basename(trajectoryDir)}.trace.json`),
1163
+ "trace",
1164
+ summary
1165
+ );
1166
+ await this.countFileIfExists(
1167
+ join(trajectoryDir, "trace.json"),
1168
+ "trace",
1169
+ summary
1170
+ );
1171
+ await this.countFileIfExists(
1172
+ join(trajectoryDir, COMPACTION_FILE),
1173
+ "compaction",
1174
+ summary
1175
+ );
1176
+ }
1177
+ async removeFileIfExists(path2, kind, summary) {
1178
+ if (!existsSync(path2)) {
1179
+ return;
1180
+ }
1181
+ await rm(path2, { force: true });
1182
+ this.incrementDeleteSummary(kind, summary);
1183
+ }
1184
+ async countFileIfExists(path2, kind, summary) {
1185
+ if (existsSync(path2)) {
1186
+ this.incrementDeleteSummary(kind, summary);
1187
+ }
1188
+ }
1189
+ incrementDeleteSummary(kind, summary) {
1190
+ if (kind === "json") {
1191
+ summary.deletedJsonFiles += 1;
1192
+ summary.removedTrajectories += 1;
1193
+ } else if (kind === "markdown") {
1194
+ summary.deletedMarkdownFiles += 1;
1195
+ } else if (kind === "trace") {
1196
+ summary.deletedTraceFiles += 1;
1197
+ } else {
1198
+ summary.deletedCompactionFiles += 1;
1199
+ }
1200
+ }
1201
+ emptyDeleteSummary() {
1202
+ return {
1203
+ removedTrajectories: 0,
1204
+ deletedJsonFiles: 0,
1205
+ deletedMarkdownFiles: 0,
1206
+ deletedTraceFiles: 0,
1207
+ deletedCompactionFiles: 0
1208
+ };
1209
+ }
1210
+ async listCompactionMarkerFiles() {
1211
+ const markerPaths = [];
1212
+ await this.walkFilesInto(
1213
+ this.activeDir,
1214
+ markerPaths,
1215
+ isCompactionMarkerFile
1216
+ );
1217
+ await this.walkFilesInto(
1218
+ this.completedDir,
1219
+ markerPaths,
1220
+ isCompactionMarkerFile
1221
+ );
1222
+ return markerPaths;
1223
+ }
1224
+ getCompactionMarkerPath(filePath, id) {
1225
+ if (basename(filePath) === TRAJECTORY_FILE) {
1226
+ return join(dirname(filePath), COMPACTION_FILE);
1227
+ }
1228
+ return join(dirname(filePath), `${id}${LEGACY_COMPACTION_SUFFIX}`);
1229
+ }
1230
+ getTrajectoryIdFromCompactionMarkerPath(markerPath) {
1231
+ if (basename(markerPath) === COMPACTION_FILE) {
1232
+ const id = basename(dirname(markerPath));
1233
+ return id.startsWith("traj_") ? id : void 0;
1234
+ }
1235
+ const markerName = basename(markerPath);
1236
+ return markerName.endsWith(LEGACY_COMPACTION_SUFFIX) ? markerName.slice(0, -LEGACY_COMPACTION_SUFFIX.length) : void 0;
1237
+ }
1238
+ async migrateLegacyIndexCompactionMarkers() {
1239
+ const indexPath = join(this.trajectoriesDir, "index.json");
1240
+ if (!existsSync(indexPath)) {
1241
+ return;
1242
+ }
1243
+ let parsed;
1244
+ try {
1245
+ parsed = JSON.parse(await readFile(indexPath, "utf-8"));
1246
+ } catch {
1247
+ return;
1248
+ }
1249
+ if (parsed === null || typeof parsed !== "object") {
1250
+ return;
1251
+ }
1252
+ const trajectories = parsed.trajectories;
1253
+ if (trajectories === null || typeof trajectories !== "object" || Array.isArray(trajectories)) {
1254
+ return;
1255
+ }
1256
+ await Promise.all(
1257
+ Object.entries(trajectories).map(async ([id, entry]) => {
1258
+ if (entry === null || typeof entry !== "object" || !isSafeTrajectoryId(id)) {
1259
+ return;
1260
+ }
1261
+ const compactedInto = entry.compactedInto;
1262
+ const path2 = entry.path;
1263
+ if (typeof compactedInto !== "string") {
1264
+ return;
1265
+ }
1266
+ const paths = typeof path2 === "string" && existsSync(path2) && this.isPathInsideTrajectoriesDir(path2) ? [path2] : await this.findTrajectoryFilePaths(id);
1267
+ if (paths.length === 0) return;
1268
+ const marker = {
1269
+ trajectoryId: id,
1270
+ compactedInto,
1271
+ compactedAt: (/* @__PURE__ */ new Date()).toISOString()
1272
+ };
1273
+ await Promise.all(
1274
+ paths.map(
1275
+ (filePath) => writeFile(
1276
+ this.getCompactionMarkerPath(filePath, id),
1277
+ JSON.stringify(marker, null, 2),
1278
+ "utf-8"
1279
+ )
1280
+ )
1281
+ );
1282
+ })
1283
+ );
1284
+ }
1285
+ isPathInsideTrajectoriesDir(path2) {
1286
+ const rel = relative(resolve(this.trajectoriesDir), resolve(path2));
1287
+ return Boolean(rel && !rel.startsWith("..") && !isAbsolute(rel));
1288
+ }
1289
+ async walkFilesInto(dir, out, predicate) {
1290
+ let entries;
1291
+ try {
1292
+ entries = await readdir(dir, { withFileTypes: true });
1293
+ } catch (error) {
1294
+ if (error.code === "ENOENT") return;
1295
+ throw error;
1296
+ }
1297
+ for (const entry of entries) {
1298
+ const entryPath = join(dir, entry.name);
1299
+ if (entry.isDirectory()) {
1300
+ await this.walkFilesInto(entryPath, out, predicate);
1301
+ } else if (entry.isFile() && predicate(entry.name)) {
1302
+ out.push(entryPath);
1303
+ }
1304
+ }
1305
+ }
1306
+ getSortValue(trajectory, sortBy) {
1307
+ if (sortBy === "title") {
1308
+ return trajectory.task.title;
1309
+ }
1310
+ return trajectory[sortBy] ?? "";
1311
+ }
1312
+ toSummary(trajectory) {
1313
+ return {
1314
+ id: trajectory.id,
1315
+ title: trajectory.task.title,
1316
+ status: trajectory.status,
1317
+ startedAt: trajectory.startedAt,
1318
+ completedAt: trajectory.completedAt,
1319
+ confidence: trajectory.retrospective?.confidence,
1320
+ chapterCount: trajectory.chapters.length,
1321
+ decisionCount: trajectory.chapters.reduce(
1322
+ (count, chapter) => count + chapter.events.filter((event) => event.type === "decision").length,
1323
+ 0
1324
+ )
1325
+ };
1326
+ }
1327
+ isNewerTrajectory(candidate, current) {
1328
+ const candidateTime = new Date(
1329
+ candidate.completedAt ?? candidate.startedAt
1330
+ ).getTime();
1331
+ const currentTime = new Date(
1332
+ current.completedAt ?? current.startedAt
1333
+ ).getTime();
1334
+ return candidateTime > currentTime;
1335
+ }
962
1336
  /**
963
1337
  * Read a trajectory file and return a tagged result so callers can
964
1338
  * distinguish missing files, malformed JSON, and schema violations.
@@ -998,82 +1372,25 @@ var FileStorage = class {
998
1372
  const result = await this.readTrajectoryFile(path2);
999
1373
  return result.ok ? result.trajectory : null;
1000
1374
  }
1001
- /**
1002
- * Read and parse the on-disk index.
1003
- *
1004
- * Tolerances (belt-and-braces against the read/write race):
1005
- * - ENOENT: first-run, return an empty index silently.
1006
- * - Empty file: a concurrent writer truncated index.json in "w" mode
1007
- * right before we read. Return an empty index silently — this is
1008
- * not a real corruption, just an interleaving the mutex + atomic
1009
- * rename should already prevent. Logging here would be noise.
1010
- * - Non-empty but malformed JSON: genuinely corrupted on disk (hand
1011
- * edit, disk error, etc). Log it and return an empty index so the
1012
- * caller can recover, but keep the log so the problem is visible.
1013
- */
1014
- async loadIndex() {
1015
- let content;
1016
- try {
1017
- content = await readFile(this.indexPath, "utf-8");
1018
- } catch (error) {
1019
- if (error.code !== "ENOENT") {
1020
- console.error(
1021
- "Error loading trajectory index, using empty index:",
1022
- error
1023
- );
1024
- }
1025
- return this.emptyIndex();
1026
- }
1027
- if (content.length === 0) {
1028
- return this.emptyIndex();
1029
- }
1030
- try {
1031
- return JSON.parse(content);
1032
- } catch (error) {
1033
- console.error(
1034
- "Error loading trajectory index, using empty index:",
1035
- error
1036
- );
1037
- return this.emptyIndex();
1038
- }
1039
- }
1040
- emptyIndex() {
1041
- return {
1042
- version: 1,
1043
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
1044
- trajectories: {}
1045
- };
1046
- }
1047
- /**
1048
- * Atomic write: stage into a process-unique temp path in the same directory
1049
- * and then rename over the live file. `rename` is atomic on POSIX, so
1050
- * concurrent readers in any process either see the old complete file or
1051
- * the new complete file — never a half-written / zero-byte state.
1052
- *
1053
- * Callers MUST hold `withIndexLock(this.indexPath, ...)` so the in-process
1054
- * read-modify-write cycle stays serialized; the unique temp name also keeps
1055
- * parallel writers in other processes from colliding on a shared tmp path.
1056
- */
1057
- async saveIndex(index) {
1058
- index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
1059
- const tmpPath = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
1060
- await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
1061
- await rename(tmpPath, this.indexPath);
1062
- }
1063
- async updateIndex(trajectory, filePath) {
1064
- await withIndexLock(this.indexPath, async () => {
1065
- const index = await this.loadIndex();
1066
- index.trajectories[trajectory.id] = {
1067
- title: trajectory.task.title,
1068
- status: trajectory.status,
1069
- startedAt: trajectory.startedAt,
1070
- completedAt: trajectory.completedAt,
1071
- path: filePath
1072
- };
1073
- await this.saveIndex(index);
1074
- });
1075
- }
1076
1375
  };
1376
+ function isTrajectoryJsonFile(name) {
1377
+ return name === TRAJECTORY_FILE || name.endsWith(".json") && name !== "index.json" && !name.endsWith(".trace.json") && !name.endsWith(LEGACY_COMPACTION_SUFFIX) && name !== COMPACTION_FILE;
1378
+ }
1379
+ function isSafeTrajectoryId(id) {
1380
+ return id.length > 0 && !id.includes("..") && !id.includes("/") && !id.includes("\\");
1381
+ }
1382
+ function isCompactionMarkerFile(name) {
1383
+ return name === COMPACTION_FILE || name.endsWith(LEGACY_COMPACTION_SUFFIX);
1384
+ }
1385
+ function getMarkdownOutputPath(outputPath) {
1386
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".md") : `${outputPath}.md`;
1387
+ }
1388
+ function getTraceOutputPath(outputPath) {
1389
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".trace.json") : `${outputPath}.trace.json`;
1390
+ }
1391
+ function getLegacyCompactionMarkerPath(outputPath) {
1392
+ return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(LEGACY_COMPACTION_SUFFIX) : `${outputPath}${LEGACY_COMPACTION_SUFFIX}`;
1393
+ }
1077
1394
 
1078
1395
  // src/cli/commands/abandon.ts
1079
1396
  function registerAbandonCommand(program2) {
@@ -1096,14 +1413,8 @@ function registerAbandonCommand(program2) {
1096
1413
 
1097
1414
  // src/cli/commands/compact.ts
1098
1415
  import { execFileSync } from "child_process";
1099
- import {
1100
- existsSync as existsSync3,
1101
- mkdirSync,
1102
- readFileSync as readFileSync2,
1103
- unlinkSync,
1104
- writeFileSync
1105
- } from "fs";
1106
- import { dirname, join as join4 } from "path";
1416
+ import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
1417
+ import { dirname as dirname2, join as join4 } from "path";
1107
1418
 
1108
1419
  // src/compact/config.ts
1109
1420
  import { existsSync as existsSync2, readFileSync } from "fs";
@@ -1807,7 +2118,7 @@ function buildCliArgs(cli) {
1807
2118
  }
1808
2119
  }
1809
2120
  function spawnWithStdin(command, args, input) {
1810
- return new Promise((resolve, reject) => {
2121
+ return new Promise((resolve2, reject) => {
1811
2122
  const child = spawn(command, args, {
1812
2123
  timeout: 3e5,
1813
2124
  stdio: ["pipe", "pipe", "pipe"]
@@ -1825,7 +2136,7 @@ function spawnWithStdin(command, args, input) {
1825
2136
  new Error(`CLI exited with code ${code}: ${stderr.slice(0, 200)}`)
1826
2137
  );
1827
2138
  } else {
1828
- resolve(Buffer.concat(chunks).toString().trim());
2139
+ resolve2(Buffer.concat(chunks).toString().trim());
1829
2140
  }
1830
2141
  });
1831
2142
  child.stdin.write(input);
@@ -2250,7 +2561,7 @@ function registerCompactCommand(program2) {
2250
2561
  "Comma-separated focus areas to emphasize in LLM compaction"
2251
2562
  ).option("--markdown", "Also write a Markdown companion file").option("--no-markdown", "Skip writing a Markdown companion file").option(
2252
2563
  "--discard-sources",
2253
- "After saving the compaction, delete source trajectory JSON/MD/trace files and remove their index entries"
2564
+ "After saving the compaction, delete source trajectory JSON/MD/trace files"
2254
2565
  ).option("--dry-run", "Preview what would be compacted without saving").option("--output <path>", "Output path for compacted trajectory").action(async (options) => {
2255
2566
  const trajectories = await loadTrajectories(options);
2256
2567
  if (trajectories.length === 0) {
@@ -2291,7 +2602,7 @@ function registerCompactCommand(program2) {
2291
2602
  markdownEnabled
2292
2603
  );
2293
2604
  if (options.discardSources) {
2294
- const discardSummary = discardSourceTrajectories(trajectories);
2605
+ const discardSummary = await discardSourceTrajectories(trajectories);
2295
2606
  printDiscardSummary(discardSummary);
2296
2607
  } else {
2297
2608
  await markTrajectoriesAsCompacted(
@@ -2303,7 +2614,7 @@ function registerCompactCommand(program2) {
2303
2614
  Compacted trajectory saved to: ${outputPath2}`);
2304
2615
  if (markdownEnabled) {
2305
2616
  console.log(
2306
- `Markdown summary saved to: ${getMarkdownOutputPath(outputPath2)}`
2617
+ `Markdown summary saved to: ${getMarkdownOutputPath2(outputPath2)}`
2307
2618
  );
2308
2619
  }
2309
2620
  printCompactedSummary(mechanicalCompacted);
@@ -2352,7 +2663,7 @@ Compacted trajectory saved to: ${outputPath2}`);
2352
2663
  const outputPath = options.output || getDefaultOutputPath(compacted, options.workflow);
2353
2664
  saveCompactionArtifacts(compacted, outputPath, markdownEnabled);
2354
2665
  if (options.discardSources) {
2355
- const discardSummary = discardSourceTrajectories(trajectories);
2666
+ const discardSummary = await discardSourceTrajectories(trajectories);
2356
2667
  printDiscardSummary(discardSummary);
2357
2668
  } else {
2358
2669
  await markTrajectoriesAsCompacted(trajectories, compacted.id);
@@ -2361,7 +2672,7 @@ Compacted trajectory saved to: ${outputPath2}`);
2361
2672
  Compacted trajectory saved to: ${outputPath}`);
2362
2673
  if (markdownEnabled) {
2363
2674
  console.log(
2364
- `Markdown summary saved to: ${getMarkdownOutputPath(outputPath)}`
2675
+ `Markdown summary saved to: ${getMarkdownOutputPath2(outputPath)}`
2365
2676
  );
2366
2677
  }
2367
2678
  printCompactedSummary(compacted);
@@ -2380,19 +2691,12 @@ async function loadTrajectories(options) {
2380
2691
  return [trimmed, trimmed.slice(0, 7)];
2381
2692
  })
2382
2693
  ) : null;
2383
- const compactedIds = options.all ? /* @__PURE__ */ new Set() : getCompactedTrajectoryIds();
2694
+ const compactedIds = options.all ? /* @__PURE__ */ new Set() : await getCompactedTrajectoryIds();
2384
2695
  const searchPaths = getSearchPaths();
2385
2696
  const seenIds = /* @__PURE__ */ new Set();
2386
2697
  for (const searchPath of searchPaths) {
2387
2698
  if (!existsSync3(searchPath)) continue;
2388
- const originalDataDir = process.env.TRAJECTORIES_DATA_DIR;
2389
- process.env.TRAJECTORIES_DATA_DIR = searchPath;
2390
- const storage = new FileStorage();
2391
- if (originalDataDir !== void 0) {
2392
- process.env.TRAJECTORIES_DATA_DIR = originalDataDir;
2393
- } else {
2394
- delete process.env.TRAJECTORIES_DATA_DIR;
2395
- }
2699
+ const storage = createStorageForSearchPath(searchPath);
2396
2700
  await storage.initialize();
2397
2701
  const summaries = await storage.list({
2398
2702
  status: "completed",
@@ -2438,6 +2742,19 @@ async function loadTrajectories(options) {
2438
2742
  }
2439
2743
  return trajectories;
2440
2744
  }
2745
+ function createStorageForSearchPath(searchPath) {
2746
+ const originalDataDir = process.env.TRAJECTORIES_DATA_DIR;
2747
+ process.env.TRAJECTORIES_DATA_DIR = searchPath;
2748
+ try {
2749
+ return new FileStorage();
2750
+ } finally {
2751
+ if (originalDataDir !== void 0) {
2752
+ process.env.TRAJECTORIES_DATA_DIR = originalDataDir;
2753
+ } else {
2754
+ delete process.env.TRAJECTORIES_DATA_DIR;
2755
+ }
2756
+ }
2757
+ }
2441
2758
  function getBranchCommits(targetBranch) {
2442
2759
  const commits = /* @__PURE__ */ new Set();
2443
2760
  try {
@@ -2462,21 +2779,17 @@ function getBranchCommits(targetBranch) {
2462
2779
  }
2463
2780
  return commits;
2464
2781
  }
2465
- function getCompactedTrajectoryIds() {
2782
+ async function getCompactedTrajectoryIds() {
2466
2783
  const compacted = /* @__PURE__ */ new Set();
2467
2784
  const searchPaths = getSearchPaths();
2468
2785
  for (const searchPath of searchPaths) {
2469
- const indexPath = join4(searchPath, "index.json");
2470
- if (!existsSync3(indexPath)) continue;
2471
- try {
2472
- const indexContent = readFileSync2(indexPath, "utf-8");
2473
- const index = JSON.parse(indexContent);
2474
- for (const [id, entry] of Object.entries(index.trajectories || {})) {
2475
- if (entry.compactedInto) {
2476
- compacted.add(id);
2477
- }
2478
- }
2479
- } catch {
2786
+ if (!existsSync3(searchPath)) {
2787
+ continue;
2788
+ }
2789
+ const storage = createStorageForSearchPath(searchPath);
2790
+ await storage.initialize();
2791
+ for (const id of await storage.getCompactedTrajectoryIds()) {
2792
+ compacted.add(id);
2480
2793
  }
2481
2794
  }
2482
2795
  return compacted;
@@ -2484,92 +2797,45 @@ function getCompactedTrajectoryIds() {
2484
2797
  async function markTrajectoriesAsCompacted(trajectories, compactedIntoId) {
2485
2798
  const searchPaths = getSearchPaths();
2486
2799
  for (const searchPath of searchPaths) {
2487
- const indexPath = join4(searchPath, "index.json");
2488
- if (!existsSync3(indexPath)) continue;
2489
- try {
2490
- const indexContent = readFileSync2(indexPath, "utf-8");
2491
- const index = JSON.parse(indexContent);
2492
- let updated = false;
2493
- for (const traj of trajectories) {
2494
- if (index.trajectories[traj.id]) {
2495
- index.trajectories[traj.id].compactedInto = compactedIntoId;
2496
- updated = true;
2497
- }
2498
- }
2499
- if (updated) {
2500
- index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
2501
- writeFileSync(indexPath, JSON.stringify(index, null, 2));
2502
- }
2503
- } catch {
2800
+ if (!existsSync3(searchPath)) {
2801
+ continue;
2504
2802
  }
2803
+ const storage = createStorageForSearchPath(searchPath);
2804
+ await storage.initialize();
2805
+ await storage.markCompactedMany(
2806
+ trajectories.map((trajectory) => trajectory.id),
2807
+ compactedIntoId
2808
+ );
2505
2809
  }
2506
2810
  }
2507
- function discardSourceTrajectories(trajectories) {
2508
- const sourceIds = new Set(trajectories.map((trajectory) => trajectory.id));
2811
+ async function discardSourceTrajectories(trajectories) {
2509
2812
  const summary = {
2510
- removedIndexEntries: 0,
2813
+ removedTrajectories: 0,
2511
2814
  deletedJsonFiles: 0,
2512
2815
  deletedMarkdownFiles: 0,
2513
- deletedTraceFiles: 0
2816
+ deletedTraceFiles: 0,
2817
+ deletedCompactionFiles: 0
2514
2818
  };
2515
2819
  for (const searchPath of getSearchPaths()) {
2516
- const indexPath = join4(searchPath, "index.json");
2517
- if (!existsSync3(indexPath)) continue;
2518
- let index;
2519
- try {
2520
- const indexContent = readFileSync2(indexPath, "utf-8");
2521
- const parsedIndex = JSON.parse(indexContent);
2522
- if (!isTrajectoryIndex(parsedIndex)) {
2523
- continue;
2524
- }
2525
- index = parsedIndex;
2526
- } catch {
2820
+ if (!existsSync3(searchPath)) {
2527
2821
  continue;
2528
2822
  }
2529
- let updated = false;
2530
- for (const id of sourceIds) {
2531
- const entry = index.trajectories[id];
2532
- if (!entry) continue;
2533
- if (deleteFileIfExists(entry.path)) {
2534
- summary.deletedJsonFiles += 1;
2535
- }
2536
- if (deleteFileIfExists(getMarkdownOutputPath(entry.path))) {
2537
- summary.deletedMarkdownFiles += 1;
2538
- }
2539
- if (deleteFileIfExists(getTraceOutputPath(entry.path))) {
2540
- summary.deletedTraceFiles += 1;
2541
- }
2542
- delete index.trajectories[id];
2543
- summary.removedIndexEntries += 1;
2544
- updated = true;
2545
- }
2546
- if (updated) {
2547
- index.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
2548
- writeFileSync(indexPath, JSON.stringify(index, null, 2));
2549
- }
2823
+ const storage = createStorageForSearchPath(searchPath);
2824
+ await storage.initialize();
2825
+ const deleteSummary = await storage.deleteManyWithSummary(
2826
+ trajectories.map((trajectory) => trajectory.id)
2827
+ );
2828
+ summary.removedTrajectories += deleteSummary.removedTrajectories;
2829
+ summary.deletedJsonFiles += deleteSummary.deletedJsonFiles;
2830
+ summary.deletedMarkdownFiles += deleteSummary.deletedMarkdownFiles;
2831
+ summary.deletedTraceFiles += deleteSummary.deletedTraceFiles;
2832
+ summary.deletedCompactionFiles += deleteSummary.deletedCompactionFiles;
2550
2833
  }
2551
2834
  return summary;
2552
2835
  }
2553
- function deleteFileIfExists(path2) {
2554
- if (!existsSync3(path2)) {
2555
- return false;
2556
- }
2557
- unlinkSync(path2);
2558
- return true;
2559
- }
2560
- function isTrajectoryIndex(value) {
2561
- if (value === null || typeof value !== "object") {
2562
- return false;
2563
- }
2564
- const candidate = value;
2565
- return candidate.trajectories !== null && typeof candidate.trajectories === "object" && !Array.isArray(candidate.trajectories);
2566
- }
2567
- function getTraceOutputPath(outputPath) {
2568
- return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".trace.json") : `${outputPath}.trace.json`;
2569
- }
2570
2836
  function printDiscardSummary(summary) {
2571
2837
  console.log(
2572
- `Discarded source trajectories: ${summary.removedIndexEntries} index entries, ${summary.deletedJsonFiles} JSON files, ${summary.deletedMarkdownFiles} Markdown files, ${summary.deletedTraceFiles} trace files`
2838
+ `Discarded source trajectories: ${summary.removedTrajectories} trajectories, ${summary.deletedJsonFiles} JSON files, ${summary.deletedMarkdownFiles} Markdown files, ${summary.deletedTraceFiles} trace files, ${summary.deletedCompactionFiles} compaction markers`
2573
2839
  );
2574
2840
  }
2575
2841
  function parseRelativeDate(input) {
@@ -2807,19 +3073,19 @@ function getDefaultOutputPath(compacted, workflowId) {
2807
3073
  return join4(compactedDir, `${compacted.id}_${dateStr}.json`);
2808
3074
  }
2809
3075
  function saveCompactionArtifacts(compacted, outputPath, markdownEnabled) {
2810
- const dir = dirname(outputPath);
3076
+ const dir = dirname2(outputPath);
2811
3077
  if (!existsSync3(dir)) {
2812
3078
  mkdirSync(dir, { recursive: true });
2813
3079
  }
2814
3080
  writeFileSync(outputPath, JSON.stringify(compacted, null, 2));
2815
3081
  if (markdownEnabled) {
2816
3082
  writeFileSync(
2817
- getMarkdownOutputPath(outputPath),
3083
+ getMarkdownOutputPath2(outputPath),
2818
3084
  renderCompactionMarkdown(compacted)
2819
3085
  );
2820
3086
  }
2821
3087
  }
2822
- function getMarkdownOutputPath(outputPath) {
3088
+ function getMarkdownOutputPath2(outputPath) {
2823
3089
  return outputPath.endsWith(".json") ? outputPath.slice(0, -".json".length).concat(".md") : `${outputPath}.md`;
2824
3090
  }
2825
3091
  function renderCompactionMarkdown(compacted) {
@@ -3142,7 +3408,7 @@ function createTraceRef(startRef, traceId) {
3142
3408
 
3143
3409
  // src/core/trailers.ts
3144
3410
  import { execSync as execSync2 } from "child_process";
3145
- import { readFileSync as readFileSync3 } from "fs";
3411
+ import { readFileSync as readFileSync2 } from "fs";
3146
3412
  function getCommitsBetween(startRef, endRef = "HEAD") {
3147
3413
  if (!isGitRepo()) {
3148
3414
  return [];
@@ -3255,7 +3521,7 @@ function detectExistingHook() {
3255
3521
  }).trim();
3256
3522
  const hookPath = `${hooksDir}/hooks/prepare-commit-msg`;
3257
3523
  try {
3258
- const content = readFileSync3(hookPath, "utf-8");
3524
+ const content = readFileSync2(hookPath, "utf-8");
3259
3525
  if (content.includes("agent-trajectories")) {
3260
3526
  return "ours";
3261
3527
  }
@@ -3393,6 +3659,51 @@ function registerDecisionCommand(program2) {
3393
3659
  });
3394
3660
  }
3395
3661
 
3662
+ // src/cli/commands/doctor.ts
3663
+ function registerDoctorCommand(program2) {
3664
+ program2.command("doctor").description(
3665
+ "List trajectory files that fail to load; optionally quarantine them"
3666
+ ).option(
3667
+ "--quarantine",
3668
+ "Move invalid files to .trajectories/invalid/ so reconcile stops scanning them"
3669
+ ).action(async (opts) => {
3670
+ const storage = new FileStorage();
3671
+ await storage.initialize();
3672
+ const summary = storage.getLastReconcileSummary();
3673
+ const failures = summary?.failures ?? [];
3674
+ if (failures.length === 0) {
3675
+ console.log("No invalid trajectory files found.");
3676
+ return;
3677
+ }
3678
+ console.log(`Found ${failures.length} invalid trajectory file(s):`);
3679
+ for (const failure of failures) {
3680
+ console.log(` ${failure.path}`);
3681
+ console.log(` reason: ${failure.reason}`);
3682
+ console.log(` detail: ${failure.message}`);
3683
+ }
3684
+ if (!opts.quarantine) {
3685
+ console.log(
3686
+ "\nRun `trail doctor --quarantine` to move these files into .trajectories/invalid/."
3687
+ );
3688
+ return;
3689
+ }
3690
+ const result = await storage.quarantineInvalid();
3691
+ if (result.moved.length === 0) {
3692
+ console.log(
3693
+ "\nNo files were moved (io_error failures are not auto-quarantined)."
3694
+ );
3695
+ return;
3696
+ }
3697
+ console.log(
3698
+ `
3699
+ Moved ${result.moved.length} file(s) to ${result.targetDir}:`
3700
+ );
3701
+ for (const failure of result.moved) {
3702
+ console.log(` ${failure.path}`);
3703
+ }
3704
+ });
3705
+ }
3706
+
3396
3707
  // src/cli/commands/enable.ts
3397
3708
  import { execSync as execSync3 } from "child_process";
3398
3709
  import { existsSync as existsSync5 } from "fs";
@@ -3470,8 +3781,8 @@ function registerEnableCommand(program2) {
3470
3781
  console.error("Remove it manually if needed");
3471
3782
  throw new Error("Hook not ours");
3472
3783
  }
3473
- const { unlink: unlink2 } = await import("fs/promises");
3474
- await unlink2(hookPath);
3784
+ const { unlink } = await import("fs/promises");
3785
+ await unlink(hookPath);
3475
3786
  console.log("Trajectory hook removed");
3476
3787
  });
3477
3788
  }
@@ -4627,9 +4938,32 @@ function registerStartCommand(program2) {
4627
4938
 
4628
4939
  // src/cli/commands/status.ts
4629
4940
  function registerStatusCommand(program2) {
4630
- program2.command("status").description("Show active trajectory status").action(async () => {
4941
+ program2.command("status").description("Show active trajectory status").option(
4942
+ "-v, --verbose",
4943
+ "Show paths and validation errors for any trajectory files that failed to load"
4944
+ ).action(async (opts) => {
4631
4945
  const storage = new FileStorage();
4632
4946
  await storage.initialize();
4947
+ if (opts.verbose) {
4948
+ const summary = storage.getLastReconcileSummary();
4949
+ const failures = summary?.failures ?? [];
4950
+ if (failures.length === 0) {
4951
+ console.log("All trajectory files loaded cleanly.");
4952
+ } else {
4953
+ console.log(
4954
+ `Skipped ${failures.length} trajectory file(s) during reconcile:`
4955
+ );
4956
+ for (const failure of failures) {
4957
+ console.log(` ${failure.path}`);
4958
+ console.log(` reason: ${failure.reason}`);
4959
+ console.log(` detail: ${failure.message}`);
4960
+ }
4961
+ console.log(
4962
+ "\nRun `trail doctor --quarantine` to move them aside and silence this warning."
4963
+ );
4964
+ }
4965
+ console.log("");
4966
+ }
4633
4967
  const active = await storage.getActive();
4634
4968
  if (!active) {
4635
4969
  console.log("No active trajectory");
@@ -4702,6 +5036,7 @@ function registerCommands(program2) {
4702
5036
  registerExportCommand(program2);
4703
5037
  registerEnableCommand(program2);
4704
5038
  registerCompactCommand(program2);
5039
+ registerDoctorCommand(program2);
4705
5040
  }
4706
5041
 
4707
5042
  // src/cli/version.ts