@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.
Files changed (98) hide show
  1. package/README.md +26 -8
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/agents/ov-co-creation.md +90 -0
  5. package/package.json +1 -1
  6. package/src/agents/hooks-deployer.test.ts +9 -1
  7. package/src/agents/hooks-deployer.ts +2 -1
  8. package/src/agents/overlay.test.ts +26 -0
  9. package/src/agents/overlay.ts +31 -4
  10. package/src/canopy/client.test.ts +107 -0
  11. package/src/canopy/client.ts +179 -0
  12. package/src/commands/agents.ts +1 -1
  13. package/src/commands/clean.test.ts +3 -0
  14. package/src/commands/clean.ts +1 -58
  15. package/src/commands/completions.test.ts +18 -6
  16. package/src/commands/completions.ts +40 -1
  17. package/src/commands/coordinator.test.ts +77 -4
  18. package/src/commands/coordinator.ts +304 -146
  19. package/src/commands/dashboard.ts +47 -10
  20. package/src/commands/discover.test.ts +288 -0
  21. package/src/commands/discover.ts +202 -0
  22. package/src/commands/doctor.ts +3 -1
  23. package/src/commands/ecosystem.test.ts +126 -1
  24. package/src/commands/ecosystem.ts +7 -53
  25. package/src/commands/feed.test.ts +117 -2
  26. package/src/commands/feed.ts +46 -30
  27. package/src/commands/group.test.ts +274 -155
  28. package/src/commands/group.ts +11 -5
  29. package/src/commands/init.test.ts +2 -1
  30. package/src/commands/init.ts +8 -0
  31. package/src/commands/log.test.ts +35 -0
  32. package/src/commands/log.ts +10 -6
  33. package/src/commands/logs.test.ts +423 -1
  34. package/src/commands/logs.ts +99 -104
  35. package/src/commands/orchestrator.ts +42 -0
  36. package/src/commands/prime.test.ts +177 -2
  37. package/src/commands/prime.ts +4 -2
  38. package/src/commands/sling.ts +23 -3
  39. package/src/commands/update.test.ts +1 -0
  40. package/src/commands/upgrade.test.ts +2 -0
  41. package/src/commands/upgrade.ts +1 -17
  42. package/src/commands/watch.test.ts +67 -1
  43. package/src/commands/watch.ts +13 -88
  44. package/src/config.test.ts +250 -0
  45. package/src/config.ts +43 -0
  46. package/src/doctor/agents.test.ts +72 -5
  47. package/src/doctor/agents.ts +10 -10
  48. package/src/doctor/consistency.test.ts +35 -0
  49. package/src/doctor/consistency.ts +7 -3
  50. package/src/doctor/dependencies.test.ts +58 -1
  51. package/src/doctor/dependencies.ts +4 -2
  52. package/src/doctor/providers.test.ts +41 -5
  53. package/src/doctor/types.ts +2 -1
  54. package/src/doctor/version.test.ts +106 -2
  55. package/src/doctor/version.ts +4 -2
  56. package/src/doctor/watchdog.test.ts +167 -0
  57. package/src/doctor/watchdog.ts +158 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +4 -2
  59. package/src/errors.test.ts +350 -0
  60. package/src/events/tailer.test.ts +25 -0
  61. package/src/events/tailer.ts +8 -1
  62. package/src/index.ts +9 -1
  63. package/src/mail/store.test.ts +110 -0
  64. package/src/mail/store.ts +2 -1
  65. package/src/runtimes/aider.test.ts +124 -0
  66. package/src/runtimes/aider.ts +147 -0
  67. package/src/runtimes/amp.test.ts +164 -0
  68. package/src/runtimes/amp.ts +154 -0
  69. package/src/runtimes/claude.test.ts +4 -2
  70. package/src/runtimes/goose.test.ts +133 -0
  71. package/src/runtimes/goose.ts +157 -0
  72. package/src/runtimes/pi-guards.ts +2 -1
  73. package/src/runtimes/pi.test.ts +9 -9
  74. package/src/runtimes/pi.ts +6 -7
  75. package/src/runtimes/registry.test.ts +1 -1
  76. package/src/runtimes/registry.ts +13 -4
  77. package/src/runtimes/sapling.ts +2 -1
  78. package/src/runtimes/types.ts +2 -2
  79. package/src/schema-consistency.test.ts +1 -0
  80. package/src/sessions/store.ts +25 -4
  81. package/src/types.ts +65 -1
  82. package/src/utils/bin.test.ts +10 -0
  83. package/src/utils/bin.ts +37 -0
  84. package/src/utils/fs.test.ts +119 -0
  85. package/src/utils/fs.ts +62 -0
  86. package/src/utils/pid.test.ts +68 -0
  87. package/src/utils/pid.ts +45 -0
  88. package/src/utils/time.test.ts +43 -0
  89. package/src/utils/time.ts +37 -0
  90. package/src/utils/version.test.ts +33 -0
  91. package/src/utils/version.ts +70 -0
  92. package/src/watchdog/daemon.test.ts +255 -1
  93. package/src/watchdog/daemon.ts +87 -9
  94. package/src/watchdog/health.test.ts +15 -1
  95. package/src/watchdog/health.ts +1 -1
  96. package/src/watchdog/triage.test.ts +49 -9
  97. package/src/watchdog/triage.ts +21 -5
  98. 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. Does NOT mock bd CLI --
