@os-eco/overstory-cli 0.8.4 → 0.8.6

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.
Files changed (43) hide show
  1. package/README.md +4 -2
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/manifest.test.ts +33 -8
  5. package/src/agents/manifest.ts +4 -3
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +420 -1
  9. package/src/commands/coordinator.ts +173 -1
  10. package/src/commands/init.test.ts +137 -0
  11. package/src/commands/init.ts +57 -1
  12. package/src/commands/inspect.test.ts +398 -1
  13. package/src/commands/inspect.ts +234 -0
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.ts +312 -322
  18. package/src/commands/spec.ts +8 -2
  19. package/src/commands/stop.test.ts +127 -6
  20. package/src/commands/stop.ts +95 -43
  21. package/src/commands/watch.ts +29 -9
  22. package/src/config.test.ts +72 -0
  23. package/src/config.ts +26 -1
  24. package/src/events/tailer.test.ts +461 -0
  25. package/src/events/tailer.ts +235 -0
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +243 -19
  28. package/src/merge/resolver.ts +235 -95
  29. package/src/runtimes/claude.test.ts +1 -1
  30. package/src/runtimes/opencode.test.ts +325 -0
  31. package/src/runtimes/opencode.ts +185 -0
  32. package/src/runtimes/pi.test.ts +119 -2
  33. package/src/runtimes/pi.ts +61 -12
  34. package/src/runtimes/registry.test.ts +21 -1
  35. package/src/runtimes/registry.ts +3 -0
  36. package/src/runtimes/sapling.test.ts +30 -0
  37. package/src/runtimes/sapling.ts +27 -24
  38. package/src/runtimes/types.ts +2 -2
  39. package/src/types.ts +19 -0
  40. package/src/watchdog/daemon.test.ts +257 -0
  41. package/src/watchdog/daemon.ts +123 -23
  42. package/src/worktree/manager.test.ts +65 -1
  43. package/src/worktree/manager.ts +36 -0
@@ -702,6 +702,143 @@ describe("initCommand: ecosystem bootstrap", () => {
702
702
  });
703
703
  });
704
704
 
705
+ describe("initCommand: scaffold commit", () => {
706
+ let tempDir: string;
707
+ let originalCwd: string;
708
+ let originalWrite: typeof process.stdout.write;
709
+
710
+ beforeEach(async () => {
711
+ tempDir = await createTempGitRepo();
712
+ originalCwd = process.cwd();
713
+ process.chdir(tempDir);
714
+ originalWrite = process.stdout.write;
715
+ process.stdout.write = (() => true) as typeof process.stdout.write;
716
+ });
717
+
718
+ afterEach(async () => {
719
+ process.chdir(originalCwd);
720
+ process.stdout.write = originalWrite;
721
+ await cleanupTempDir(tempDir);
722
+ });
723
+
724
+ test("git commit is called with scaffold message when git add succeeds and changes are staged", async () => {
725
+ const calls: string[][] = [];
726
+ const spawner: import("./init.ts").Spawner = async (args) => {
727
+ calls.push(args);
728
+ const key = args.join(" ");
729
+ // Sibling tool calls: all "not installed"
730
+ if (key.endsWith("--version")) return { exitCode: 1, stdout: "", stderr: "not found" };
731
+ // git add: success
732
+ if (key.startsWith("git add")) return { exitCode: 0, stdout: "", stderr: "" };
733
+ // git diff --cached --quiet: exit 1 means changes are staged
734
+ if (key.startsWith("git diff --cached --quiet"))
735
+ return { exitCode: 1, stdout: "", stderr: "" };
736
+ // git commit: success
737
+ if (key.startsWith("git commit")) return { exitCode: 0, stdout: "", stderr: "" };
738
+ return { exitCode: 1, stdout: "", stderr: "not found" };
739
+ };
740
+
741
+ await initCommand({ _spawner: spawner });
742
+
743
+ expect(calls).toContainEqual([
744
+ "git",
745
+ "commit",
746
+ "-m",
747
+ "chore: initialize overstory and ecosystem tools",
748
+ ]);
749
+ });
750
+
751
+ test("git commit is NOT called when git diff reports nothing staged (exit 0)", async () => {
752
+ const calls: string[][] = [];
753
+ const spawner: import("./init.ts").Spawner = async (args) => {
754
+ calls.push(args);
755
+ const key = args.join(" ");
756
+ if (key.endsWith("--version")) return { exitCode: 1, stdout: "", stderr: "not found" };
757
+ if (key.startsWith("git add")) return { exitCode: 0, stdout: "", stderr: "" };
758
+ // exit 0 = nothing staged
759
+ if (key.startsWith("git diff --cached --quiet"))
760
+ return { exitCode: 0, stdout: "", stderr: "" };
761
+ if (key.startsWith("git commit")) return { exitCode: 0, stdout: "", stderr: "" };
762
+ return { exitCode: 1, stdout: "", stderr: "not found" };
763
+ };
764
+
765
+ await initCommand({ _spawner: spawner });
766
+
767
+ const commitCalls = calls.filter((c) => c[0] === "git" && c[1] === "commit");
768
+ expect(commitCalls).toHaveLength(0);
769
+ });
770
+
771
+ test("git commit failure does not throw — init still succeeds", async () => {
772
+ const spawner: import("./init.ts").Spawner = async (args) => {
773
+ const key = args.join(" ");
774
+ if (key.endsWith("--version")) return { exitCode: 1, stdout: "", stderr: "not found" };
775
+ if (key.startsWith("git add")) return { exitCode: 0, stdout: "", stderr: "" };
776
+ if (key.startsWith("git diff --cached --quiet"))
777
+ return { exitCode: 1, stdout: "", stderr: "" };
778
+ // commit fails
779
+ if (key.startsWith("git commit"))
780
+ return { exitCode: 1, stdout: "", stderr: "nothing to commit" };
781
+ return { exitCode: 1, stdout: "", stderr: "not found" };
782
+ };
783
+
784
+ // Should not throw
785
+ await expect(initCommand({ _spawner: spawner })).resolves.toBeUndefined();
786
+
787
+ // .overstory files should still be created
788
+ const configPath = join(tempDir, ".overstory", "config.yaml");
789
+ const exists = await Bun.file(configPath).exists();
790
+ expect(exists).toBe(true);
791
+ });
792
+
793
+ test("git add failure skips commit without throwing", async () => {
794
+ const calls: string[][] = [];
795
+ const spawner: import("./init.ts").Spawner = async (args) => {
796
+ calls.push(args);
797
+ const key = args.join(" ");
798
+ if (key.endsWith("--version")) return { exitCode: 1, stdout: "", stderr: "not found" };
799
+ // git add fails
800
+ if (key.startsWith("git add")) return { exitCode: 1, stdout: "", stderr: "git add failed" };
801
+ if (key.startsWith("git commit")) return { exitCode: 0, stdout: "", stderr: "" };
802
+ return { exitCode: 1, stdout: "", stderr: "not found" };
803
+ };
804
+
805
+ await expect(initCommand({ _spawner: spawner })).resolves.toBeUndefined();
806
+
807
+ // commit should NOT have been called since add failed
808
+ const commitCalls = calls.filter((c) => c[0] === "git" && c[1] === "commit");
809
+ expect(commitCalls).toHaveLength(0);
810
+ });
811
+
812
+ test("--json output includes scaffoldCommitted boolean", async () => {
813
+ const spawner: import("./init.ts").Spawner = async (args) => {
814
+ const key = args.join(" ");
815
+ if (key.endsWith("--version")) return { exitCode: 1, stdout: "", stderr: "not found" };
816
+ if (key.startsWith("git add")) return { exitCode: 0, stdout: "", stderr: "" };
817
+ if (key.startsWith("git diff --cached --quiet"))
818
+ return { exitCode: 1, stdout: "", stderr: "" };
819
+ if (key.startsWith("git commit")) return { exitCode: 0, stdout: "", stderr: "" };
820
+ return { exitCode: 1, stdout: "", stderr: "not found" };
821
+ };
822
+
823
+ let capturedOutput = "";
824
+ const restoreWrite = process.stdout.write;
825
+ process.stdout.write = ((chunk: unknown) => {
826
+ capturedOutput += String(chunk);
827
+ return true;
828
+ }) as typeof process.stdout.write;
829
+
830
+ await initCommand({ json: true, _spawner: spawner });
831
+
832
+ process.stdout.write = restoreWrite;
833
+
834
+ const jsonLine = capturedOutput.split("\n").find((line) => line.startsWith('{"success":'));
835
+ expect(jsonLine).toBeDefined();
836
+ const parsed = JSON.parse(jsonLine ?? "{}") as Record<string, unknown>;
837
+ expect(typeof parsed.scaffoldCommitted).toBe("boolean");
838
+ expect(parsed.scaffoldCommitted).toBe(true);
839
+ });
840
+ });
841
+
705
842
  describe("initCommand: .gitattributes setup", () => {
706
843
  let tempDir: string;
707
844
  let originalCwd: string;
@@ -815,13 +815,69 @@ export async function initCommand(opts: InitOptions): Promise<void> {
815
815
  }
816
816
  }
817
817
 
818
- // 12. Output final result
818
+ // 12. Auto-commit scaffold files so ecosystem dirs are tracked before agents create branches.
819
+ // Without this, agent branches that add files to .mulch/.seeds/.canopy cause
820
+ // untracked-vs-tracked conflicts in ov merge (overstory-fe42).
821
+ let scaffoldCommitted = false;
822
+ const pathsToAdd: string[] = [OVERSTORY_DIR];
823
+
824
+ // Add .gitattributes if it exists
825
+ try {
826
+ await stat(join(projectRoot, ".gitattributes"));
827
+ pathsToAdd.push(".gitattributes");
828
+ } catch {
829
+ // not present — skip
830
+ }
831
+
832
+ // Add CLAUDE.md if it exists (may have been modified by onboard)
833
+ try {
834
+ await stat(join(projectRoot, "CLAUDE.md"));
835
+ pathsToAdd.push("CLAUDE.md");
836
+ } catch {
837
+ // not present — skip
838
+ }
839
+
840
+ // Add sibling tool dirs that were created
841
+ for (const tool of SIBLING_TOOLS) {
842
+ try {
843
+ await stat(join(projectRoot, tool.dotDir));
844
+ pathsToAdd.push(tool.dotDir);
845
+ } catch {
846
+ // not present — skip
847
+ }
848
+ }
849
+
850
+ const addResult = await spawner(["git", "add", ...pathsToAdd], { cwd: projectRoot });
851
+ if (addResult.exitCode !== 0) {
852
+ printWarning("Scaffold commit skipped", addResult.stderr.trim() || "git add failed");
853
+ } else {
854
+ // git diff --cached --quiet exits 0 if nothing staged, 1 if changes are staged
855
+ const diffResult = await spawner(["git", "diff", "--cached", "--quiet"], {
856
+ cwd: projectRoot,
857
+ });
858
+ if (diffResult.exitCode !== 0) {
859
+ // Changes are staged — commit them
860
+ const commitResult = await spawner(
861
+ ["git", "commit", "-m", "chore: initialize overstory and ecosystem tools"],
862
+ { cwd: projectRoot },
863
+ );
864
+ if (commitResult.exitCode === 0) {
865
+ printSuccess("Committed", "scaffold files");
866
+ scaffoldCommitted = true;
867
+ } else {
868
+ printWarning("Scaffold commit failed", commitResult.stderr.trim() || "git commit failed");
869
+ }
870
+ }
871
+ }
872
+
873
+ // 13. Output final result
819
874
  if (opts.json) {
820
875
  jsonOutput("init", {
821
876
  project: projectName,
822
877
  tools: toolResults,
823
878
  onboard: onboardResults,
824
879
  gitattributes: gitattrsUpdated,
880
+ scaffoldCommitted,
825
881
  });
826
882
  return;
827
883
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
- import { mkdtemp } from "node:fs/promises";
12
+ import { mkdir, mkdtemp } from "node:fs/promises";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { ValidationError } from "../errors.ts";
@@ -672,6 +672,7 @@ describe("inspectCommand", () => {
672
672
  toolStats: [],
673
673
  tokenUsage: null,
674
674
  tmuxOutput: null,
675
+ headlessTurnInfo: null,
675
676
  };
676
677
 
677
678
  printInspectData(data);
@@ -710,6 +711,7 @@ describe("inspectCommand", () => {
710
711
  toolStats: [],
711
712
  tokenUsage: null,
712
713
  tmuxOutput: "[Headless agent — showing recent tool events]",
714
+ headlessTurnInfo: null,
713
715
  };
714
716
 
715
717
  printInspectData(data);
@@ -720,6 +722,401 @@ describe("inspectCommand", () => {
720
722
  });
721
723
  });
722
724
 
725
+ // === stdout.log fallback (headless agents) ===
726
+
727
+ describe("stdout.log fallback", () => {
728
+ /** Create a headless session in SessionStore and return the overstoryDir. */
729
+ async function setupHeadlessSession(
730
+ agentName: string,
731
+ worktreePathVal = "/tmp/wt",
732
+ ): Promise<string> {
733
+ const overstoryDir = join(tempDir, ".overstory");
734
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
735
+ const store = createSessionStore(sessionsDbPath);
736
+ store.upsert({
737
+ id: `sess-${agentName}`,
738
+ agentName,
739
+ capability: "builder",
740
+ worktreePath: worktreePathVal,
741
+ branchName: `overstory/headless/${agentName}`,
742
+ taskId: "overstory-h10",
743
+ tmuxSession: "", // headless
744
+ state: "working",
745
+ pid: process.pid,
746
+ parentAgent: null,
747
+ depth: 0,
748
+ runId: null,
749
+ startedAt: new Date().toISOString(),
750
+ lastActivity: new Date().toISOString(),
751
+ escalationLevel: 0,
752
+ stalledSince: null,
753
+ transcriptPath: null,
754
+ });
755
+ store.close();
756
+ return overstoryDir;
757
+ }
758
+
759
+ /** Write NDJSON events to stdout.log in the agent's log dir. */
760
+ async function writeStdoutLog(
761
+ overstoryDir: string,
762
+ agentName: string,
763
+ events: Record<string, unknown>[],
764
+ ): Promise<void> {
765
+ const logDir = join(overstoryDir, "logs", agentName, "2026-03-05T14-30-00-000Z");
766
+ await mkdir(logDir, { recursive: true });
767
+ const ndjson = `${events.map((e) => JSON.stringify(e)).join("\n")}\n`;
768
+ await Bun.write(join(logDir, "stdout.log"), ndjson);
769
+ }
770
+
771
+ test("populates recentToolCalls from stdout.log when events.db is empty", async () => {
772
+ const overstoryDir = await setupHeadlessSession("stdout-tools");
773
+ await writeStdoutLog(overstoryDir, "stdout-tools", [
774
+ {
775
+ type: "tool_start",
776
+ timestamp: "2026-03-05T14:30:01.000Z",
777
+ toolName: "Read",
778
+ argsSummary: "src/index.ts",
779
+ },
780
+ {
781
+ type: "tool_end",
782
+ timestamp: "2026-03-05T14:30:01.050Z",
783
+ toolName: "Read",
784
+ success: true,
785
+ durationMs: 50,
786
+ },
787
+ {
788
+ type: "tool_start",
789
+ timestamp: "2026-03-05T14:30:02.000Z",
790
+ toolName: "Edit",
791
+ argsSummary: "src/commands/inspect.ts",
792
+ },
793
+ {
794
+ type: "tool_end",
795
+ timestamp: "2026-03-05T14:30:02.200Z",
796
+ toolName: "Edit",
797
+ success: true,
798
+ durationMs: 200,
799
+ },
800
+ ]);
801
+
802
+ const data = await gatherInspectData(tempDir, "stdout-tools", { noTmux: true });
803
+
804
+ expect(data.recentToolCalls.length).toBe(2);
805
+ expect(data.recentToolCalls[0]?.toolName).toBe("Read");
806
+ expect(data.recentToolCalls[0]?.durationMs).toBe(50);
807
+ expect(data.recentToolCalls[0]?.args).toBe("src/index.ts");
808
+ expect(data.recentToolCalls[1]?.toolName).toBe("Edit");
809
+ expect(data.recentToolCalls[1]?.durationMs).toBe(200);
810
+ });
811
+
812
+ test("populates tokenUsage from turn_end events when metrics.db is absent", async () => {
813
+ const overstoryDir = await setupHeadlessSession("stdout-tokens");
814
+ await writeStdoutLog(overstoryDir, "stdout-tokens", [
815
+ {
816
+ type: "turn_start",
817
+ timestamp: "2026-03-05T14:30:00.000Z",
818
+ turn: 1,
819
+ },
820
+ {
821
+ type: "turn_end",
822
+ timestamp: "2026-03-05T14:30:05.000Z",
823
+ inputTokens: 1000,
824
+ outputTokens: 500,
825
+ cacheReadTokens: 200,
826
+ model: "claude-sonnet-4-6",
827
+ contextUtilization: 0.3,
828
+ },
829
+ {
830
+ type: "turn_start",
831
+ timestamp: "2026-03-05T14:30:06.000Z",
832
+ turn: 2,
833
+ },
834
+ {
835
+ type: "turn_end",
836
+ timestamp: "2026-03-05T14:30:10.000Z",
837
+ inputTokens: 800,
838
+ outputTokens: 300,
839
+ cacheReadTokens: 150,
840
+ model: "claude-sonnet-4-6",
841
+ contextUtilization: 0.45,
842
+ },
843
+ ]);
844
+
845
+ const data = await gatherInspectData(tempDir, "stdout-tokens", { noTmux: true });
846
+
847
+ // Token usage should be cumulative across turn_end events
848
+ expect(data.tokenUsage).not.toBeNull();
849
+ expect(data.tokenUsage?.inputTokens).toBe(1800);
850
+ expect(data.tokenUsage?.outputTokens).toBe(800);
851
+ expect(data.tokenUsage?.cacheReadTokens).toBe(350);
852
+ expect(data.tokenUsage?.modelUsed).toBe("claude-sonnet-4-6");
853
+ expect(data.tokenUsage?.cacheCreationTokens).toBe(0);
854
+ expect(data.tokenUsage?.estimatedCostUsd).toBeNull();
855
+ });
856
+
857
+ test("populates headlessTurnInfo with turn number, context utilization, and isMidTool", async () => {
858
+ const overstoryDir = await setupHeadlessSession("stdout-turn-info");
859
+ await writeStdoutLog(overstoryDir, "stdout-turn-info", [
860
+ {
861
+ type: "turn_start",
862
+ timestamp: "2026-03-05T14:30:00.000Z",
863
+ turn: 3,
864
+ },
865
+ {
866
+ type: "tool_start",
867
+ timestamp: "2026-03-05T14:30:01.000Z",
868
+ toolName: "Bash",
869
+ argsSummary: "bun test",
870
+ },
871
+ // No tool_end — still mid-tool
872
+ ]);
873
+
874
+ const data = await gatherInspectData(tempDir, "stdout-turn-info", { noTmux: true });
875
+
876
+ expect(data.headlessTurnInfo).not.toBeNull();
877
+ expect(data.headlessTurnInfo?.currentTurn).toBe(3);
878
+ expect(data.headlessTurnInfo?.isMidTool).toBe(true);
879
+ });
880
+
881
+ test("isMidTool is false when last event is not tool_start", async () => {
882
+ const overstoryDir = await setupHeadlessSession("stdout-between-turns");
883
+ await writeStdoutLog(overstoryDir, "stdout-between-turns", [
884
+ {
885
+ type: "turn_start",
886
+ timestamp: "2026-03-05T14:30:00.000Z",
887
+ turn: 2,
888
+ },
889
+ {
890
+ type: "turn_end",
891
+ timestamp: "2026-03-05T14:30:05.000Z",
892
+ inputTokens: 500,
893
+ outputTokens: 200,
894
+ cacheReadTokens: 0,
895
+ model: "claude-sonnet-4-6",
896
+ contextUtilization: 0.2,
897
+ },
898
+ ]);
899
+
900
+ const data = await gatherInspectData(tempDir, "stdout-between-turns", { noTmux: true });
901
+
902
+ expect(data.headlessTurnInfo?.isMidTool).toBe(false);
903
+ expect(data.headlessTurnInfo?.contextUtilization).toBeCloseTo(0.2);
904
+ });
905
+
906
+ test("does not overwrite tokenUsage from metrics.db with stdout.log data", async () => {
907
+ const overstoryDir = await setupHeadlessSession("stdout-no-override");
908
+ const metricsDbPath = join(overstoryDir, "metrics.db");
909
+
910
+ // Metrics DB has authoritative data
911
+ const metricsStore = createMetricsStore(metricsDbPath);
912
+ metricsStore.recordSession(
913
+ makeMetrics({
914
+ agentName: "stdout-no-override",
915
+ inputTokens: 9999,
916
+ outputTokens: 8888,
917
+ modelUsed: "claude-opus-4-6",
918
+ }),
919
+ );
920
+ metricsStore.close();
921
+
922
+ // stdout.log also has token data
923
+ await writeStdoutLog(overstoryDir, "stdout-no-override", [
924
+ {
925
+ type: "turn_end",
926
+ timestamp: "2026-03-05T14:30:05.000Z",
927
+ inputTokens: 100,
928
+ outputTokens: 50,
929
+ cacheReadTokens: 0,
930
+ model: "claude-sonnet-4-6",
931
+ contextUtilization: 0.1,
932
+ },
933
+ ]);
934
+
935
+ const data = await gatherInspectData(tempDir, "stdout-no-override", { noTmux: true });
936
+
937
+ // metrics.db data wins
938
+ expect(data.tokenUsage?.inputTokens).toBe(9999);
939
+ expect(data.tokenUsage?.modelUsed).toBe("claude-opus-4-6");
940
+ });
941
+
942
+ test("gracefully handles missing stdout.log", async () => {
943
+ const overstoryDir = await setupHeadlessSession("stdout-missing");
944
+ // Create log dir but no stdout.log inside it
945
+ await mkdir(join(overstoryDir, "logs", "stdout-missing", "2026-03-05T14-30-00-000Z"), {
946
+ recursive: true,
947
+ });
948
+
949
+ const data = await gatherInspectData(tempDir, "stdout-missing", { noTmux: true });
950
+
951
+ expect(data.recentToolCalls).toEqual([]);
952
+ expect(data.tokenUsage).toBeNull();
953
+ expect(data.headlessTurnInfo).toBeNull();
954
+ });
955
+
956
+ test("gracefully handles no log dir at all", async () => {
957
+ await setupHeadlessSession("stdout-no-log-dir");
958
+ // Don't create any log dir
959
+
960
+ const data = await gatherInspectData(tempDir, "stdout-no-log-dir", { noTmux: true });
961
+
962
+ expect(data.recentToolCalls).toEqual([]);
963
+ expect(data.headlessTurnInfo).toBeNull();
964
+ });
965
+
966
+ test("respects limit when populating recentToolCalls from stdout.log", async () => {
967
+ const overstoryDir = await setupHeadlessSession("stdout-limit");
968
+ const events: Record<string, unknown>[] = [];
969
+ for (let i = 0; i < 10; i++) {
970
+ events.push({
971
+ type: "tool_start",
972
+ timestamp: `2026-03-05T14:30:0${i}.000Z`,
973
+ toolName: "Read",
974
+ argsSummary: `src/file${i}.ts`,
975
+ });
976
+ events.push({
977
+ type: "tool_end",
978
+ timestamp: `2026-03-05T14:30:0${i}.050Z`,
979
+ toolName: "Read",
980
+ success: true,
981
+ durationMs: 50,
982
+ });
983
+ }
984
+ await writeStdoutLog(overstoryDir, "stdout-limit", events);
985
+
986
+ const data = await gatherInspectData(tempDir, "stdout-limit", {
987
+ noTmux: true,
988
+ limit: 3,
989
+ });
990
+
991
+ expect(data.recentToolCalls.length).toBe(3);
992
+ });
993
+
994
+ test("printInspectData shows Turn Progress section when headlessTurnInfo is set", () => {
995
+ const data = {
996
+ session: {
997
+ id: "sess-tp",
998
+ agentName: "headless-turn-progress",
999
+ capability: "builder",
1000
+ worktreePath: "/tmp/wt",
1001
+ branchName: "overstory/headless/tp",
1002
+ taskId: "overstory-tp",
1003
+ tmuxSession: "",
1004
+ state: "working" as const,
1005
+ pid: 12345,
1006
+ parentAgent: null,
1007
+ depth: 0,
1008
+ runId: null,
1009
+ startedAt: new Date().toISOString(),
1010
+ lastActivity: new Date().toISOString(),
1011
+ escalationLevel: 0,
1012
+ stalledSince: null,
1013
+ transcriptPath: null,
1014
+ },
1015
+ timeSinceLastActivity: 1000,
1016
+ recentToolCalls: [],
1017
+ currentFile: null,
1018
+ toolStats: [],
1019
+ tokenUsage: null,
1020
+ tmuxOutput: null,
1021
+ headlessTurnInfo: {
1022
+ currentTurn: 5,
1023
+ contextUtilization: 0.625,
1024
+ isMidTool: false,
1025
+ },
1026
+ };
1027
+
1028
+ printInspectData(data);
1029
+
1030
+ const out = output();
1031
+ expect(out).toContain("Turn Progress");
1032
+ expect(out).toContain("5");
1033
+ expect(out).toContain("62.5%");
1034
+ expect(out).toContain("between turns");
1035
+ });
1036
+
1037
+ test("printInspectData shows executing tool status when isMidTool is true", () => {
1038
+ const data = {
1039
+ session: {
1040
+ id: "sess-mid",
1041
+ agentName: "headless-mid-tool",
1042
+ capability: "builder",
1043
+ worktreePath: "/tmp/wt",
1044
+ branchName: "overstory/headless/mid",
1045
+ taskId: "overstory-mid",
1046
+ tmuxSession: "",
1047
+ state: "working" as const,
1048
+ pid: 12345,
1049
+ parentAgent: null,
1050
+ depth: 0,
1051
+ runId: null,
1052
+ startedAt: new Date().toISOString(),
1053
+ lastActivity: new Date().toISOString(),
1054
+ escalationLevel: 0,
1055
+ stalledSince: null,
1056
+ transcriptPath: null,
1057
+ },
1058
+ timeSinceLastActivity: 500,
1059
+ recentToolCalls: [],
1060
+ currentFile: null,
1061
+ toolStats: [],
1062
+ tokenUsage: null,
1063
+ tmuxOutput: null,
1064
+ headlessTurnInfo: {
1065
+ currentTurn: 2,
1066
+ contextUtilization: null,
1067
+ isMidTool: true,
1068
+ },
1069
+ };
1070
+
1071
+ printInspectData(data);
1072
+
1073
+ const out = output();
1074
+ expect(out).toContain("Turn Progress");
1075
+ expect(out).toContain("executing tool");
1076
+ });
1077
+
1078
+ test("uses latest log dir when multiple exist", async () => {
1079
+ const overstoryDir = await setupHeadlessSession("stdout-multi-dir");
1080
+ const agentLogsDir = join(overstoryDir, "logs", "stdout-multi-dir");
1081
+
1082
+ // Create two log dirs — the later one has the important data
1083
+ const oldDir = join(agentLogsDir, "2026-03-05T10-00-00-000Z");
1084
+ const newDir = join(agentLogsDir, "2026-03-05T14-30-00-000Z");
1085
+ await mkdir(oldDir, { recursive: true });
1086
+ await mkdir(newDir, { recursive: true });
1087
+
1088
+ // Old dir: no useful data
1089
+ await Bun.write(join(oldDir, "stdout.log"), "");
1090
+
1091
+ // New dir: has turn data
1092
+ const events = [
1093
+ {
1094
+ type: "turn_start",
1095
+ timestamp: "2026-03-05T14:30:00.000Z",
1096
+ turn: 7,
1097
+ },
1098
+ {
1099
+ type: "turn_end",
1100
+ timestamp: "2026-03-05T14:30:05.000Z",
1101
+ inputTokens: 2000,
1102
+ outputTokens: 700,
1103
+ cacheReadTokens: 300,
1104
+ model: "claude-sonnet-4-6",
1105
+ contextUtilization: 0.55,
1106
+ },
1107
+ ];
1108
+ await Bun.write(
1109
+ join(newDir, "stdout.log"),
1110
+ `${events.map((e) => JSON.stringify(e)).join("\n")}\n`,
1111
+ );
1112
+
1113
+ const data = await gatherInspectData(tempDir, "stdout-multi-dir", { noTmux: true });
1114
+
1115
+ expect(data.headlessTurnInfo?.currentTurn).toBe(7);
1116
+ expect(data.tokenUsage?.inputTokens).toBe(2000);
1117
+ });
1118
+ });
1119
+
723
1120
  // === Human-readable output ===
724
1121
 
725
1122
  describe("human-readable output", () => {