@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.
- package/README.md +4 -2
- package/agents/coordinator.md +52 -4
- package/package.json +1 -1
- package/src/agents/manifest.test.ts +33 -8
- package/src/agents/manifest.ts +4 -3
- package/src/commands/clean.test.ts +136 -0
- package/src/commands/clean.ts +198 -4
- package/src/commands/coordinator.test.ts +420 -1
- package/src/commands/coordinator.ts +173 -1
- package/src/commands/init.test.ts +137 -0
- package/src/commands/init.ts +57 -1
- package/src/commands/inspect.test.ts +398 -1
- package/src/commands/inspect.ts +234 -0
- package/src/commands/log.test.ts +10 -11
- package/src/commands/log.ts +31 -32
- package/src/commands/prime.ts +30 -5
- package/src/commands/sling.ts +312 -322
- package/src/commands/spec.ts +8 -2
- package/src/commands/stop.test.ts +127 -6
- package/src/commands/stop.ts +95 -43
- package/src/commands/watch.ts +29 -9
- package/src/config.test.ts +72 -0
- package/src/config.ts +26 -1
- package/src/events/tailer.test.ts +461 -0
- package/src/events/tailer.ts +235 -0
- package/src/index.ts +4 -1
- package/src/merge/resolver.test.ts +243 -19
- package/src/merge/resolver.ts +235 -95
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +185 -0
- package/src/runtimes/pi.test.ts +119 -2
- package/src/runtimes/pi.ts +61 -12
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.test.ts +30 -0
- package/src/runtimes/sapling.ts +27 -24
- package/src/runtimes/types.ts +2 -2
- package/src/types.ts +19 -0
- package/src/watchdog/daemon.test.ts +257 -0
- package/src/watchdog/daemon.ts +123 -23
- package/src/worktree/manager.test.ts +65 -1
- 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;
|
package/src/commands/init.ts
CHANGED
|
@@ -815,13 +815,69 @@ export async function initCommand(opts: InitOptions): Promise<void> {
|
|
|
815
815
|
}
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
-
// 12.
|
|
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", () => {
|