5
- * tests focus on the JSON storage layer and validation logic.
6
- * The beads validation is tested with --skip-validation flag since
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 { TaskGroup } from "../types.ts";
15
- import { loadGroups } from "./group.ts";
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
- await Bun.write(groupsJsonPath, `${JSON.stringify(groups, null, "\t")}\n`);
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
- await Bun.write(groupsJsonPath, "not valid json");
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
- describe("group create (via JSON storage)", () => {
81
- test("creates a group with correct structure", async () => {
82
- const group = makeGroup({
83
- name: "Feature Batch",
84
- memberIssueIds: ["abc-123", "def-456"],
85
- });
86
- await writeGroups([group]);
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
- const groups = await readGroups();
89
- expect(groups).toHaveLength(1);
90
- const saved = groups[0];
91
- expect(saved?.name).toBe("Feature Batch");
92
- expect(saved?.memberIssueIds).toEqual(["abc-123", "def-456"]);
93
- expect(saved?.status).toBe("active");
94
- expect(saved?.completedAt).toBeNull();
95
- expect(saved?.id).toMatch(/^group-[a-f0-9]{8}$/);
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("group ID has correct format", () => {
99
- const id = `group-${crypto.randomUUID().slice(0, 8)}`;
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("groups.json has trailing newline", async () => {
104
- await writeGroups([makeGroup()]);
105
- const raw = await Bun.file(groupsJsonPath).text();
106
- expect(raw.endsWith("\n")).toBe(true);
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
- describe("group add (via JSON storage)", () => {
111
- test("adds issues to existing group", async () => {
112
- const group = makeGroup({ memberIssueIds: ["issue-1"] });
113
- await writeGroups([group]);
128
+ // -- addToGroup --
114
129
 
115
- // Simulate add
116
- const groups = await readGroups();
117
- const target = groups[0];
118
- expect(target).toBeDefined();
119
- if (target) {
120
- target.memberIssueIds.push("issue-2", "issue-3");
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
- const updated = await readGroups();
125
- expect(updated[0]?.memberIssueIds).toEqual(["issue-1", "issue-2", "issue-3"]);
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("reopens completed group when adding issues", async () => {
129
- const group = makeGroup({
130
- status: "completed",
131
- completedAt: new Date().toISOString(),
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
- const groups = await readGroups();
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
- expect(target).toBeDefined();
138
- if (target) {
139
- target.memberIssueIds.push("new-issue");
140
- target.status = "active";
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 readGroups();
146
- expect(updated[0]?.status).toBe("active");
147
- expect(updated[0]?.completedAt).toBeNull();
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("detects duplicate members", () => {
151
- const group = makeGroup({ memberIssueIds: ["issue-1", "issue-2"] });
152
- const isDuplicate = group.memberIssueIds.includes("issue-1");
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
- describe("group remove (via JSON storage)", () => {
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
- const updated = await readGroups();
171
- expect(updated[0]?.memberIssueIds).toEqual(["a", "c"]);
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("cannot remove all issues (would leave empty group)", () => {
175
- const group = makeGroup({ memberIssueIds: ["only-one"] });
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("detects non-member issue", () => {
183
- const group = makeGroup({ memberIssueIds: ["a", "b"] });
184
- const isNotMember = !group.memberIssueIds.includes("c");
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
- describe("auto-close logic", () => {
190
- test("marks group completed when all issues are closed", async () => {
191
- const group = makeGroup({
192
- status: "active",
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
- // Simulate auto-close: all completed
198
- const groups = await readGroups();
199
- const target = groups[0];
200
- expect(target).toBeDefined();
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
- const updated = await readGroups();
209
- expect(updated[0]?.status).toBe("completed");
210
- expect(updated[0]?.completedAt).not.toBeNull();
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
- test("does not auto-close when some issues are still open", async () => {
214
- const group = makeGroup({ status: "active" });
215
- await writeGroups([group]);
203
+ // -- getGroupProgress --
216
204
 
217
- // No change -- some still open
218
- const groups = await readGroups();
219
- expect(groups[0]?.status).toBe("active");
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("does not auto-close already-completed group", () => {
223
- const group = makeGroup({ status: "completed", completedAt: "2025-01-01T00:00:00Z" });
224
- // Already completed, should not re-trigger
225
- expect(group.status).toBe("completed");
226
- expect(group.completedAt).toBe("2025-01-01T00:00:00Z");
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
- describe("group list (via JSON storage)", () => {
231
- test("lists all groups", async () => {
232
- const g1 = makeGroup({ name: "Group A" });
233
- const g2 = makeGroup({ name: "Group B", status: "completed" });
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 groups = await readGroups();
237
- expect(groups).toHaveLength(2);
238
- expect(groups[0]?.name).toBe("Group A");
239
- expect(groups[1]?.name).toBe("Group B");
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("empty list when no groups exist", async () => {
243
- const groups = await loadGroups(tempDir);
244
- expect(groups).toEqual([]);
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
- describe("error cases", () => {
249
- test("group not found by ID", async () => {
250
- await writeGroups([makeGroup()]);
251
- const groups = await readGroups();
252
- const found = groups.find((g) => g.id === "group-nonexist");
253
- expect(found).toBeUndefined();
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("multiple groups can be stored", async () => {
257
- const groups = [makeGroup({ name: "A" }), makeGroup({ name: "B" }), makeGroup({ name: "C" })];
258
- await writeGroups(groups);
259
- const loaded = await readGroups();
260
- expect(loaded).toHaveLength(3);
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
  });
@@ -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 8 agent definition files (supervisor deprecated)", async () => {
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");
@@ -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",
@@ -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: