agent-trajectories 0.5.3 → 0.5.4

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
@@ -92,7 +92,7 @@ var DecisionSchema = z.object({
92
92
  });
93
93
  var AgentParticipationSchema = z.object({
94
94
  name: z.string().min(1, "Agent name is required"),
95
- role: z.enum(["lead", "contributor", "reviewer"]),
95
+ role: z.string().min(1, "Agent role is required"),
96
96
  joinedAt: z.string().datetime(),
97
97
  leftAt: z.string().datetime().optional()
98
98
  });
@@ -152,7 +152,7 @@ var TrajectoryTraceRefSchema = z.object({
152
152
  traceId: z.string().optional()
153
153
  });
154
154
  var TrajectorySchema = z.object({
155
- id: z.string().regex(/^traj_[a-z0-9]+$/, "Invalid trajectory ID format"),
155
+ id: z.string().regex(/^traj_[a-z0-9_]+$/, "Invalid trajectory ID format"),
156
156
  version: z.literal(1),
157
157
  task: TaskReferenceSchema,
158
158
  status: TrajectoryStatusSchema,
@@ -161,11 +161,11 @@ var TrajectorySchema = z.object({
161
161
  agents: z.array(AgentParticipationSchema),
162
162
  chapters: z.array(ChapterSchema),
163
163
  retrospective: RetrospectiveSchema.optional(),
164
- commits: z.array(z.string()),
165
- filesChanged: z.array(z.string()),
166
- projectId: z.string(),
164
+ commits: z.array(z.string()).default([]),
165
+ filesChanged: z.array(z.string()).default([]),
166
+ projectId: z.string().optional(),
167
167
  workflowId: z.string().optional(),
168
- tags: z.array(z.string()),
168
+ tags: z.array(z.string()).default([]),
169
169
  _trace: TrajectoryTraceRefSchema.optional()
170
170
  });
171
171
  var CreateTrajectoryInputSchema = z.object({
@@ -567,11 +567,11 @@ function extractDecisions(trajectory) {
567
567
  }
568
568
 
569
569
  // src/storage/file.ts
570
- function expandPath(path) {
571
- if (path.startsWith("~")) {
572
- return join(process.env.HOME ?? "", path.slice(1));
570
+ function expandPath(path2) {
571
+ if (path2.startsWith("~")) {
572
+ return join(process.env.HOME ?? "", path2.slice(1));
573
573
  }
574
- return path;
574
+ return path2;
575
575
  }
576
576
  function getSearchPaths() {
577
577
  const searchPathsEnv = process.env.TRAJECTORIES_SEARCH_PATHS;
@@ -616,11 +616,129 @@ var FileStorage = class {
616
616
  trajectories: {}
617
617
  });
618
618
  }
619
+ await this.reconcileIndex();
620
+ }
621
+ /**
622
+ * Scan active/ and completed/ recursively and add any trajectory files
623
+ * missing from the index. Existing entries are preserved — reconcile
624
+ * only adds, never removes.
625
+ *
626
+ * Handles three on-disk layouts in completed/:
627
+ * - flat: completed/{id}.json (legacy workforce data)
628
+ * - monthly: completed/YYYY-MM/{id}.json (current save() writes)
629
+ * - nested: completed/.../{id}.json (defensive — any depth)
630
+ *
631
+ * Returns a ReconcileSummary so tests and CLI wrappers can observe
632
+ * outcomes without parsing logs. Only writes the index if anything was
633
+ * added.
634
+ */
635
+ async reconcileIndex() {
636
+ const summary = {
637
+ scanned: 0,
638
+ added: 0,
639
+ alreadyIndexed: 0,
640
+ skippedMalformedJson: 0,
641
+ skippedSchemaViolation: 0,
642
+ skippedIoError: 0
643
+ };
644
+ const index = await this.loadIndex();
645
+ const before = Object.keys(index.trajectories).length;
646
+ const discovered = [];
647
+ try {
648
+ const activeFiles = await readdir(this.activeDir);
649
+ for (const file of activeFiles) {
650
+ if (!file.endsWith(".json")) continue;
651
+ discovered.push(join(this.activeDir, file));
652
+ }
653
+ } catch (error) {
654
+ if (error.code !== "ENOENT") throw error;
655
+ }
656
+ await this.walkJsonFilesInto(this.completedDir, discovered);
657
+ for (const filePath of discovered) {
658
+ summary.scanned += 1;
659
+ const result = await this.readTrajectoryFile(filePath);
660
+ if (!result.ok) {
661
+ if (result.reason === "malformed_json") {
662
+ summary.skippedMalformedJson += 1;
663
+ } else if (result.reason === "schema_violation") {
664
+ summary.skippedSchemaViolation += 1;
665
+ } else {
666
+ summary.skippedIoError += 1;
667
+ }
668
+ continue;
669
+ }
670
+ const trajectory = result.trajectory;
671
+ if (index.trajectories[trajectory.id]) {
672
+ summary.alreadyIndexed += 1;
673
+ continue;
674
+ }
675
+ index.trajectories[trajectory.id] = {
676
+ title: trajectory.task.title,
677
+ status: trajectory.status,
678
+ startedAt: trajectory.startedAt,
679
+ completedAt: trajectory.completedAt,
680
+ path: filePath
681
+ };
682
+ summary.added += 1;
683
+ }
684
+ if (Object.keys(index.trajectories).length !== before) {
685
+ await this.saveIndex(index);
686
+ }
687
+ const hadSkips = summary.skippedMalformedJson + summary.skippedSchemaViolation + summary.skippedIoError > 0;
688
+ if (summary.added > 0 || hadSkips) {
689
+ const parts = [`reconciled ${summary.added}/${summary.scanned}`];
690
+ if (summary.skippedMalformedJson > 0) {
691
+ parts.push(`malformed: ${summary.skippedMalformedJson}`);
692
+ }
693
+ if (summary.skippedSchemaViolation > 0) {
694
+ parts.push(`invalid: ${summary.skippedSchemaViolation}`);
695
+ }
696
+ if (summary.skippedIoError > 0) {
697
+ parts.push(`io: ${summary.skippedIoError}`);
698
+ }
699
+ console.warn(`[trajectories] ${parts.join(", ")}`);
700
+ }
701
+ return summary;
702
+ }
703
+ /**
704
+ * Recursively collect all .json file paths under `dir` into `out`.
705
+ * Silently treats a missing directory as empty.
706
+ */
707
+ async walkJsonFilesInto(dir, out) {
708
+ let entries;
709
+ try {
710
+ entries = await readdir(dir, { withFileTypes: true });
711
+ } catch (error) {
712
+ if (error.code === "ENOENT") return;
713
+ throw error;
714
+ }
715
+ for (const entry of entries) {
716
+ const entryPath = join(dir, entry.name);
717
+ if (entry.isDirectory()) {
718
+ await this.walkJsonFilesInto(entryPath, out);
719
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
720
+ out.push(entryPath);
721
+ }
722
+ }
619
723
  }
620
724
  /**
621
- * Save a trajectory
725
+ * Save a trajectory.
726
+ *
727
+ * Validates the input against the trajectory schema before touching
728
+ * disk. Closes the historical read/write asymmetry where save() would
729
+ * happily write data that the reader then rejected, producing files
730
+ * that could never be loaded back.
622
731
  */
623
- async save(trajectory) {
732
+ async save(input) {
733
+ const validation = validateTrajectory(input);
734
+ if (!validation.success) {
735
+ const issues = validation.errors?.issues.map((issue) => {
736
+ const path2 = issue.path.length > 0 ? issue.path.join(".") : "root";
737
+ return `${path2}: ${issue.message}`;
738
+ }).join("; ") ?? "unknown validation error";
739
+ throw new Error(`Cannot save invalid trajectory: ${issues}`);
740
+ }
741
+ const trajectory = validation.data;
624
742
  const isCompleted = trajectory.status === "completed" || trajectory.status === "abandoned";
625
743
  let filePath;
626
744
  if (isCompleted) {
@@ -650,19 +768,23 @@ var FileStorage = class {
650
768
  async get(id) {
651
769
  const activePath = join(this.activeDir, `${id}.json`);
652
770
  if (existsSync(activePath)) {
653
- return this.readTrajectoryFile(activePath);
771
+ return this.readTrajectoryOrNull(activePath);
654
772
  }
655
773
  const index = await this.loadIndex();
656
774
  const entry = index.trajectories[id];
657
775
  if (entry?.path && existsSync(entry.path)) {
658
- return this.readTrajectoryFile(entry.path);
776
+ return this.readTrajectoryOrNull(entry.path);
659
777
  }
660
778
  try {
779
+ const flatPath = join(this.completedDir, `${id}.json`);
780
+ if (existsSync(flatPath)) {
781
+ return this.readTrajectoryOrNull(flatPath);
782
+ }
661
783
  const months = await readdir(this.completedDir);
662
784
  for (const month of months) {
663
785
  const filePath = join(this.completedDir, month, `${id}.json`);
664
786
  if (existsSync(filePath)) {
665
- return this.readTrajectoryFile(filePath);
787
+ return this.readTrajectoryOrNull(filePath);
666
788
  }
667
789
  }
668
790
  } catch (error) {
@@ -685,7 +807,7 @@ var FileStorage = class {
685
807
  let mostRecent = null;
686
808
  let mostRecentTime = 0;
687
809
  for (const file of jsonFiles) {
688
- const trajectory = await this.readTrajectoryFile(
810
+ const trajectory = await this.readTrajectoryOrNull(
689
811
  join(this.activeDir, file)
690
812
  );
691
813
  if (trajectory) {
@@ -813,20 +935,44 @@ var FileStorage = class {
813
935
  async close() {
814
936
  }
815
937
  // Private helpers
816
- async readTrajectoryFile(path) {
938
+ /**
939
+ * Read a trajectory file and return a tagged result so callers can
940
+ * distinguish missing files, malformed JSON, and schema violations.
941
+ *
942
+ * Does NOT log. Callers choose whether to warn, swallow, or throw.
943
+ */
944
+ async readTrajectoryFile(path2) {
945
+ let content;
817
946
  try {
818
- const content = await readFile(path, "utf-8");
819
- const data = JSON.parse(content);
820
- const validation = validateTrajectory(data);
821
- if (validation.success) {
822
- return validation.data;
823
- }
824
- console.error(`Invalid trajectory at ${path}:`, validation.errors);
825
- return null;
947
+ content = await readFile(path2, "utf-8");
826
948
  } catch (error) {
827
- console.error(`Failed to read trajectory at ${path}:`, error);
828
- return null;
949
+ return { ok: false, reason: "io_error", path: path2, error };
829
950
  }
951
+ let data;
952
+ try {
953
+ data = JSON.parse(content);
954
+ } catch (error) {
955
+ return { ok: false, reason: "malformed_json", path: path2, error };
956
+ }
957
+ const validation = validateTrajectory(data);
958
+ if (validation.success) {
959
+ return { ok: true, trajectory: validation.data };
960
+ }
961
+ return {
962
+ ok: false,
963
+ reason: "schema_violation",
964
+ path: path2,
965
+ error: validation.errors
966
+ };
967
+ }
968
+ /**
969
+ * Convenience wrapper for callers that only care whether they got a
970
+ * trajectory. Returns null for any failure and writes nothing to the
971
+ * console — so nothing leaks into test output or the CLI spinner.
972
+ */
973
+ async readTrajectoryOrNull(path2) {
974
+ const result = await this.readTrajectoryFile(path2);
975
+ return result.ok ? result.trajectory : null;
830
976
  }
831
977
  async loadIndex() {
832
978
  try {
@@ -1662,9 +1808,9 @@ async function resolveCLIProvider() {
1662
1808
  return SUPPORTED_CLIS;
1663
1809
  })();
1664
1810
  for (const cli of clisToTry) {
1665
- const path = await findBinary(cli);
1666
- if (path) {
1667
- return new CLIProvider(cli, path);
1811
+ const path2 = await findBinary(cli);
1812
+ if (path2) {
1813
+ return new CLIProvider(cli, path2);
1668
1814
  }
1669
1815
  }
1670
1816
  return null;
@@ -1672,8 +1818,8 @@ async function resolveCLIProvider() {
1672
1818
  async function findBinary(name) {
1673
1819
  try {
1674
1820
  const { stdout } = await execFileAsync("which", [name]);
1675
- const path = stdout.trim();
1676
- if (path) return path;
1821
+ const path2 = stdout.trim();
1822
+ if (path2) return path2;
1677
1823
  } catch {
1678
1824
  }
1679
1825
  const home = homedir();
@@ -2775,8 +2921,8 @@ function generateTrace(trajectory, startRef) {
2775
2921
  return null;
2776
2922
  }
2777
2923
  const model = detectModel();
2778
- const traceFiles = changedFiles.map(({ path, ranges }) => ({
2779
- path,
2924
+ const traceFiles = changedFiles.map(({ path: path2, ranges }) => ({
2925
+ path: path2,
2780
2926
  conversations: [
2781
2927
  {
2782
2928
  contributor: {
@@ -2923,8 +3069,11 @@ if [ -z "$ACTIVE_FILE" ]; then
2923
3069
  exit 0
2924
3070
  fi
2925
3071
 
2926
- # Extract trajectory ID (grep for the "id" field)
2927
- TRAJ_ID=$(grep -o '"id"[[:space:]]*:[[:space:]]*"traj_[a-z0-9]*"' "$ACTIVE_FILE" | head -1 | grep -o 'traj_[a-z0-9]*')
3072
+ # Extract trajectory ID (grep for the "id" field). Character class must
3073
+ # include underscore to match legacy traj_<timestamp>_<hex> ids -- without
3074
+ # it, grep -o silently truncates at the first internal underscore and
3075
+ # emits a wrong (shorter) id into the commit trailer.
3076
+ TRAJ_ID=$(grep -o '"id"[[:space:]]*:[[:space:]]*"traj_[a-z0-9_]*"' "$ACTIVE_FILE" | head -1 | grep -o 'traj_[a-z0-9_]*')
2928
3077
  if [ -z "$TRAJ_ID" ]; then
2929
3078
  exit 0
2930
3079
  fi
@@ -3882,19 +4031,19 @@ function registerExportCommand(program2) {
3882
4031
  }
3883
4032
  });
3884
4033
  }
3885
- function openInBrowser(path) {
4034
+ function openInBrowser(path2) {
3886
4035
  const platform = process.platform;
3887
4036
  let command;
3888
4037
  if (platform === "darwin") {
3889
- command = `open "${path}"`;
4038
+ command = `open "${path2}"`;
3890
4039
  } else if (platform === "win32") {
3891
- command = `start "" "${path}"`;
4040
+ command = `start "" "${path2}"`;
3892
4041
  } else {
3893
- command = `xdg-open "${path}"`;
4042
+ command = `xdg-open "${path2}"`;
3894
4043
  }
3895
4044
  exec(command, (error) => {
3896
4045
  if (error) {
3897
- console.log(`Open manually: file://${path}`);
4046
+ console.log(`Open manually: file://${path2}`);
3898
4047
  }
3899
4048
  });
3900
4049
  }
@@ -4399,8 +4548,37 @@ function registerCommands(program2) {
4399
4548
  registerCompactCommand(program2);
4400
4549
  }
4401
4550
 
4551
+ // src/cli/version.ts
4552
+ import fs from "fs";
4553
+ import path from "path";
4554
+ import { fileURLToPath } from "url";
4555
+ function findPackageJson(startDir) {
4556
+ let dir = startDir;
4557
+ while (dir !== path.dirname(dir)) {
4558
+ const candidate = path.join(dir, "package.json");
4559
+ if (fs.existsSync(candidate)) {
4560
+ return candidate;
4561
+ }
4562
+ dir = path.dirname(dir);
4563
+ }
4564
+ throw new Error("Could not find package.json");
4565
+ }
4566
+ function resolveCliVersion() {
4567
+ try {
4568
+ const here = path.dirname(fileURLToPath(import.meta.url));
4569
+ const packageJsonPath = findPackageJson(here);
4570
+ const packageJson = JSON.parse(
4571
+ fs.readFileSync(packageJsonPath, "utf-8")
4572
+ );
4573
+ return packageJson.version ?? "unknown";
4574
+ } catch {
4575
+ return "unknown";
4576
+ }
4577
+ }
4578
+ var VERSION = resolveCliVersion();
4579
+
4402
4580
  // src/cli/index.ts
4403
- program.name("trail").description("Leave a trail of your work for others to follow").version("0.1.0").option(
4581
+ program.name("trail").description("Leave a trail of your work for others to follow").version(VERSION).option(
4404
4582
  "--data-dir <path>",
4405
4583
  "Override trajectory storage directory (or set TRAJECTORIES_DATA_DIR)"
4406
4584
  ).hook("preAction", (thisCommand) => {