diff --git a/.changeset/verbose-error-causes.md b/.changeset/verbose-error-causes.md
new file mode 100644
index 00000000..9d038ff1
--- /dev/null
+++ b/.changeset/verbose-error-causes.md
@@ -0,0 +1,5 @@
+---
+"@gh-symphony/cli": patch
+---
+
+Fix issue #396 so `-v` / `--verbose` surfaces stack traces and error cause chains for top-level CLI and orchestrator failures, including daemon startup diagnostics.
diff --git a/packages/cli/src/commands/__snapshots__/help.test.ts.snap b/packages/cli/src/commands/__snapshots__/help.test.ts.snap
index 96e49426..4855fbec 100644
--- a/packages/cli/src/commands/__snapshots__/help.test.ts.snap
+++ b/packages/cli/src/commands/__snapshots__/help.test.ts.snap
@@ -34,7 +34,7 @@ exports[`help output > renders the colored grouped help snapshot 1`] = `
[33m[1mGlobal Options:[0m[0m
[36m--config
[0m Config directory override (advanced; default uses initialized
cwd runtime, then ~/.gh-symphony)
- [36m--verbose, -v[0m Verbose output
+ [36m--verbose, -v[0m Verbose output, including top-level error stack traces
[36m--json[0m JSON output
[36m--no-color[0m Disable color output
[36m--help, -h[0m Show help
@@ -76,7 +76,7 @@ Maintenance:
Global Options:
--config Config directory override (advanced; default uses initialized
cwd runtime, then ~/.gh-symphony)
- --verbose, -v Verbose output
+ --verbose, -v Verbose output, including top-level error stack traces
--json JSON output
--no-color Disable color output
--help, -h Show help
diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts
index 528b7417..0ceb28e1 100644
--- a/packages/cli/src/commands/help.ts
+++ b/packages/cli/src/commands/help.ts
@@ -131,7 +131,7 @@ export const HELP_SECTIONS: HelpSection[] = [
},
{
name: "--verbose, -v",
- description: "Verbose output",
+ description: "Verbose output, including top-level error stack traces",
},
{
name: "--json",
diff --git a/packages/cli/src/commands/start.test.ts b/packages/cli/src/commands/start.test.ts
index 85ff8f77..a0fc6bad 100644
--- a/packages/cli/src/commands/start.test.ts
+++ b/packages/cli/src/commands/start.test.ts
@@ -23,6 +23,16 @@ const ghAuthMocks = vi.hoisted(() => ({
const promptMocks = vi.hoisted(() => ({
confirm: vi.fn(),
}));
+const childProcessMocks = vi.hoisted(() => ({
+ spawn: vi.fn(() => ({
+ pid: 2468,
+ unref: vi.fn(),
+ })),
+}));
+
+vi.mock("node:child_process", () => ({
+ spawn: childProcessMocks.spawn,
+}));
vi.mock("@gh-symphony/orchestrator", () => ({
acquireProjectLock,
@@ -114,6 +124,11 @@ beforeEach(() => {
ghAuthMocks.runGhAuthRefresh.mockReset();
promptMocks.confirm.mockReset();
promptMocks.confirm.mockResolvedValue(true);
+ childProcessMocks.spawn.mockClear();
+ childProcessMocks.spawn.mockReturnValue({
+ pid: 2468,
+ unref: vi.fn(),
+ });
process.env.GITHUB_GRAPHQL_TOKEN = originalGithubToken;
process.env.LINEAR_API_KEY = originalLinearApiKey;
serviceDependencies.length = 0;
@@ -608,6 +623,47 @@ describe("start command foreground locking", () => {
expect(exitSpy).toHaveBeenCalledWith(0);
});
+ it("maps the global verbose option to orchestrator verbose logs", async () => {
+ const configDir = await createConfigFixture({
+ activeProject: "tenant-a",
+ projects: [createProject("tenant-a", "acme", "platform")],
+ });
+ run.mockResolvedValue(undefined);
+
+ await startModule.default([], {
+ ...baseOptions(configDir),
+ verbose: true,
+ });
+
+ expect(serviceDependencies.at(-1)).toMatchObject({
+ logLevel: "verbose",
+ });
+ });
+
+ it("passes global verbose through to daemon child diagnostics", async () => {
+ const configDir = await createConfigFixture({
+ activeProject: "tenant-a",
+ projects: [createProject("tenant-a", "acme", "platform")],
+ });
+
+ await startModule.default(["--daemon"], {
+ ...baseOptions(configDir),
+ verbose: true,
+ });
+
+ expect(childProcessMocks.spawn).toHaveBeenCalledTimes(1);
+ const childArgs = childProcessMocks.spawn.mock.calls[0]?.[1];
+ expect(childArgs).toEqual(
+ expect.arrayContaining([
+ "repo",
+ "start",
+ "--verbose",
+ "--log-level",
+ "verbose",
+ ])
+ );
+ });
+
it("tails completed worker logs from the flat runtime run path", async () => {
const configDir = await createConfigFixture({
activeProject: "tenant-a",
diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
index 21114cb8..de2c21bb 100644
--- a/packages/cli/src/commands/start.ts
+++ b/packages/cli/src/commands/start.ts
@@ -686,10 +686,11 @@ const handler = async (
const runtimeRoot = resolveRuntimeRoot(options.configDir);
const projectId = projectConfig.projectId;
let logLevel: OrchestratorLogLevel;
+ const requestedLogLevel =
+ parsed.logLevel ??
+ (options.verbose ? "verbose" : process.env.SYMPHONY_LOG_LEVEL);
try {
- logLevel = resolveOrchestratorLogLevel(
- parsed.logLevel ?? process.env.SYMPHONY_LOG_LEVEL
- );
+ logLevel = resolveOrchestratorLogLevel(requestedLogLevel);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : "Unsupported log level"}\n`
@@ -709,7 +710,7 @@ const handler = async (
await startDaemon(
options,
projectId,
- parsed.logLevel,
+ parsed.logLevel ?? (options.verbose ? "verbose" : undefined),
parsed.httpPort,
parsed.webPort,
parsed.assignedOnly === true
@@ -1080,6 +1081,7 @@ async function startDaemon(
process.argv[1]!,
"repo",
"start",
+ ...(options.verbose ? ["--verbose"] : []),
...(assignedOnly ? ["--assigned-only"] : []),
...(httpPort !== undefined ? ["--http", String(httpPort)] : []),
...(webPort !== undefined ? ["--web", String(webPort)] : []),
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 66adc808..1a09754a 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -1,5 +1,6 @@
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
+import { formatErrorForTerminal, hasVerboseFlag } from "@gh-symphony/core";
import {
Command,
CommanderError,
@@ -89,7 +90,7 @@ function addGlobalOptions(command: Command): Command {
return command
.option("--config ", "Config directory")
.addOption(new Option("--config-dir ").hideHelp())
- .option("-v, --verbose", "Enable verbose output")
+ .option("-v, --verbose", "Enable verbose output with stack traces")
.option("--json", "Output in JSON format")
.option("--no-color", "Disable color output");
}
@@ -763,7 +764,9 @@ if (
) {
main().catch((error: unknown) => {
process.stderr.write(
- `${error instanceof Error ? error.message : "Unknown error"}\n`
+ formatErrorForTerminal(error, {
+ verbose: hasVerboseFlag(process.argv.slice(2)),
+ })
);
process.exitCode = 1;
});
diff --git a/packages/core/src/observability/error-format.test.ts b/packages/core/src/observability/error-format.test.ts
new file mode 100644
index 00000000..8b71c2a8
--- /dev/null
+++ b/packages/core/src/observability/error-format.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { formatErrorForTerminal, hasVerboseFlag } from "./error-format.js";
+
+describe("terminal error formatting", () => {
+ it("keeps non-verbose errors to a single message line", () => {
+ const error = new Error("top-level failure", {
+ cause: new Error("root cause"),
+ });
+
+ expect(formatErrorForTerminal(error)).toBe("top-level failure\n");
+ });
+
+ it("prints stacks and walks causes when verbose", () => {
+ const cause = new Error("root cause");
+ cause.stack = "Error: root cause\n at root";
+ const error = new Error("top-level failure", { cause });
+ error.stack = "Error: top-level failure\n at top";
+
+ expect(formatErrorForTerminal(error, { verbose: true })).toBe(
+ [
+ "Error: top-level failure",
+ " at top",
+ "Caused by: Error: root cause",
+ " at root",
+ "",
+ ].join("\n")
+ );
+ });
+
+ it("detects circular cause chains before repeating the top-level error", () => {
+ const cause = new Error("root cause");
+ cause.stack = "Error: root cause\n at root";
+ const error = new Error("top-level failure", { cause });
+ error.stack = "Error: top-level failure\n at top";
+ cause.cause = error;
+
+ expect(formatErrorForTerminal(error, { verbose: true })).toBe(
+ [
+ "Error: top-level failure",
+ " at top",
+ "Caused by: Error: root cause",
+ " at root",
+ "Caused by: [Circular cause]",
+ "",
+ ].join("\n")
+ );
+ });
+
+ it("detects both verbose flags", () => {
+ expect(hasVerboseFlag(["repo", "start", "--verbose"])).toBe(true);
+ expect(hasVerboseFlag(["-v", "repo", "start"])).toBe(true);
+ expect(hasVerboseFlag(["repo", "start"])).toBe(false);
+ });
+});
diff --git a/packages/core/src/observability/error-format.ts b/packages/core/src/observability/error-format.ts
new file mode 100644
index 00000000..1d820057
--- /dev/null
+++ b/packages/core/src/observability/error-format.ts
@@ -0,0 +1,50 @@
+export type TerminalErrorFormatOptions = {
+ verbose?: boolean;
+};
+
+export function hasVerboseFlag(argv: readonly string[]): boolean {
+ return argv.some((arg) => arg === "--verbose" || arg === "-v");
+}
+
+export function formatErrorForTerminal(
+ error: unknown,
+ options: TerminalErrorFormatOptions = {}
+): string {
+ if (!options.verbose) {
+ return `${error instanceof Error ? error.message : "Unknown error"}\n`;
+ }
+
+ const lines = [formatSingleError(error)];
+ const seenCauses = new Set