@os-eco/overstory-cli 0.7.9 → 0.8.2
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 +16 -7
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: ov update [--agents] [--manifest] [--hooks] [--dry-run] [--json]
|
|
3
|
+
*
|
|
4
|
+
* Refreshes .overstory/ managed files from the installed npm package without
|
|
5
|
+
* requiring a full `ov init`. Distinct from `ov upgrade` (which updates the
|
|
6
|
+
* npm package itself).
|
|
7
|
+
*
|
|
8
|
+
* Managed files refreshed:
|
|
9
|
+
* - Agent definitions (.overstory/agent-defs/*.md)
|
|
10
|
+
* - agent-manifest.json
|
|
11
|
+
* - hooks.json
|
|
12
|
+
* - .gitignore
|
|
13
|
+
* - README.md
|
|
14
|
+
*
|
|
15
|
+
* Does NOT touch: config.yaml, config.local.yaml, SQLite databases,
|
|
16
|
+
* agents/, worktrees/, specs/, logs/, or .claude/settings.local.json.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { Command } from "commander";
|
|
22
|
+
import { ValidationError } from "../errors.ts";
|
|
23
|
+
import { jsonOutput } from "../json.ts";
|
|
24
|
+
import { printHint, printSuccess } from "../logging/color.ts";
|
|
25
|
+
import {
|
|
26
|
+
buildAgentManifest,
|
|
27
|
+
buildHooksJson,
|
|
28
|
+
OVERSTORY_GITIGNORE,
|
|
29
|
+
OVERSTORY_README,
|
|
30
|
+
writeOverstoryGitignore,
|
|
31
|
+
writeOverstoryReadme,
|
|
32
|
+
} from "./init.ts";
|
|
33
|
+
|
|
34
|
+
export interface UpdateOptions {
|
|
35
|
+
agents?: boolean;
|
|
36
|
+
manifest?: boolean;
|
|
37
|
+
hooks?: boolean;
|
|
38
|
+
dryRun?: boolean;
|
|
39
|
+
json?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Agent def files to exclude (deprecated). */
|
|
43
|
+
const EXCLUDED_AGENT_DEFS = new Set(["supervisor.md"]);
|
|
44
|
+
|
|
45
|
+
interface UpdateResult {
|
|
46
|
+
agentDefs: { updated: string[]; unchanged: string[] };
|
|
47
|
+
manifest: { updated: boolean };
|
|
48
|
+
hooks: { updated: boolean };
|
|
49
|
+
gitignore: { updated: boolean };
|
|
50
|
+
readme: { updated: boolean };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Entry point for `ov update [flags]`.
|
|
55
|
+
*/
|
|
56
|
+
export async function executeUpdate(opts: UpdateOptions): Promise<void> {
|
|
57
|
+
const json = opts.json ?? false;
|
|
58
|
+
const dryRun = opts.dryRun ?? false;
|
|
59
|
+
|
|
60
|
+
const projectRoot = process.cwd();
|
|
61
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
62
|
+
|
|
63
|
+
// Verify .overstory/config.yaml exists (already initialized)
|
|
64
|
+
const configFile = Bun.file(join(overstoryDir, "config.yaml"));
|
|
65
|
+
if (!(await configFile.exists())) {
|
|
66
|
+
throw new ValidationError("Not initialized. Run 'ov init' first to set up .overstory/.", {
|
|
67
|
+
field: "config.yaml",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Determine what to refresh. No flags = refresh all.
|
|
72
|
+
const hasGranularFlags = opts.agents || opts.manifest || opts.hooks;
|
|
73
|
+
const doAgents = hasGranularFlags ? (opts.agents ?? false) : true;
|
|
74
|
+
const doManifest = hasGranularFlags ? (opts.manifest ?? false) : true;
|
|
75
|
+
const doHooks = hasGranularFlags ? (opts.hooks ?? false) : true;
|
|
76
|
+
const doGitignore = !hasGranularFlags;
|
|
77
|
+
const doReadme = !hasGranularFlags;
|
|
78
|
+
|
|
79
|
+
const result: UpdateResult = {
|
|
80
|
+
agentDefs: { updated: [], unchanged: [] },
|
|
81
|
+
manifest: { updated: false },
|
|
82
|
+
hooks: { updated: false },
|
|
83
|
+
gitignore: { updated: false },
|
|
84
|
+
readme: { updated: false },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// 1. Refresh agent definitions
|
|
88
|
+
if (doAgents) {
|
|
89
|
+
const sourceDir = join(import.meta.dir, "..", "..", "agents");
|
|
90
|
+
const targetDir = join(overstoryDir, "agent-defs");
|
|
91
|
+
|
|
92
|
+
await mkdir(targetDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const sourceFiles = await readdir(sourceDir);
|
|
95
|
+
for (const fileName of sourceFiles) {
|
|
96
|
+
if (!fileName.endsWith(".md")) continue;
|
|
97
|
+
if (EXCLUDED_AGENT_DEFS.has(fileName)) continue;
|
|
98
|
+
|
|
99
|
+
const sourceContent = await Bun.file(join(sourceDir, fileName)).text();
|
|
100
|
+
const targetPath = join(targetDir, fileName);
|
|
101
|
+
const targetFile = Bun.file(targetPath);
|
|
102
|
+
|
|
103
|
+
let needsUpdate = true;
|
|
104
|
+
if (await targetFile.exists()) {
|
|
105
|
+
const existing = await targetFile.text();
|
|
106
|
+
if (existing === sourceContent) {
|
|
107
|
+
needsUpdate = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (needsUpdate) {
|
|
112
|
+
if (!dryRun) {
|
|
113
|
+
await Bun.write(targetPath, sourceContent);
|
|
114
|
+
}
|
|
115
|
+
result.agentDefs.updated.push(fileName);
|
|
116
|
+
} else {
|
|
117
|
+
result.agentDefs.unchanged.push(fileName);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. Refresh agent-manifest.json
|
|
123
|
+
if (doManifest) {
|
|
124
|
+
const manifestPath = join(overstoryDir, "agent-manifest.json");
|
|
125
|
+
const newContent = `${JSON.stringify(buildAgentManifest(), null, "\t")}\n`;
|
|
126
|
+
const manifestFile = Bun.file(manifestPath);
|
|
127
|
+
|
|
128
|
+
let needsUpdate = true;
|
|
129
|
+
if (await manifestFile.exists()) {
|
|
130
|
+
const existing = await manifestFile.text();
|
|
131
|
+
if (existing === newContent) {
|
|
132
|
+
needsUpdate = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (needsUpdate) {
|
|
137
|
+
if (!dryRun) {
|
|
138
|
+
await Bun.write(manifestPath, newContent);
|
|
139
|
+
}
|
|
140
|
+
result.manifest.updated = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Refresh hooks.json
|
|
145
|
+
if (doHooks) {
|
|
146
|
+
const hooksPath = join(overstoryDir, "hooks.json");
|
|
147
|
+
const newContent = buildHooksJson();
|
|
148
|
+
const hooksFile = Bun.file(hooksPath);
|
|
149
|
+
|
|
150
|
+
let needsUpdate = true;
|
|
151
|
+
if (await hooksFile.exists()) {
|
|
152
|
+
const existing = await hooksFile.text();
|
|
153
|
+
if (existing === newContent) {
|
|
154
|
+
needsUpdate = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (needsUpdate) {
|
|
159
|
+
if (!dryRun) {
|
|
160
|
+
await Bun.write(hooksPath, newContent);
|
|
161
|
+
}
|
|
162
|
+
result.hooks.updated = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Refresh .gitignore
|
|
167
|
+
if (doGitignore) {
|
|
168
|
+
const gitignorePath = join(overstoryDir, ".gitignore");
|
|
169
|
+
const gitignoreFile = Bun.file(gitignorePath);
|
|
170
|
+
|
|
171
|
+
let needsUpdate = true;
|
|
172
|
+
if (await gitignoreFile.exists()) {
|
|
173
|
+
const existing = await gitignoreFile.text();
|
|
174
|
+
if (existing === OVERSTORY_GITIGNORE) {
|
|
175
|
+
needsUpdate = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (needsUpdate) {
|
|
180
|
+
if (!dryRun) {
|
|
181
|
+
await writeOverstoryGitignore(overstoryDir);
|
|
182
|
+
}
|
|
183
|
+
result.gitignore.updated = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 5. Refresh README.md
|
|
188
|
+
if (doReadme) {
|
|
189
|
+
const readmePath = join(overstoryDir, "README.md");
|
|
190
|
+
const readmeFile = Bun.file(readmePath);
|
|
191
|
+
|
|
192
|
+
let needsUpdate = true;
|
|
193
|
+
if (await readmeFile.exists()) {
|
|
194
|
+
const existing = await readmeFile.text();
|
|
195
|
+
if (existing === OVERSTORY_README) {
|
|
196
|
+
needsUpdate = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (needsUpdate) {
|
|
201
|
+
if (!dryRun) {
|
|
202
|
+
await writeOverstoryReadme(overstoryDir);
|
|
203
|
+
}
|
|
204
|
+
result.readme.updated = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Output
|
|
209
|
+
if (json) {
|
|
210
|
+
jsonOutput("update", { dryRun, ...result });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const prefix = dryRun ? "Would update" : "Updated";
|
|
215
|
+
let anyChanged = false;
|
|
216
|
+
|
|
217
|
+
if (result.agentDefs.updated.length > 0) {
|
|
218
|
+
anyChanged = true;
|
|
219
|
+
for (const f of result.agentDefs.updated) {
|
|
220
|
+
printSuccess(prefix, `agent-defs/${f}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (result.manifest.updated) {
|
|
225
|
+
anyChanged = true;
|
|
226
|
+
printSuccess(prefix, "agent-manifest.json");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (result.hooks.updated) {
|
|
230
|
+
anyChanged = true;
|
|
231
|
+
printSuccess(prefix, "hooks.json");
|
|
232
|
+
if (!dryRun) {
|
|
233
|
+
printHint("If hooks are deployed, run 'ov hooks install --force' to redeploy");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (result.gitignore.updated) {
|
|
238
|
+
anyChanged = true;
|
|
239
|
+
printSuccess(prefix, ".gitignore");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (result.readme.updated) {
|
|
243
|
+
anyChanged = true;
|
|
244
|
+
printSuccess(prefix, "README.md");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!anyChanged) {
|
|
248
|
+
printSuccess("Already up to date");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function createUpdateCommand(): Command {
|
|
253
|
+
return new Command("update")
|
|
254
|
+
.description("Refresh .overstory/ managed files from the installed package")
|
|
255
|
+
.option("--agents", "Refresh agent definition files only")
|
|
256
|
+
.option("--manifest", "Refresh agent-manifest.json only")
|
|
257
|
+
.option("--hooks", "Refresh hooks.json only")
|
|
258
|
+
.option("--dry-run", "Show what would change without writing")
|
|
259
|
+
.option("--json", "Output as JSON")
|
|
260
|
+
.action(async (opts: UpdateOptions) => {
|
|
261
|
+
await executeUpdate(opts);
|
|
262
|
+
});
|
|
263
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, mkdtemp, realpath } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
clearProjectRootOverride,
|
|
7
|
+
DEFAULT_CONFIG,
|
|
8
|
+
DEFAULT_QUALITY_GATES,
|
|
9
|
+
loadConfig,
|
|
10
|
+
resolveProjectRoot,
|
|
11
|
+
setProjectRootOverride,
|
|
12
|
+
} from "./config.ts";
|
|
6
13
|
import { ValidationError } from "./errors.ts";
|
|
7
14
|
import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
|
|
8
15
|
|
|
@@ -961,6 +968,63 @@ describe("resolveProjectRoot", () => {
|
|
|
961
968
|
});
|
|
962
969
|
});
|
|
963
970
|
|
|
971
|
+
describe("projectRootOverride", () => {
|
|
972
|
+
let tempDir: string;
|
|
973
|
+
|
|
974
|
+
beforeEach(async () => {
|
|
975
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
|
|
976
|
+
clearProjectRootOverride();
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
afterEach(async () => {
|
|
980
|
+
clearProjectRootOverride();
|
|
981
|
+
await cleanupTempDir(tempDir);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("setProjectRootOverride makes resolveProjectRoot return the override", async () => {
|
|
985
|
+
setProjectRootOverride(tempDir);
|
|
986
|
+
const result = await resolveProjectRoot("/some/other/dir");
|
|
987
|
+
expect(result).toBe(tempDir);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("clearProjectRootOverride restores normal resolution", async () => {
|
|
991
|
+
setProjectRootOverride("/completely/fake/path");
|
|
992
|
+
clearProjectRootOverride();
|
|
993
|
+
// After clearing, normal resolution returns startDir when no .overstory present
|
|
994
|
+
const result = await resolveProjectRoot(tempDir);
|
|
995
|
+
expect(result).toBe(tempDir);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("loadConfig respects project root override", async () => {
|
|
999
|
+
await mkdir(join(tempDir, ".overstory"), { recursive: true });
|
|
1000
|
+
await Bun.write(
|
|
1001
|
+
join(tempDir, ".overstory", "config.yaml"),
|
|
1002
|
+
"project:\n canonicalBranch: override-branch\n",
|
|
1003
|
+
);
|
|
1004
|
+
setProjectRootOverride(tempDir);
|
|
1005
|
+
const config = await loadConfig("/completely/different/path");
|
|
1006
|
+
expect(config.project.root).toBe(tempDir);
|
|
1007
|
+
expect(config.project.canonicalBranch).toBe("override-branch");
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("override takes precedence over worktree resolution", async () => {
|
|
1011
|
+
// Even if we're in a worktree, the override wins
|
|
1012
|
+
const otherDir = await mkdtemp(join(tmpdir(), "overstory-other-"));
|
|
1013
|
+
try {
|
|
1014
|
+
await mkdir(join(otherDir, ".overstory"), { recursive: true });
|
|
1015
|
+
await Bun.write(
|
|
1016
|
+
join(otherDir, ".overstory", "config.yaml"),
|
|
1017
|
+
"project:\n canonicalBranch: other-branch\n",
|
|
1018
|
+
);
|
|
1019
|
+
setProjectRootOverride(otherDir);
|
|
1020
|
+
const result = await resolveProjectRoot(tempDir);
|
|
1021
|
+
expect(result).toBe(otherDir);
|
|
1022
|
+
} finally {
|
|
1023
|
+
await cleanupTempDir(otherDir);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
964
1028
|
describe("DEFAULT_CONFIG", () => {
|
|
965
1029
|
test("has all required top-level keys", () => {
|
|
966
1030
|
expect(DEFAULT_CONFIG.project).toBeDefined();
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,24 @@ import { dirname, join, resolve } from "node:path";
|
|
|
2
2
|
import { ConfigError, ValidationError } from "./errors.ts";
|
|
3
3
|
import type { OverstoryConfig, QualityGate, TaskTrackerBackend } from "./types.ts";
|
|
4
4
|
|
|
5
|
+
// Module-level project root override (set by --project global flag)
|
|
6
|
+
let _projectRootOverride: string | undefined;
|
|
7
|
+
|
|
8
|
+
/** Override project root for all config resolution (used by --project global flag). */
|
|
9
|
+
export function setProjectRootOverride(path: string): void {
|
|
10
|
+
_projectRootOverride = path;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Get the current project root override, if any. */
|
|
14
|
+
export function getProjectRootOverride(): string | undefined {
|
|
15
|
+
return _projectRootOverride;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Clear the project root override (used in tests and cleanup). */
|
|
19
|
+
export function clearProjectRootOverride(): void {
|
|
20
|
+
_projectRootOverride = undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
/**
|
|
6
24
|
* Default configuration with all fields populated.
|
|
7
25
|
* Used as the base; file-loaded values are merged on top.
|
|
@@ -777,6 +795,11 @@ async function mergeLocalConfig(
|
|
|
777
795
|
* @returns The resolved project root containing `.overstory/`
|
|
778
796
|
*/
|
|
779
797
|
export async function resolveProjectRoot(startDir: string): Promise<string> {
|
|
798
|
+
// Check for explicit override first (set by --project global flag)
|
|
799
|
+
if (_projectRootOverride !== undefined) {
|
|
800
|
+
return _projectRootOverride;
|
|
801
|
+
}
|
|
802
|
+
|
|
780
803
|
const { existsSync } = require("node:fs") as typeof import("node:fs");
|
|
781
804
|
|
|
782
805
|
// Check git worktree FIRST. When running from an agent worktree
|
|
@@ -30,6 +30,7 @@ const EXPECTED_AGENT_DEFS = [
|
|
|
30
30
|
"lead.md",
|
|
31
31
|
"merger.md",
|
|
32
32
|
"monitor.md",
|
|
33
|
+
"orchestrator.md",
|
|
33
34
|
"reviewer.md",
|
|
34
35
|
"scout.md",
|
|
35
36
|
];
|
|
@@ -81,7 +82,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
81
82
|
const gitignoreFile = Bun.file(join(overstoryDir, ".gitignore"));
|
|
82
83
|
expect(await gitignoreFile.exists()).toBe(true);
|
|
83
84
|
|
|
84
|
-
// agent-defs/ contains all
|
|
85
|
+
// agent-defs/ contains all 8 agent definition files (supervisor deprecated)
|
|
85
86
|
const agentDefsDir = join(overstoryDir, "agent-defs");
|
|
86
87
|
const agentDefFiles = (await readdir(agentDefsDir)).filter((f) => f.endsWith(".md")).sort();
|
|
87
88
|
expect(agentDefFiles).toEqual(EXPECTED_AGENT_DEFS);
|
|
@@ -113,7 +114,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
113
114
|
expect(config.project.name).toBeTruthy();
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
test("manifest loads successfully with all
|
|
117
|
+
test("manifest loads successfully with all 8 agents (supervisor deprecated)", async () => {
|
|
117
118
|
await initCommand({ _spawner: noopSpawner });
|
|
118
119
|
|
|
119
120
|
const manifestPath = join(tempDir, ".overstory", "agent-manifest.json");
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Usage: ov <command> [args...]
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
10
12
|
import { Command, Help } from "commander";
|
|
11
13
|
import { createAgentsCommand } from "./commands/agents.ts";
|
|
12
14
|
import { cleanCommand } from "./commands/clean.ts";
|
|
@@ -38,14 +40,16 @@ import { createStatusCommand } from "./commands/status.ts";
|
|
|
38
40
|
import { stopCommand } from "./commands/stop.ts";
|
|
39
41
|
import { createSupervisorCommand } from "./commands/supervisor.ts";
|
|
40
42
|
import { traceCommand } from "./commands/trace.ts";
|
|
43
|
+
import { createUpdateCommand } from "./commands/update.ts";
|
|
41
44
|
import { createUpgradeCommand } from "./commands/upgrade.ts";
|
|
42
45
|
import { createWatchCommand } from "./commands/watch.ts";
|
|
43
46
|
import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
44
|
-
import {
|
|
47
|
+
import { setProjectRootOverride } from "./config.ts";
|
|
48
|
+
import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
45
49
|
import { jsonError } from "./json.ts";
|
|
46
50
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
47
51
|
|
|
48
|
-
export const VERSION = "0.
|
|
52
|
+
export const VERSION = "0.8.2";
|
|
49
53
|
|
|
50
54
|
const rawArgs = process.argv.slice(2);
|
|
51
55
|
|
|
@@ -95,6 +99,7 @@ const COMMANDS = [
|
|
|
95
99
|
"run",
|
|
96
100
|
"costs",
|
|
97
101
|
"metrics",
|
|
102
|
+
"update",
|
|
98
103
|
"upgrade",
|
|
99
104
|
"completions",
|
|
100
105
|
];
|
|
@@ -144,6 +149,7 @@ program
|
|
|
144
149
|
.option("--json", "JSON output")
|
|
145
150
|
.option("--verbose", "Verbose output")
|
|
146
151
|
.option("--timing", "Print command execution time to stderr")
|
|
152
|
+
.option("--project <path>", "Target project root (overrides auto-detection)")
|
|
147
153
|
.addHelpCommand(false)
|
|
148
154
|
.configureHelp({
|
|
149
155
|
formatHelp(cmd, helper): string {
|
|
@@ -199,6 +205,17 @@ program.hook("preAction", (thisCmd) => {
|
|
|
199
205
|
if (opts.quiet) {
|
|
200
206
|
setQuiet(true);
|
|
201
207
|
}
|
|
208
|
+
const projectFlag = opts.project as string | undefined;
|
|
209
|
+
if (projectFlag !== undefined) {
|
|
210
|
+
const resolvedProject = resolve(process.cwd(), projectFlag);
|
|
211
|
+
if (!existsSync(join(resolvedProject, ".overstory", "config.yaml"))) {
|
|
212
|
+
throw new ConfigError(
|
|
213
|
+
`'${resolvedProject}' is not an overstory project (missing .overstory/config.yaml). Run 'ov init' first.`,
|
|
214
|
+
{ configPath: join(resolvedProject, ".overstory", "config.yaml") },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
setProjectRootOverride(resolvedProject);
|
|
218
|
+
}
|
|
202
219
|
if (opts.timing) {
|
|
203
220
|
timingStart = performance.now();
|
|
204
221
|
}
|
|
@@ -390,6 +407,8 @@ program.addCommand(createCostsCommand());
|
|
|
390
407
|
|
|
391
408
|
program.addCommand(createMetricsCommand());
|
|
392
409
|
|
|
410
|
+
program.addCommand(createUpdateCommand());
|
|
411
|
+
|
|
393
412
|
program.addCommand(createUpgradeCommand());
|
|
394
413
|
|
|
395
414
|
// Handle unknown commands with Levenshtein fuzzy-match suggestions
|
package/src/logging/theme.ts
CHANGED
|
@@ -66,6 +66,10 @@ const EVENT_LABELS: Record<EventType, EventLabel> = {
|
|
|
66
66
|
spawn: { compact: "SPAWN", full: "SPAWN ", color: color.magenta },
|
|
67
67
|
error: { compact: "ERROR", full: "ERROR ", color: color.red },
|
|
68
68
|
custom: { compact: "CUSTM", full: "CUSTOM ", color: color.gray },
|
|
69
|
+
turn_start: { compact: "TURN+", full: "TURN START", color: color.green },
|
|
70
|
+
turn_end: { compact: "TURN-", full: "TURN END ", color: color.yellow },
|
|
71
|
+
progress: { compact: "PROG ", full: "PROGRESS ", color: color.cyan },
|
|
72
|
+
result: { compact: "RSULT", full: "RESULT ", color: color.green },
|
|
69
73
|
};
|
|
70
74
|
|
|
71
75
|
/** Returns the EventLabel for a given event type. */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { getConnection, removeConnection, setConnection } from "./connections.ts";
|
|
3
|
+
import type { ConnectionState, RuntimeConnection } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Minimal RuntimeConnection stub for testing the registry. */
|
|
6
|
+
function makeConn(onClose?: () => void): RuntimeConnection {
|
|
7
|
+
return {
|
|
8
|
+
sendPrompt: async (_text: string) => {},
|
|
9
|
+
followUp: async (_text: string) => {},
|
|
10
|
+
abort: async () => {},
|
|
11
|
+
getState: async (): Promise<ConnectionState> => ({ status: "idle" }),
|
|
12
|
+
close: () => {
|
|
13
|
+
if (onClose) onClose();
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("connection registry", () => {
|
|
19
|
+
// Reset registry between tests by removing any entries set during each test.
|
|
20
|
+
// We track names used so we can clean up without affecting other entries.
|
|
21
|
+
const usedNames: string[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const name of usedNames.splice(0)) {
|
|
25
|
+
const conn = getConnection(name);
|
|
26
|
+
if (conn) {
|
|
27
|
+
removeConnection(name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("set and get returns the registered connection", () => {
|
|
33
|
+
const conn = makeConn();
|
|
34
|
+
usedNames.push("agent-alpha");
|
|
35
|
+
setConnection("agent-alpha", conn);
|
|
36
|
+
expect(getConnection("agent-alpha")).toBe(conn);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("get unknown returns undefined", () => {
|
|
40
|
+
expect(getConnection("does-not-exist-xyz")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("removeConnection calls close() on the connection", () => {
|
|
44
|
+
let closed = false;
|
|
45
|
+
const conn = makeConn(() => {
|
|
46
|
+
closed = true;
|
|
47
|
+
});
|
|
48
|
+
usedNames.push("agent-beta");
|
|
49
|
+
setConnection("agent-beta", conn);
|
|
50
|
+
removeConnection("agent-beta");
|
|
51
|
+
expect(closed).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("removeConnection deletes the entry (get returns undefined after)", () => {
|
|
55
|
+
const conn = makeConn();
|
|
56
|
+
usedNames.push("agent-gamma");
|
|
57
|
+
setConnection("agent-gamma", conn);
|
|
58
|
+
removeConnection("agent-gamma");
|
|
59
|
+
expect(getConnection("agent-gamma")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("removeConnection on unknown name is a no-op (does not throw)", () => {
|
|
63
|
+
expect(() => removeConnection("never-registered-xyz")).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("setConnection overwrites an existing entry", () => {
|
|
67
|
+
const conn1 = makeConn();
|
|
68
|
+
const conn2 = makeConn();
|
|
69
|
+
usedNames.push("agent-delta");
|
|
70
|
+
setConnection("agent-delta", conn1);
|
|
71
|
+
setConnection("agent-delta", conn2);
|
|
72
|
+
expect(getConnection("agent-delta")).toBe(conn2);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level connection registry for active RuntimeConnection instances.
|
|
3
|
+
*
|
|
4
|
+
* Tracks RPC connections to headless agent processes (e.g., Sapling).
|
|
5
|
+
* Keyed by agent name — same namespace as AgentSession.agentName.
|
|
6
|
+
*
|
|
7
|
+
* Thread safety: single-threaded Bun runtime; no locking needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RuntimeConnection } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const connections = new Map<string, RuntimeConnection>();
|
|
13
|
+
|
|
14
|
+
/** Retrieve the active connection for a given agent, or undefined if none. */
|
|
15
|
+
export function getConnection(agentName: string): RuntimeConnection | undefined {
|
|
16
|
+
return connections.get(agentName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Register a connection for a given agent. Overwrites any existing entry. */
|
|
20
|
+
export function setConnection(agentName: string, conn: RuntimeConnection): void {
|
|
21
|
+
connections.set(agentName, conn);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Remove the connection for a given agent, calling close() first.
|
|
26
|
+
* Safe to call if no connection exists (no-op).
|
|
27
|
+
*/
|
|
28
|
+
export function removeConnection(agentName: string): void {
|
|
29
|
+
const conn = connections.get(agentName);
|
|
30
|
+
if (conn) {
|
|
31
|
+
conn.close();
|
|
32
|
+
connections.delete(agentName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -22,7 +22,7 @@ describe("getRuntime", () => {
|
|
|
22
22
|
|
|
23
23
|
it("throws with a helpful message for an unknown runtime", () => {
|
|
24
24
|
expect(() => getRuntime("unknown-runtime")).toThrow(
|
|
25
|
-
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini',
|
|
25
|
+
'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling',
|
|
26
26
|
);
|
|
27
27
|
});
|
|
28
28
|
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { CodexRuntime } from "./codex.ts";
|
|
|
7
7
|
import { CopilotRuntime } from "./copilot.ts";
|
|
8
8
|
import { GeminiRuntime } from "./gemini.ts";
|
|
9
9
|
import { PiRuntime } from "./pi.ts";
|
|
10
|
+
import { SaplingRuntime } from "./sapling.ts";
|
|
10
11
|
import type { AgentRuntime } from "./types.ts";
|
|
11
12
|
|
|
12
13
|
/** Registry of config-independent runtime adapters (name → factory). */
|
|
@@ -16,6 +17,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
|
|
|
16
17
|
["pi", () => new PiRuntime()],
|
|
17
18
|
["copilot", () => new CopilotRuntime()],
|
|
18
19
|
["gemini", () => new GeminiRuntime()],
|
|
20
|
+
["sapling", () => new SaplingRuntime()],
|
|
19
21
|
]);
|
|
20
22
|
|
|
21
23
|
/**
|