@os-eco/overstory-cli 0.8.7 → 0.9.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 +26 -8
- package/agents/coordinator.md +30 -6
- package/agents/lead.md +11 -1
- package/agents/ov-co-creation.md +90 -0
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +31 -4
- package/src/canopy/client.test.ts +107 -0
- package/src/canopy/client.ts +179 -0
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +304 -146
- package/src/commands/dashboard.ts +47 -10
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +8 -0
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +23 -3
- package/src/commands/update.test.ts +1 -0
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +13 -88
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +4 -2
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +9 -1
- package/src/mail/store.test.ts +110 -0
- package/src/mail/store.ts +2 -1
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +9 -9
- package/src/runtimes/pi.ts +6 -7
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +2 -2
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.ts +25 -4
- package/src/types.ts +65 -1
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +87 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
- package/templates/overlay.md.tmpl +2 -0
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for overstory group command.
|
|
3
3
|
*
|
|
4
|
-
* Uses real temp directories for groups.json I/O
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* bd is an external CLI not available in unit tests.
|
|
4
|
+
* Uses real temp directories for groups.json I/O and direct function calls
|
|
5
|
+
* to createGroup, addToGroup, removeFromGroup, getGroupProgress, printGroupProgress.
|
|
6
|
+
* Tracker validation uses inline stub objects (no mock.module).
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
10
|
import { mkdir } from "node:fs/promises";
|
|
12
11
|
import { join } from "node:path";
|
|
12
|
+
import { GroupError, ValidationError } from "../errors.ts";
|
|
13
13
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
14
|
+
import type { TrackerIssue } from "../tracker/types.ts";
|
|
15
|
+
import type { TaskGroup, TaskGroupProgress } from "../types.ts";
|
|
16
|
+
import {
|
|
17
|
+
addToGroup,
|
|
18
|
+
createGroup,
|
|
19
|
+
getGroupProgress,
|
|
20
|
+
loadGroups,
|
|
21
|
+
printGroupProgress,
|
|
22
|
+
removeFromGroup,
|
|
23
|
+
} from "./group.ts";
|
|
16
24
|
|
|
17
25
|
let tempDir: string;
|
|
18
26
|
let overstoryDir: string;
|
|
19
|
-
let groupsJsonPath: string;
|
|
20
27
|
|
|
21
28
|
beforeEach(async () => {
|
|
22
29
|
tempDir = await createTempGitRepo();
|
|
23
30
|
overstoryDir = join(tempDir, ".overstory");
|
|
24
31
|
await mkdir(overstoryDir, { recursive: true });
|
|
25
|
-
groupsJsonPath = join(overstoryDir, "groups.json");
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
afterEach(async () => {
|
|
@@ -33,15 +39,8 @@ afterEach(async () => {
|
|
|
33
39
|
* Helper to write groups.json directly for test setup.
|
|
34
40
|
*/
|
|
35
41
|
async function writeGroups(groups: TaskGroup[]): Promise<void> {
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Helper to read groups.json directly for assertions.
|
|
41
|
-
*/
|
|
42
|
-
async function readGroups(): Promise<TaskGroup[]> {
|
|
43
|
-
const text = await Bun.file(groupsJsonPath).text();
|
|
44
|
-
return JSON.parse(text) as TaskGroup[];
|
|
42
|
+
const path = join(overstoryDir, "groups.json");
|
|
43
|
+
await Bun.write(path, `${JSON.stringify(groups, null, "\t")}\n`);
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
function makeGroup(overrides?: Partial<TaskGroup>): TaskGroup {
|
|
@@ -56,6 +55,8 @@ function makeGroup(overrides?: Partial<TaskGroup>): TaskGroup {
|
|
|
56
55
|
};
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
// -- loadGroups --
|
|
59
|
+
|
|
59
60
|
describe("loadGroups", () => {
|
|
60
61
|
test("returns empty array when groups.json does not exist", async () => {
|
|
61
62
|
const groups = await loadGroups(tempDir);
|
|
@@ -63,7 +64,8 @@ describe("loadGroups", () => {
|
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
test("returns empty array when groups.json is malformed", async () => {
|
|
66
|
-
|
|
67
|
+
const path = join(overstoryDir, "groups.json");
|
|
68
|
+
await Bun.write(path, "not valid json");
|
|
67
69
|
const groups = await loadGroups(tempDir);
|
|
68
70
|
expect(groups).toEqual([]);
|
|
69
71
|
});
|
|
@@ -77,186 +79,303 @@ describe("loadGroups", () => {
|
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
// -- createGroup --
|
|
83
|
+
|
|
84
|
+
describe("createGroup", () => {
|
|
85
|
+
test("creates a group with valid name and issue IDs", async () => {
|
|
86
|
+
const group = await createGroup(tempDir, "Feature Batch", ["abc-1", "def-2"], true);
|
|
87
|
+
expect(group.name).toBe("Feature Batch");
|
|
88
|
+
expect(group.memberIssueIds).toEqual(["abc-1", "def-2"]);
|
|
89
|
+
expect(group.status).toBe("active");
|
|
90
|
+
expect(group.completedAt).toBeNull();
|
|
91
|
+
expect(group.id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
92
|
+
expect(group.createdAt).toBeTruthy();
|
|
93
|
+
});
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
expect(
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
test("persists to disk", async () => {
|
|
96
|
+
await createGroup(tempDir, "Persisted", ["x-1"], true);
|
|
97
|
+
const loaded = await loadGroups(tempDir);
|
|
98
|
+
expect(loaded).toHaveLength(1);
|
|
99
|
+
expect(loaded[0]?.name).toBe("Persisted");
|
|
100
|
+
expect(loaded[0]?.memberIssueIds).toEqual(["x-1"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("throws ValidationError for empty name", async () => {
|
|
104
|
+
await expect(createGroup(tempDir, "", ["id-1"], true)).rejects.toThrow(ValidationError);
|
|
105
|
+
await expect(createGroup(tempDir, " ", ["id-1"], true)).rejects.toThrow(ValidationError);
|
|
96
106
|
});
|
|
97
107
|
|
|
98
|
-
test("
|
|
99
|
-
|
|
100
|
-
expect(id).toMatch(/^group-[a-f0-9]{8}$/);
|
|
108
|
+
test("throws ValidationError for empty issueIds", async () => {
|
|
109
|
+
await expect(createGroup(tempDir, "Name", [], true)).rejects.toThrow(ValidationError);
|
|
101
110
|
});
|
|
102
111
|
|
|
103
|
-
test("
|
|
104
|
-
await
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
test("throws ValidationError for duplicate IDs", async () => {
|
|
113
|
+
await expect(createGroup(tempDir, "Name", ["a", "b", "a"], true)).rejects.toThrow(
|
|
114
|
+
ValidationError,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("appends to existing groups", async () => {
|
|
119
|
+
await createGroup(tempDir, "First", ["id-1"], true);
|
|
120
|
+
await createGroup(tempDir, "Second", ["id-2"], true);
|
|
121
|
+
const loaded = await loadGroups(tempDir);
|
|
122
|
+
expect(loaded).toHaveLength(2);
|
|
123
|
+
expect(loaded[0]?.name).toBe("First");
|
|
124
|
+
expect(loaded[1]?.name).toBe("Second");
|
|
107
125
|
});
|
|
108
126
|
});
|
|
109
127
|
|
|
110
|
-
|
|
111
|
-
test("adds issues to existing group", async () => {
|
|
112
|
-
const group = makeGroup({ memberIssueIds: ["issue-1"] });
|
|
113
|
-
await writeGroups([group]);
|
|
128
|
+
// -- addToGroup --
|
|
114
129
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await writeGroups(groups);
|
|
122
|
-
}
|
|
130
|
+
describe("addToGroup", () => {
|
|
131
|
+
test("adds IDs to an existing group", async () => {
|
|
132
|
+
const created = await createGroup(tempDir, "G", ["a"], true);
|
|
133
|
+
const updated = await addToGroup(tempDir, created.id, ["b", "c"], true);
|
|
134
|
+
expect(updated.memberIssueIds).toEqual(["a", "b", "c"]);
|
|
135
|
+
});
|
|
123
136
|
|
|
124
|
-
|
|
125
|
-
expect(
|
|
137
|
+
test("throws GroupError when group not found", async () => {
|
|
138
|
+
await expect(addToGroup(tempDir, "group-missing0", ["x"], true)).rejects.toThrow(GroupError);
|
|
126
139
|
});
|
|
127
140
|
|
|
128
|
-
test("
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
});
|
|
133
|
-
await writeGroups([group]);
|
|
141
|
+
test("throws GroupError for duplicate member", async () => {
|
|
142
|
+
const created = await createGroup(tempDir, "G", ["a", "b"], true);
|
|
143
|
+
await expect(addToGroup(tempDir, created.id, ["a"], true)).rejects.toThrow(GroupError);
|
|
144
|
+
});
|
|
134
145
|
|
|
135
|
-
|
|
146
|
+
test("reopens completed group when adding issues", async () => {
|
|
147
|
+
const created = await createGroup(tempDir, "G", ["a"], true);
|
|
148
|
+
// Manually mark as completed on disk
|
|
149
|
+
const groups = await loadGroups(tempDir);
|
|
136
150
|
const target = groups[0];
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
target.completedAt = null;
|
|
142
|
-
await writeGroups(groups);
|
|
143
|
-
}
|
|
151
|
+
if (!target) throw new Error("expected group");
|
|
152
|
+
target.status = "completed";
|
|
153
|
+
target.completedAt = new Date().toISOString();
|
|
154
|
+
await writeGroups(groups);
|
|
144
155
|
|
|
145
|
-
const updated = await
|
|
146
|
-
expect(updated
|
|
147
|
-
expect(updated
|
|
156
|
+
const updated = await addToGroup(tempDir, created.id, ["b"], true);
|
|
157
|
+
expect(updated.status).toBe("active");
|
|
158
|
+
expect(updated.completedAt).toBeNull();
|
|
148
159
|
});
|
|
149
160
|
|
|
150
|
-
test("
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
expect(isDuplicate).toBe(true);
|
|
161
|
+
test("throws ValidationError for empty issueIds", async () => {
|
|
162
|
+
const created = await createGroup(tempDir, "G", ["a"], true);
|
|
163
|
+
await expect(addToGroup(tempDir, created.id, [], true)).rejects.toThrow(ValidationError);
|
|
154
164
|
});
|
|
155
165
|
});
|
|
156
166
|
|
|
157
|
-
|
|
158
|
-
test("removes issues from group", async () => {
|
|
159
|
-
const group = makeGroup({ memberIssueIds: ["a", "b", "c"] });
|
|
160
|
-
await writeGroups([group]);
|
|
161
|
-
|
|
162
|
-
const groups = await readGroups();
|
|
163
|
-
const target = groups[0];
|
|
164
|
-
expect(target).toBeDefined();
|
|
165
|
-
if (target) {
|
|
166
|
-
target.memberIssueIds = target.memberIssueIds.filter((id) => id !== "b");
|
|
167
|
-
await writeGroups(groups);
|
|
168
|
-
}
|
|
167
|
+
// -- removeFromGroup --
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
describe("removeFromGroup", () => {
|
|
170
|
+
test("removes IDs from a group", async () => {
|
|
171
|
+
const created = await createGroup(tempDir, "G", ["a", "b", "c"], true);
|
|
172
|
+
const updated = await removeFromGroup(tempDir, created.id, ["b"]);
|
|
173
|
+
expect(updated.memberIssueIds).toEqual(["a", "c"]);
|
|
172
174
|
});
|
|
173
175
|
|
|
174
|
-
test("
|
|
175
|
-
|
|
176
|
-
const toRemove = ["only-one"];
|
|
177
|
-
const remaining = group.memberIssueIds.filter((id) => !toRemove.includes(id));
|
|
178
|
-
expect(remaining.length).toBe(0);
|
|
179
|
-
// The command should throw GroupError in this case
|
|
176
|
+
test("throws GroupError when group not found", async () => {
|
|
177
|
+
await expect(removeFromGroup(tempDir, "group-missing0", ["x"])).rejects.toThrow(GroupError);
|
|
180
178
|
});
|
|
181
179
|
|
|
182
|
-
test("
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
expect(isNotMember).toBe(true);
|
|
180
|
+
test("throws GroupError for non-member issue", async () => {
|
|
181
|
+
const created = await createGroup(tempDir, "G", ["a", "b"], true);
|
|
182
|
+
await expect(removeFromGroup(tempDir, created.id, ["z"])).rejects.toThrow(GroupError);
|
|
186
183
|
});
|
|
187
|
-
});
|
|
188
184
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
memberIssueIds: ["done-1", "done-2"],
|
|
194
|
-
});
|
|
195
|
-
await writeGroups([group]);
|
|
185
|
+
test("throws GroupError when removal would empty the group", async () => {
|
|
186
|
+
const created = await createGroup(tempDir, "G", ["only"], true);
|
|
187
|
+
await expect(removeFromGroup(tempDir, created.id, ["only"])).rejects.toThrow(GroupError);
|
|
188
|
+
});
|
|
196
189
|
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (target && target.status === "active") {
|
|
202
|
-
// All issues closed -> auto-close
|
|
203
|
-
target.status = "completed";
|
|
204
|
-
target.completedAt = new Date().toISOString();
|
|
205
|
-
await writeGroups(groups);
|
|
206
|
-
}
|
|
190
|
+
test("throws ValidationError for empty issueIds", async () => {
|
|
191
|
+
const created = await createGroup(tempDir, "G", ["a"], true);
|
|
192
|
+
await expect(removeFromGroup(tempDir, created.id, [])).rejects.toThrow(ValidationError);
|
|
193
|
+
});
|
|
207
194
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
195
|
+
test("persists removal to disk", async () => {
|
|
196
|
+
const created = await createGroup(tempDir, "G", ["a", "b", "c"], true);
|
|
197
|
+
await removeFromGroup(tempDir, created.id, ["b"]);
|
|
198
|
+
const loaded = await loadGroups(tempDir);
|
|
199
|
+
expect(loaded[0]?.memberIssueIds).toEqual(["a", "c"]);
|
|
211
200
|
});
|
|
201
|
+
});
|
|
212
202
|
|
|
213
|
-
|
|
214
|
-
const group = makeGroup({ status: "active" });
|
|
215
|
-
await writeGroups([group]);
|
|
203
|
+
// -- getGroupProgress --
|
|
216
204
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
205
|
+
describe("getGroupProgress", () => {
|
|
206
|
+
test("counts default to open without tracker", async () => {
|
|
207
|
+
const group = makeGroup({ memberIssueIds: ["x", "y", "z"] });
|
|
208
|
+
const groups = [group];
|
|
209
|
+
await writeGroups(groups);
|
|
210
|
+
|
|
211
|
+
const progress = await getGroupProgress(tempDir, group, groups);
|
|
212
|
+
expect(progress.total).toBe(3);
|
|
213
|
+
expect(progress.open).toBe(3);
|
|
214
|
+
expect(progress.completed).toBe(0);
|
|
215
|
+
expect(progress.inProgress).toBe(0);
|
|
216
|
+
expect(progress.blocked).toBe(0);
|
|
220
217
|
});
|
|
221
218
|
|
|
222
|
-
test("
|
|
223
|
-
const group = makeGroup({
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
test("auto-closes when all issues are closed (stub tracker)", async () => {
|
|
220
|
+
const group = makeGroup({ memberIssueIds: ["done-1", "done-2"] });
|
|
221
|
+
const groups = [group];
|
|
222
|
+
await writeGroups(groups);
|
|
223
|
+
|
|
224
|
+
const stubTracker = {
|
|
225
|
+
ready: async () => [],
|
|
226
|
+
show: async (id: string): Promise<TrackerIssue> => ({
|
|
227
|
+
id,
|
|
228
|
+
title: id,
|
|
229
|
+
status: "closed",
|
|
230
|
+
priority: 3,
|
|
231
|
+
type: "task",
|
|
232
|
+
}),
|
|
233
|
+
create: async () => "",
|
|
234
|
+
claim: async () => {},
|
|
235
|
+
close: async () => {},
|
|
236
|
+
list: async () => [],
|
|
237
|
+
sync: async () => {},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const progress = await getGroupProgress(tempDir, group, groups, stubTracker);
|
|
241
|
+
expect(progress.completed).toBe(2);
|
|
242
|
+
expect(progress.total).toBe(2);
|
|
243
|
+
expect(progress.group.status).toBe("completed");
|
|
244
|
+
expect(progress.group.completedAt).not.toBeNull();
|
|
227
245
|
});
|
|
228
|
-
});
|
|
229
246
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
await writeGroups([g1, g2]);
|
|
247
|
+
test("does not auto-close when some issues are still open", async () => {
|
|
248
|
+
const group = makeGroup({ memberIssueIds: ["done-1", "open-1"] });
|
|
249
|
+
const groups = [group];
|
|
250
|
+
await writeGroups(groups);
|
|
235
251
|
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
252
|
+
const stubTracker = {
|
|
253
|
+
ready: async () => [],
|
|
254
|
+
show: async (id: string): Promise<TrackerIssue> => ({
|
|
255
|
+
id,
|
|
256
|
+
title: id,
|
|
257
|
+
status: id.startsWith("done-") ? "closed" : "open",
|
|
258
|
+
priority: 3,
|
|
259
|
+
type: "task",
|
|
260
|
+
}),
|
|
261
|
+
create: async () => "",
|
|
262
|
+
claim: async () => {},
|
|
263
|
+
close: async () => {},
|
|
264
|
+
list: async () => [],
|
|
265
|
+
sync: async () => {},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const progress = await getGroupProgress(tempDir, group, groups, stubTracker);
|
|
269
|
+
expect(progress.completed).toBe(1);
|
|
270
|
+
expect(progress.open).toBe(1);
|
|
271
|
+
expect(progress.group.status).toBe("active");
|
|
240
272
|
});
|
|
241
273
|
|
|
242
|
-
test("
|
|
243
|
-
const
|
|
244
|
-
|
|
274
|
+
test("counts in_progress and blocked statuses", async () => {
|
|
275
|
+
const group = makeGroup({ memberIssueIds: ["ip-1", "bl-1", "cl-1", "op-1"] });
|
|
276
|
+
const groups = [group];
|
|
277
|
+
await writeGroups(groups);
|
|
278
|
+
|
|
279
|
+
const statusMap: Record<string, string> = {
|
|
280
|
+
"ip-1": "in_progress",
|
|
281
|
+
"bl-1": "blocked",
|
|
282
|
+
"cl-1": "closed",
|
|
283
|
+
"op-1": "open",
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const stubTracker = {
|
|
287
|
+
ready: async () => [],
|
|
288
|
+
show: async (id: string): Promise<TrackerIssue> => ({
|
|
289
|
+
id,
|
|
290
|
+
title: id,
|
|
291
|
+
status: statusMap[id] ?? "open",
|
|
292
|
+
priority: 3,
|
|
293
|
+
type: "task",
|
|
294
|
+
}),
|
|
295
|
+
create: async () => "",
|
|
296
|
+
claim: async () => {},
|
|
297
|
+
close: async () => {},
|
|
298
|
+
list: async () => [],
|
|
299
|
+
sync: async () => {},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const progress = await getGroupProgress(tempDir, group, groups, stubTracker);
|
|
303
|
+
expect(progress.inProgress).toBe(1);
|
|
304
|
+
expect(progress.blocked).toBe(1);
|
|
305
|
+
expect(progress.completed).toBe(1);
|
|
306
|
+
expect(progress.open).toBe(1);
|
|
245
307
|
});
|
|
246
308
|
});
|
|
247
309
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
310
|
+
// -- printGroupProgress --
|
|
311
|
+
|
|
312
|
+
describe("printGroupProgress", () => {
|
|
313
|
+
test("outputs formatted progress for active group", () => {
|
|
314
|
+
const group = makeGroup({ id: "group-abc12345", name: "My Group" });
|
|
315
|
+
const progress: TaskGroupProgress = {
|
|
316
|
+
group,
|
|
317
|
+
total: 5,
|
|
318
|
+
completed: 2,
|
|
319
|
+
inProgress: 1,
|
|
320
|
+
blocked: 1,
|
|
321
|
+
open: 1,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const chunks: string[] = [];
|
|
325
|
+
const origWrite = process.stdout.write;
|
|
326
|
+
process.stdout.write = ((chunk: string) => {
|
|
327
|
+
chunks.push(chunk);
|
|
328
|
+
return true;
|
|
329
|
+
}) as typeof process.stdout.write;
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
printGroupProgress(progress);
|
|
333
|
+
} finally {
|
|
334
|
+
process.stdout.write = origWrite;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const output = chunks.join("");
|
|
338
|
+
expect(output).toContain("My Group");
|
|
339
|
+
expect(output).toContain("group-abc12345");
|
|
340
|
+
expect(output).toContain("[active]");
|
|
341
|
+
expect(output).toContain("5 total");
|
|
342
|
+
expect(output).toContain("2 completed");
|
|
343
|
+
expect(output).toContain("1 in_progress");
|
|
344
|
+
expect(output).toContain("1 blocked");
|
|
345
|
+
expect(output).toContain("1 open");
|
|
254
346
|
});
|
|
255
347
|
|
|
256
|
-
test("
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
348
|
+
test("outputs completed timestamp for completed group", () => {
|
|
349
|
+
const group = makeGroup({
|
|
350
|
+
id: "group-done1234",
|
|
351
|
+
name: "Done Group",
|
|
352
|
+
status: "completed",
|
|
353
|
+
completedAt: "2026-01-15T10:00:00.000Z",
|
|
354
|
+
});
|
|
355
|
+
const progress: TaskGroupProgress = {
|
|
356
|
+
group,
|
|
357
|
+
total: 2,
|
|
358
|
+
completed: 2,
|
|
359
|
+
inProgress: 0,
|
|
360
|
+
blocked: 0,
|
|
361
|
+
open: 0,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const chunks: string[] = [];
|
|
365
|
+
const origWrite = process.stdout.write;
|
|
366
|
+
process.stdout.write = ((chunk: string) => {
|
|
367
|
+
chunks.push(chunk);
|
|
368
|
+
return true;
|
|
369
|
+
}) as typeof process.stdout.write;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
printGroupProgress(progress);
|
|
373
|
+
} finally {
|
|
374
|
+
process.stdout.write = origWrite;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const output = chunks.join("");
|
|
378
|
+
expect(output).toContain("[completed]");
|
|
379
|
+
expect(output).toContain("2026-01-15T10:00:00.000Z");
|
|
261
380
|
});
|
|
262
381
|
});
|
package/src/commands/group.ts
CHANGED
|
@@ -25,6 +25,7 @@ function groupsPath(projectRoot: string): string {
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Load groups from .overstory/groups.json.
|
|
28
|
+
* @internal Exported for testing.
|
|
28
29
|
*/
|
|
29
30
|
export async function loadGroups(projectRoot: string): Promise<TaskGroup[]> {
|
|
30
31
|
const path = groupsPath(projectRoot);
|
|
@@ -80,8 +81,9 @@ function generateGroupId(): string {
|
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Create a new task group.
|
|
84
|
+
* @internal Exported for testing.
|
|
83
85
|
*/
|
|
84
|
-
async function createGroup(
|
|
86
|
+
export async function createGroup(
|
|
85
87
|
projectRoot: string,
|
|
86
88
|
name: string,
|
|
87
89
|
issueIds: string[],
|
|
@@ -124,8 +126,9 @@ async function createGroup(
|
|
|
124
126
|
|
|
125
127
|
/**
|
|
126
128
|
* Add issues to an existing group.
|
|
129
|
+
* @internal Exported for testing.
|
|
127
130
|
*/
|
|
128
|
-
async function addToGroup(
|
|
131
|
+
export async function addToGroup(
|
|
129
132
|
projectRoot: string,
|
|
130
133
|
groupId: string,
|
|
131
134
|
issueIds: string[],
|
|
@@ -172,8 +175,9 @@ async function addToGroup(
|
|
|
172
175
|
|
|
173
176
|
/**
|
|
174
177
|
* Remove issues from an existing group.
|
|
178
|
+
* @internal Exported for testing.
|
|
175
179
|
*/
|
|
176
|
-
async function removeFromGroup(
|
|
180
|
+
export async function removeFromGroup(
|
|
177
181
|
projectRoot: string,
|
|
178
182
|
groupId: string,
|
|
179
183
|
issueIds: string[],
|
|
@@ -211,8 +215,9 @@ async function removeFromGroup(
|
|
|
211
215
|
/**
|
|
212
216
|
* Get progress for a single group. Queries the tracker for member issue statuses.
|
|
213
217
|
* Auto-closes the group if all members are closed.
|
|
218
|
+
* @internal Exported for testing.
|
|
214
219
|
*/
|
|
215
|
-
async function getGroupProgress(
|
|
220
|
+
export async function getGroupProgress(
|
|
216
221
|
projectRoot: string,
|
|
217
222
|
group: TaskGroup,
|
|
218
223
|
groups: TaskGroup[],
|
|
@@ -284,8 +289,9 @@ async function getGroupProgress(
|
|
|
284
289
|
|
|
285
290
|
/**
|
|
286
291
|
* Print a group's progress in human-readable format.
|
|
292
|
+
* @internal Exported for testing.
|
|
287
293
|
*/
|
|
288
|
-
function printGroupProgress(progress: TaskGroupProgress): void {
|
|
294
|
+
export function printGroupProgress(progress: TaskGroupProgress): void {
|
|
289
295
|
const w = process.stdout.write.bind(process.stdout);
|
|
290
296
|
const { group, total, completed, inProgress, blocked, open } = progress;
|
|
291
297
|
const status = group.status === "completed" ? "[completed]" : "[active]";
|
|
@@ -27,6 +27,7 @@ const AGENT_DEF_FILES = [
|
|
|
27
27
|
"coordinator.md",
|
|
28
28
|
"monitor.md",
|
|
29
29
|
"orchestrator.md",
|
|
30
|
+
"ov-co-creation.md",
|
|
30
31
|
];
|
|
31
32
|
|
|
32
33
|
/** Resolve the source agents directory (same logic as init.ts). */
|
|
@@ -53,7 +54,7 @@ describe("initCommand: agent-defs deployment", () => {
|
|
|
53
54
|
await cleanupTempDir(tempDir);
|
|
54
55
|
});
|
|
55
56
|
|
|
56
|
-
test("creates .overstory/agent-defs/ with all
|
|
57
|
+
test("creates .overstory/agent-defs/ with all 9 agent definition files (supervisor deprecated)", async () => {
|
|
57
58
|
await initCommand({ _spawner: noopSpawner });
|
|
58
59
|
|
|
59
60
|
const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
|
package/src/commands/init.ts
CHANGED
|
@@ -396,6 +396,14 @@ export function buildAgentManifest(): AgentManifest {
|
|
|
396
396
|
canSpawn: true,
|
|
397
397
|
constraints: ["read-only", "no-worktree"],
|
|
398
398
|
},
|
|
399
|
+
orchestrator: {
|
|
400
|
+
file: "orchestrator.md",
|
|
401
|
+
model: "opus",
|
|
402
|
+
tools: ["Read", "Glob", "Grep", "Bash"],
|
|
403
|
+
capabilities: ["orchestrate", "coordinate", "dispatch", "escalate"],
|
|
404
|
+
canSpawn: true,
|
|
405
|
+
constraints: ["read-only", "no-worktree"],
|
|
406
|
+
},
|
|
399
407
|
monitor: {
|
|
400
408
|
file: "monitor.md",
|
|
401
409
|
model: "sonnet",
|
package/src/commands/log.test.ts
CHANGED
|
@@ -399,6 +399,41 @@ describe("logCommand", () => {
|
|
|
399
399
|
expect(updatedSession?.state).toBe("working");
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
test("session-end does NOT transition orchestrator to completed (persistent agent)", async () => {
|
|
403
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
404
|
+
const session: AgentSession = {
|
|
405
|
+
id: "session-orch",
|
|
406
|
+
agentName: "orchestrator",
|
|
407
|
+
capability: "orchestrator",
|
|
408
|
+
worktreePath: tempDir,
|
|
409
|
+
branchName: "main",
|
|
410
|
+
taskId: "",
|
|
411
|
+
tmuxSession: "overstory-orchestrator",
|
|
412
|
+
state: "working",
|
|
413
|
+
pid: 33333,
|
|
414
|
+
parentAgent: null,
|
|
415
|
+
depth: 0,
|
|
416
|
+
runId: null,
|
|
417
|
+
startedAt: new Date().toISOString(),
|
|
418
|
+
lastActivity: new Date(Date.now() - 60_000).toISOString(),
|
|
419
|
+
escalationLevel: 0,
|
|
420
|
+
stalledSince: null,
|
|
421
|
+
transcriptPath: null,
|
|
422
|
+
};
|
|
423
|
+
const store = createSessionStore(dbPath);
|
|
424
|
+
store.upsert(session);
|
|
425
|
+
store.close();
|
|
426
|
+
|
|
427
|
+
await logCommand(["session-end", "--agent", "orchestrator"]);
|
|
428
|
+
|
|
429
|
+
const readStore = createSessionStore(dbPath);
|
|
430
|
+
const updatedSession = readStore.getByName("orchestrator");
|
|
431
|
+
readStore.close();
|
|
432
|
+
|
|
433
|
+
expect(updatedSession).toBeDefined();
|
|
434
|
+
expect(updatedSession?.state).toBe("working");
|
|
435
|
+
});
|
|
436
|
+
|
|
402
437
|
describe("session-end coordinator run completion", () => {
|
|
403
438
|
test("session-end does NOT auto-complete the active run for coordinator agent (per-turn Stop hook guard)", async () => {
|
|
404
439
|
// Regression test for overstory-adc5:
|