@os-eco/overstory-cli 0.9.2 → 0.9.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/README.md +3 -0
- package/agents/coordinator.md +4 -4
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/beads/client.ts +31 -3
- package/src/commands/clean.ts +2 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.ts +4 -2
- package/src/commands/dashboard.ts +4 -1
- package/src/commands/doctor.ts +91 -76
- package/src/commands/init.ts +42 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/monitor.ts +10 -3
- package/src/commands/sling.test.ts +12 -0
- package/src/commands/sling.ts +10 -1
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +6 -4
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +20 -1
- package/src/doctor/consistency.ts +2 -2
- package/src/index.ts +1 -1
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/pi.test.ts +24 -0
- package/src/runtimes/pi.ts +4 -3
- package/src/runtimes/types.ts +7 -0
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/worktree/tmux.test.ts +202 -49
- package/src/worktree/tmux.ts +49 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ Overstory turns a single coding session into a multi-agent team by spawning work
|
|
|
10
10
|
|
|
11
11
|
> **Warning: Agent swarms are not a universal solution.** Do not deploy Overstory without understanding the risks of multi-agent orchestration — compounding error rates, cost amplification, debugging complexity, and merge conflicts are the normal case, not edge cases. Read [STEELMAN.md](STEELMAN.md) for a full risk analysis and the [Agentic Engineering Book](https://github.com/jayminwest/agentic-engineering-book) ([web version](https://jayminwest.com/agentic-engineering-book)) before using this tool in production.
|
|
12
12
|
|
|
13
|
+
> **Maintenance status.** Overstory is maintained part-time. PRs are reviewed in roughly 2-week batches; PRs inactive for 30+ days are closed (reopen anytime). For features larger than ~200 lines, open an issue or discussion first. See [CONTRIBUTING.md](CONTRIBUTING.md#review-cadence).
|
|
14
|
+
|
|
13
15
|
## Install
|
|
14
16
|
|
|
15
17
|
Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agent runtime must be installed:
|
|
@@ -295,6 +297,7 @@ overstory/
|
|
|
295
297
|
checkpoint.ts Session checkpoint save/restore
|
|
296
298
|
lifecycle.ts Handoff orchestration
|
|
297
299
|
hooks-deployer.ts Deploy hooks + tool enforcement
|
|
300
|
+
copilot-hooks-deployer.ts Deploy hooks config to Copilot worktrees
|
|
298
301
|
guard-rules.ts Shared guard constants (tool lists, bash patterns)
|
|
299
302
|
worktree/ Git worktree + tmux management
|
|
300
303
|
mail/ SQLite mail system (typed protocol, broadcast)
|
package/agents/coordinator.md
CHANGED
|
@@ -319,16 +319,16 @@ When a batch is complete (task group auto-closed, all issues resolved):
|
|
|
319
319
|
4. **Only then** close the issue: `{{TRACKER_CLI}} close <id> --reason "Merged branch <branch-name>"`.
|
|
320
320
|
|
|
321
321
|
1. Verify all issues are closed: run `{{TRACKER_CLI}} show <id>` for each issue in the group.
|
|
322
|
-
2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal.
|
|
323
|
-
3.
|
|
324
|
-
4.
|
|
325
|
-
5. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
|
|
322
|
+
2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal. **Note:** merged branches carry each worker's committed `.mulch/` changes into the canonical branch — this is how discovery scout findings reach the main repo.
|
|
323
|
+
3. Record orchestration insights: `ml record <domain> --type <type> --classification <foundational|tactical|observational> --description "<insight>"`.
|
|
324
|
+
4. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
|
|
326
325
|
```bash
|
|
327
326
|
{{TRACKER_CLI}} sync
|
|
328
327
|
git add .overstory/ .mulch/
|
|
329
328
|
git diff --cached --quiet || git commit -m "chore: sync runtime state"
|
|
330
329
|
git push
|
|
331
330
|
```
|
|
331
|
+
5. Clean up worktrees: `ov worktree clean --completed`. **Only run this after branches are merged and .mulch/ state is committed** — cleaning worktrees before merging destroys any uncommitted scout findings.
|
|
332
332
|
6. Report to the human operator: summarize what was accomplished, what was merged, any issues encountered.
|
|
333
333
|
7. Check for follow-up work: `{{TRACKER_CLI}} ready` to see if new issues surfaced during the batch.
|
|
334
334
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-eco/overstory-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
|
|
5
5
|
"author": "Jaymin West",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
6
|
+
import { deployCopilotHooks } from "./copilot-hooks-deployer.ts";
|
|
7
|
+
import { PATH_PREFIX } from "./hooks-deployer.ts";
|
|
8
|
+
|
|
9
|
+
describe("deployCopilotHooks", () => {
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-copilot-hooks-test-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await cleanupTempDir(tempDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("writes hooks.json to .github/hooks/ directory", async () => {
|
|
21
|
+
const worktreePath = join(tempDir, "worktree");
|
|
22
|
+
await deployCopilotHooks(worktreePath, "my-builder");
|
|
23
|
+
|
|
24
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
25
|
+
const exists = await Bun.file(hooksPath).exists();
|
|
26
|
+
expect(exists).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("creates .github/hooks/ directory if it does not exist", async () => {
|
|
30
|
+
const worktreePath = join(tempDir, "new-worktree");
|
|
31
|
+
// Directory does not exist before the call
|
|
32
|
+
await deployCopilotHooks(worktreePath, "builder-1");
|
|
33
|
+
|
|
34
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
35
|
+
expect(await Bun.file(hooksPath).exists()).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("output file is valid JSON", async () => {
|
|
39
|
+
const worktreePath = join(tempDir, "worktree");
|
|
40
|
+
await deployCopilotHooks(worktreePath, "test-agent");
|
|
41
|
+
|
|
42
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
43
|
+
const raw = await Bun.file(hooksPath).text();
|
|
44
|
+
expect(() => JSON.parse(raw)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("output has Copilot schema structure (top-level hooks with onSessionStart)", async () => {
|
|
48
|
+
const worktreePath = join(tempDir, "worktree");
|
|
49
|
+
await deployCopilotHooks(worktreePath, "test-agent");
|
|
50
|
+
|
|
51
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
52
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
expect(config).toHaveProperty("hooks");
|
|
55
|
+
const hooks = config.hooks as Record<string, unknown>;
|
|
56
|
+
expect(hooks).toHaveProperty("onSessionStart");
|
|
57
|
+
expect(Array.isArray(hooks.onSessionStart)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("replaces {{AGENT_NAME}} with agentName in all commands", async () => {
|
|
61
|
+
const worktreePath = join(tempDir, "worktree");
|
|
62
|
+
await deployCopilotHooks(worktreePath, "scout-agent-42");
|
|
63
|
+
|
|
64
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
65
|
+
const raw = await Bun.file(hooksPath).text();
|
|
66
|
+
|
|
67
|
+
expect(raw).toContain("scout-agent-42");
|
|
68
|
+
expect(raw).not.toContain("{{AGENT_NAME}}");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("prepends PATH_PREFIX to all hook commands", async () => {
|
|
72
|
+
const worktreePath = join(tempDir, "worktree");
|
|
73
|
+
await deployCopilotHooks(worktreePath, "builder-1");
|
|
74
|
+
|
|
75
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
76
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as {
|
|
77
|
+
hooks: Record<string, Array<{ command: string }>>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const allCommands = Object.values(config.hooks)
|
|
81
|
+
.flat()
|
|
82
|
+
.map((e) => e.command);
|
|
83
|
+
expect(allCommands.length).toBeGreaterThan(0);
|
|
84
|
+
for (const cmd of allCommands) {
|
|
85
|
+
expect(cmd).toStartWith(PATH_PREFIX);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("onSessionStart entries are objects with command field only (no matcher, no type)", async () => {
|
|
90
|
+
const worktreePath = join(tempDir, "worktree");
|
|
91
|
+
await deployCopilotHooks(worktreePath, "builder-1");
|
|
92
|
+
|
|
93
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
94
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as {
|
|
95
|
+
hooks: { onSessionStart: Array<Record<string, unknown>> };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const entry of config.hooks.onSessionStart) {
|
|
99
|
+
expect(typeof entry.command).toBe("string");
|
|
100
|
+
// Copilot schema has no matcher or type fields
|
|
101
|
+
expect(entry).not.toHaveProperty("matcher");
|
|
102
|
+
expect(entry).not.toHaveProperty("type");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("onSessionStart includes ov prime command", async () => {
|
|
107
|
+
const worktreePath = join(tempDir, "worktree");
|
|
108
|
+
await deployCopilotHooks(worktreePath, "prime-test-agent");
|
|
109
|
+
|
|
110
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
111
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as {
|
|
112
|
+
hooks: { onSessionStart: Array<{ command: string }> };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const commands = config.hooks.onSessionStart.map((e) => e.command);
|
|
116
|
+
expect(commands.some((c) => c.includes("ov prime") && c.includes("prime-test-agent"))).toBe(
|
|
117
|
+
true,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("onSessionStart includes ov mail check --inject command", async () => {
|
|
122
|
+
const worktreePath = join(tempDir, "worktree");
|
|
123
|
+
await deployCopilotHooks(worktreePath, "mail-test-agent");
|
|
124
|
+
|
|
125
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
126
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as {
|
|
127
|
+
hooks: { onSessionStart: Array<{ command: string }> };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const commands = config.hooks.onSessionStart.map((e) => e.command);
|
|
131
|
+
expect(
|
|
132
|
+
commands.some((c) => c.includes("ov mail check --inject") && c.includes("mail-test-agent")),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("all hook commands include ENV_GUARD pattern", async () => {
|
|
137
|
+
const worktreePath = join(tempDir, "worktree");
|
|
138
|
+
await deployCopilotHooks(worktreePath, "guard-test-agent");
|
|
139
|
+
|
|
140
|
+
const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
|
|
141
|
+
const config = JSON.parse(await Bun.file(hooksPath).text()) as {
|
|
142
|
+
hooks: Record<string, Array<{ command: string }>>;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const allCommands = Object.values(config.hooks)
|
|
146
|
+
.flat()
|
|
147
|
+
.map((e) => e.command);
|
|
148
|
+
for (const cmd of allCommands) {
|
|
149
|
+
expect(cmd).toContain("OVERSTORY_AGENT_NAME");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("template file exists and is valid JSON after substitution", async () => {
|
|
154
|
+
// Verify template file is present and parseable (basic template health check).
|
|
155
|
+
const templatePath = join(import.meta.dir, "..", "..", "templates", "copilot-hooks.json.tmpl");
|
|
156
|
+
const exists = await Bun.file(templatePath).exists();
|
|
157
|
+
expect(exists).toBe(true);
|
|
158
|
+
|
|
159
|
+
const raw = (await Bun.file(templatePath).text()).replace(/\{\{AGENT_NAME\}\}/g, "test");
|
|
160
|
+
expect(() => JSON.parse(raw)).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { AgentError } from "../errors.ts";
|
|
4
|
+
import { PATH_PREFIX } from "./hooks-deployer.ts";
|
|
5
|
+
|
|
6
|
+
/** Copilot hook entry shape — simpler than Claude Code (no matcher, no type field). */
|
|
7
|
+
interface CopilotHookEntry {
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the path to the Copilot hooks template file.
|
|
13
|
+
* The template lives at `templates/copilot-hooks.json.tmpl` relative to the repo root.
|
|
14
|
+
*/
|
|
15
|
+
function getTemplatePath(): string {
|
|
16
|
+
// src/agents/copilot-hooks-deployer.ts -> repo root is ../../
|
|
17
|
+
return join(dirname(import.meta.dir), "..", "templates", "copilot-hooks.json.tmpl");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Deploy Copilot lifecycle hooks to an agent's worktree.
|
|
22
|
+
*
|
|
23
|
+
* Reads `templates/copilot-hooks.json.tmpl`, replaces all `{{AGENT_NAME}}` tokens,
|
|
24
|
+
* prepends PATH_PREFIX to every hook command so CLIs (ov, ml, sd) resolve correctly
|
|
25
|
+
* under Copilot's minimal PATH, then writes the result to
|
|
26
|
+
* `<worktreePath>/.github/hooks/hooks.json`.
|
|
27
|
+
*
|
|
28
|
+
* Phase 1: lifecycle hooks only (onSessionStart). No security guards.
|
|
29
|
+
*
|
|
30
|
+
* @param worktreePath - Absolute path to the agent's git worktree
|
|
31
|
+
* @param agentName - The unique name of the agent (replaces {{AGENT_NAME}} in template)
|
|
32
|
+
* @throws {AgentError} If the template is missing or the write fails
|
|
33
|
+
*/
|
|
34
|
+
export async function deployCopilotHooks(worktreePath: string, agentName: string): Promise<void> {
|
|
35
|
+
const templatePath = getTemplatePath();
|
|
36
|
+
const file = Bun.file(templatePath);
|
|
37
|
+
const exists = await file.exists();
|
|
38
|
+
|
|
39
|
+
if (!exists) {
|
|
40
|
+
throw new AgentError(`Copilot hooks template not found: ${templatePath}`, {
|
|
41
|
+
agentName,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let template: string;
|
|
46
|
+
try {
|
|
47
|
+
template = await file.text();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new AgentError(`Failed to read Copilot hooks template: ${templatePath}`, {
|
|
50
|
+
agentName,
|
|
51
|
+
cause: err instanceof Error ? err : undefined,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Replace all occurrences of {{AGENT_NAME}}
|
|
56
|
+
let content = template;
|
|
57
|
+
while (content.includes("{{AGENT_NAME}}")) {
|
|
58
|
+
content = content.replace("{{AGENT_NAME}}", agentName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse the base config from the template
|
|
62
|
+
const config = JSON.parse(content) as { hooks: Record<string, CopilotHookEntry[]> };
|
|
63
|
+
|
|
64
|
+
// Extend PATH in all hook commands.
|
|
65
|
+
// Copilot CLI executes hooks with a minimal PATH — ~/.bun/bin (where ov, ml, sd live)
|
|
66
|
+
// is not included. Prepend PATH_PREFIX so CLIs resolve correctly.
|
|
67
|
+
for (const entries of Object.values(config.hooks)) {
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
entry.command = `${PATH_PREFIX} ${entry.command}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hooksDir = join(worktreePath, ".github", "hooks");
|
|
74
|
+
const outputPath = join(hooksDir, "hooks.json");
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await mkdir(hooksDir, { recursive: true });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new AgentError(`Failed to create .github/hooks/ directory at: ${hooksDir}`, {
|
|
80
|
+
agentName,
|
|
81
|
+
cause: err instanceof Error ? err : undefined,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await Bun.write(outputPath, `${JSON.stringify(config, null, "\t")}\n`);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new AgentError(`Failed to write Copilot hooks config to: ${outputPath}`, {
|
|
89
|
+
agentName,
|
|
90
|
+
cause: err instanceof Error ? err : undefined,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/beads/client.ts
CHANGED
|
@@ -194,9 +194,37 @@ export function createBeadsClient(cwd: string): BeadsClient {
|
|
|
194
194
|
if (options?.limit !== undefined) {
|
|
195
195
|
args.push("--limit", String(options.limit));
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
try {
|
|
198
|
+
const { stdout } = await runBd(args, "list");
|
|
199
|
+
const trimmed = stdout.trim();
|
|
200
|
+
if (trimmed === "") return [];
|
|
201
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
202
|
+
if (Array.isArray(parsed)) {
|
|
203
|
+
// Flat format: RawBeadIssue[]
|
|
204
|
+
return (parsed as RawBeadIssue[]).map(normalizeIssue);
|
|
205
|
+
}
|
|
206
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
207
|
+
// Tree format: { mol: RawBeadIssue[] } — flatten all groups
|
|
208
|
+
const tree = parsed as Record<string, unknown>;
|
|
209
|
+
const issues: BeadIssue[] = [];
|
|
210
|
+
for (const group of Object.values(tree)) {
|
|
211
|
+
if (Array.isArray(group)) {
|
|
212
|
+
for (const item of group) {
|
|
213
|
+
issues.push(normalizeIssue(item as RawBeadIssue));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (issues.length > 0) return issues;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// fall through to ready fallback
|
|
221
|
+
}
|
|
222
|
+
// Fallback: bd ready --json always returns a flat array
|
|
223
|
+
const { stdout: readyStdout } = await runBd(["ready", "--json"], "ready (list fallback)");
|
|
224
|
+
const readyTrimmed = readyStdout.trim();
|
|
225
|
+
if (readyTrimmed === "") return [];
|
|
226
|
+
const readyRaw = parseJsonOutput<RawBeadIssue[]>(readyTrimmed, "ready (list fallback)");
|
|
227
|
+
return readyRaw.map(normalizeIssue);
|
|
200
228
|
},
|
|
201
229
|
};
|
|
202
230
|
}
|
package/src/commands/clean.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
killProcessTree,
|
|
38
38
|
killSession,
|
|
39
39
|
listSessions,
|
|
40
|
+
sanitizeTmuxName,
|
|
40
41
|
} from "../worktree/tmux.ts";
|
|
41
42
|
|
|
42
43
|
export interface CleanOptions {
|
|
@@ -155,7 +156,7 @@ interface CleanResult {
|
|
|
155
156
|
*/
|
|
156
157
|
async function killAllTmuxSessions(overstoryDir: string, projectName: string): Promise<number> {
|
|
157
158
|
let killed = 0;
|
|
158
|
-
const projectPrefix = `overstory-${projectName}-`;
|
|
159
|
+
const projectPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
|
|
159
160
|
try {
|
|
160
161
|
const tmuxSessions = await listSessions();
|
|
161
162
|
const overStorySessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
|
|
@@ -12,7 +12,10 @@ import {
|
|
|
12
12
|
} from "./completions.ts";
|
|
13
13
|
|
|
14
14
|
afterEach(() => {
|
|
15
|
-
|
|
15
|
+
// Use 0 not undefined — Bun doesn't reliably clear a nonzero exitCode when
|
|
16
|
+
// reassigned to undefined (see prior fix f3fde1a). If the 1 from completion
|
|
17
|
+
// tests leaks to bun test's shutdown, the suite exits 1 with 0 test failures.
|
|
18
|
+
process.exitCode = 0;
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
describe("COMMANDS array", () => {
|
|
@@ -37,7 +37,9 @@ import {
|
|
|
37
37
|
ensureTmuxAvailable,
|
|
38
38
|
isSessionAlive,
|
|
39
39
|
killSession,
|
|
40
|
+
sanitizeTmuxName,
|
|
40
41
|
sendKeys,
|
|
42
|
+
TMUX_SOCKET,
|
|
41
43
|
waitForTuiReady,
|
|
42
44
|
} from "../worktree/tmux.ts";
|
|
43
45
|
import { nudgeAgent } from "./nudge.ts";
|
|
@@ -75,7 +77,7 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
|
|
|
75
77
|
* Includes the project name to prevent cross-project collisions (overstory-pcef).
|
|
76
78
|
*/
|
|
77
79
|
function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
|
|
78
|
-
return `overstory-${projectName}-${name}`;
|
|
80
|
+
return `overstory-${sanitizeTmuxName(projectName)}-${name}`;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** Dependency injection for testing. Uses real implementations when omitted. */
|
|
@@ -630,7 +632,7 @@ export async function startCoordinatorSession(
|
|
|
630
632
|
}
|
|
631
633
|
|
|
632
634
|
if (shouldAttach) {
|
|
633
|
-
Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
|
|
635
|
+
Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
|
|
634
636
|
stdio: ["inherit", "inherit", "inherit"],
|
|
635
637
|
});
|
|
636
638
|
}
|
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
MailMessage,
|
|
47
47
|
OverstoryConfig,
|
|
48
48
|
StoredEvent,
|
|
49
|
+
TaskTrackerBackend,
|
|
49
50
|
} from "../types.ts";
|
|
50
51
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
51
52
|
import { isProcessAlive } from "../worktree/tmux.ts";
|
|
@@ -356,6 +357,7 @@ async function loadDashboardData(
|
|
|
356
357
|
thresholds?: { staleMs: number; zombieMs: number },
|
|
357
358
|
eventBuffer?: EventBuffer,
|
|
358
359
|
runtimeConfig?: OverstoryConfig["runtime"],
|
|
360
|
+
taskTrackerBackend?: TaskTrackerBackend,
|
|
359
361
|
): Promise<DashboardData> {
|
|
360
362
|
// Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
|
|
361
363
|
let allSessions: AgentSession[];
|
|
@@ -516,7 +518,7 @@ async function loadDashboardData(
|
|
|
516
518
|
const now2 = Date.now();
|
|
517
519
|
if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
|
|
518
520
|
try {
|
|
519
|
-
const backend = await resolveBackend("auto", root);
|
|
521
|
+
const backend = await resolveBackend(taskTrackerBackend ?? "auto", root);
|
|
520
522
|
const tracker = createTrackerClient(backend, root);
|
|
521
523
|
tasks = await tracker.list({ limit: 10 });
|
|
522
524
|
trackerCache = { tasks, fetchedAt: now2 };
|
|
@@ -1104,6 +1106,7 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
1104
1106
|
thresholds,
|
|
1105
1107
|
eventBuffer,
|
|
1106
1108
|
config.runtime,
|
|
1109
|
+
config.taskTracker.backend,
|
|
1107
1110
|
);
|
|
1108
1111
|
lastGoodData = data;
|
|
1109
1112
|
// If recovering from an error, clear the stale error line at the bottom
|
package/src/commands/doctor.ts
CHANGED
|
@@ -158,10 +158,81 @@ export interface DoctorCommandOptions {
|
|
|
158
158
|
checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
interface DoctorActionOpts {
|
|
162
|
+
json?: boolean;
|
|
163
|
+
verbose?: boolean;
|
|
164
|
+
category?: string;
|
|
165
|
+
fix?: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
161
168
|
/**
|
|
162
|
-
*
|
|
169
|
+
* Run the doctor checks. Returns true if any check failed.
|
|
170
|
+
* Shared by both the Commander action and the programmatic entry point so the
|
|
171
|
+
* exit-code signal never has to travel through `process.exitCode` (which is
|
|
172
|
+
* global mutable state and races with other tests in parallel bun test runs).
|
|
163
173
|
*/
|
|
164
|
-
|
|
174
|
+
async function runDoctorChecks(
|
|
175
|
+
opts: DoctorActionOpts,
|
|
176
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const json = opts.json ?? false;
|
|
179
|
+
const verbose = opts.verbose ?? false;
|
|
180
|
+
const categoryFilter = opts.category;
|
|
181
|
+
const fix = opts.fix ?? false;
|
|
182
|
+
|
|
183
|
+
if (categoryFilter !== undefined) {
|
|
184
|
+
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
185
|
+
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
186
|
+
throw new ValidationError(
|
|
187
|
+
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
188
|
+
{
|
|
189
|
+
field: "category",
|
|
190
|
+
value: categoryFilter,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cwd = process.cwd();
|
|
197
|
+
const config = await loadConfig(cwd);
|
|
198
|
+
const overstoryDir = join(config.project.root, ".overstory");
|
|
199
|
+
|
|
200
|
+
const checksToRun = categoryFilter
|
|
201
|
+
? checkRunners.filter((c) => c.category === categoryFilter)
|
|
202
|
+
: checkRunners;
|
|
203
|
+
|
|
204
|
+
let results: DoctorCheck[] = [];
|
|
205
|
+
for (const { fn } of checksToRun) {
|
|
206
|
+
const checkResults = await fn(config, overstoryDir);
|
|
207
|
+
results.push(...checkResults);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let fixedItems: string[] | undefined;
|
|
211
|
+
if (fix) {
|
|
212
|
+
const applied = await applyFixes(results);
|
|
213
|
+
if (applied.length > 0) {
|
|
214
|
+
fixedItems = applied;
|
|
215
|
+
results = [];
|
|
216
|
+
for (const { fn } of checksToRun) {
|
|
217
|
+
const checkResults = await fn(config, overstoryDir);
|
|
218
|
+
results.push(...checkResults);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (json) {
|
|
224
|
+
printJSON(results, fixedItems);
|
|
225
|
+
} else {
|
|
226
|
+
printHumanReadable(results, verbose, checkRunners, fixedItems);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return results.some((c) => c.status === "fail");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildDoctorCommand(
|
|
233
|
+
onResult: (hasFailures: boolean) => void,
|
|
234
|
+
checkRunners: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
|
|
235
|
+
): Command {
|
|
165
236
|
return new Command("doctor")
|
|
166
237
|
.description("Run health checks on overstory setup")
|
|
167
238
|
.option("--json", "Output as JSON")
|
|
@@ -172,73 +243,20 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
|
172
243
|
"after",
|
|
173
244
|
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
|
|
174
245
|
)
|
|
175
|
-
.action(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const categoryFilter = opts.category;
|
|
180
|
-
const fix = opts.fix ?? false;
|
|
181
|
-
|
|
182
|
-
// Validate category filter if provided
|
|
183
|
-
if (categoryFilter !== undefined) {
|
|
184
|
-
const validCategories = ALL_CHECKS.map((c) => c.category);
|
|
185
|
-
if (!validCategories.includes(categoryFilter as DoctorCategory)) {
|
|
186
|
-
throw new ValidationError(
|
|
187
|
-
`Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
|
|
188
|
-
{
|
|
189
|
-
field: "category",
|
|
190
|
-
value: categoryFilter,
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const cwd = process.cwd();
|
|
197
|
-
const config = await loadConfig(cwd);
|
|
198
|
-
const overstoryDir = join(config.project.root, ".overstory");
|
|
199
|
-
|
|
200
|
-
// Filter checks by category if specified
|
|
201
|
-
const allChecks = options?.checkRunners ?? ALL_CHECKS;
|
|
202
|
-
const checksToRun = categoryFilter
|
|
203
|
-
? allChecks.filter((c) => c.category === categoryFilter)
|
|
204
|
-
: allChecks;
|
|
205
|
-
|
|
206
|
-
// Run all checks sequentially
|
|
207
|
-
let results: DoctorCheck[] = [];
|
|
208
|
-
for (const { fn } of checksToRun) {
|
|
209
|
-
const checkResults = await fn(config, overstoryDir);
|
|
210
|
-
results.push(...checkResults);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Apply fixes if requested
|
|
214
|
-
let fixedItems: string[] | undefined;
|
|
215
|
-
if (fix) {
|
|
216
|
-
const applied = await applyFixes(results);
|
|
217
|
-
if (applied.length > 0) {
|
|
218
|
-
fixedItems = applied;
|
|
219
|
-
// Re-run all checks to get fresh results after fixes
|
|
220
|
-
results = [];
|
|
221
|
-
for (const { fn } of checksToRun) {
|
|
222
|
-
const checkResults = await fn(config, overstoryDir);
|
|
223
|
-
results.push(...checkResults);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Output results
|
|
229
|
-
if (json) {
|
|
230
|
-
printJSON(results, fixedItems);
|
|
231
|
-
} else {
|
|
232
|
-
printHumanReadable(results, verbose, allChecks, fixedItems);
|
|
233
|
-
}
|
|
246
|
+
.action(async (opts: DoctorActionOpts) => {
|
|
247
|
+
onResult(await runDoctorChecks(opts, checkRunners));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Create the Commander command for `overstory doctor`.
|
|
253
|
+
*/
|
|
254
|
+
export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
255
|
+
return buildDoctorCommand((hasFailures) => {
|
|
256
|
+
if (hasFailures) {
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
}
|
|
259
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
/**
|
|
@@ -250,16 +268,15 @@ export async function doctorCommand(
|
|
|
250
268
|
args: string[],
|
|
251
269
|
options?: DoctorCommandOptions,
|
|
252
270
|
): Promise<number | undefined> {
|
|
253
|
-
|
|
271
|
+
let hasFailures = false;
|
|
272
|
+
const cmd = buildDoctorCommand((result) => {
|
|
273
|
+
hasFailures = result;
|
|
274
|
+
}, options?.checkRunners ?? ALL_CHECKS);
|
|
254
275
|
cmd.exitOverride();
|
|
255
276
|
|
|
256
|
-
const prevExitCode = process.exitCode as number | undefined;
|
|
257
|
-
process.exitCode = undefined;
|
|
258
|
-
|
|
259
277
|
try {
|
|
260
278
|
await cmd.parseAsync(args, { from: "user" });
|
|
261
279
|
} catch (err: unknown) {
|
|
262
|
-
process.exitCode = prevExitCode;
|
|
263
280
|
if (err && typeof err === "object" && "code" in err) {
|
|
264
281
|
const code = (err as { code: string }).code;
|
|
265
282
|
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
@@ -269,7 +286,5 @@ export async function doctorCommand(
|
|
|
269
286
|
throw err;
|
|
270
287
|
}
|
|
271
288
|
|
|
272
|
-
|
|
273
|
-
process.exitCode = prevExitCode;
|
|
274
|
-
return exitCode;
|
|
289
|
+
return hasFailures ? 1 : undefined;
|
|
275
290
|
}
|