@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
package/src/commands/logs.ts
CHANGED
|
@@ -18,45 +18,13 @@ import { color } from "../logging/color.ts";
|
|
|
18
18
|
import { formatAbsoluteTime, formatDate, logLevelColor, logLevelLabel } from "../logging/format.ts";
|
|
19
19
|
import { renderHeader } from "../logging/theme.ts";
|
|
20
20
|
import type { LogEvent } from "../types.ts";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Parse relative time formats like "1h", "30m", "2d", "10s" into a Date object.
|
|
24
|
-
* Falls back to parsing as ISO 8601 if not in relative format.
|
|
25
|
-
*/
|
|
26
|
-
function parseRelativeTime(timeStr: string): Date {
|
|
27
|
-
const relativeMatch = /^(\d+)(s|m|h|d)$/.exec(timeStr);
|
|
28
|
-
if (relativeMatch) {
|
|
29
|
-
const value = Number.parseInt(relativeMatch[1] ?? "0", 10);
|
|
30
|
-
const unit = relativeMatch[2];
|
|
31
|
-
const now = Date.now();
|
|
32
|
-
let offsetMs = 0;
|
|
33
|
-
|
|
34
|
-
switch (unit) {
|
|
35
|
-
case "s":
|
|
36
|
-
offsetMs = value * 1000;
|
|
37
|
-
break;
|
|
38
|
-
case "m":
|
|
39
|
-
offsetMs = value * 60 * 1000;
|
|
40
|
-
break;
|
|
41
|
-
case "h":
|
|
42
|
-
offsetMs = value * 60 * 60 * 1000;
|
|
43
|
-
break;
|
|
44
|
-
case "d":
|
|
45
|
-
offsetMs = value * 24 * 60 * 60 * 1000;
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return new Date(now - offsetMs);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Not a relative format, treat as ISO 8601
|
|
53
|
-
return new Date(timeStr);
|
|
54
|
-
}
|
|
21
|
+
import { parseRelativeTime } from "../utils/time.ts";
|
|
55
22
|
|
|
56
23
|
/**
|
|
57
24
|
* Build a detail string for a log event based on its data.
|
|
25
|
+
* @internal Exported for testing.
|
|
58
26
|
*/
|
|
59
|
-
function buildLogDetail(event: LogEvent): string {
|
|
27
|
+
export function buildLogDetail(event: LogEvent): string {
|
|
60
28
|
const parts: string[] = [];
|
|
61
29
|
|
|
62
30
|
for (const [key, value] of Object.entries(event.data)) {
|
|
@@ -74,8 +42,9 @@ function buildLogDetail(event: LogEvent): string {
|
|
|
74
42
|
/**
|
|
75
43
|
* Discover all events.ndjson files in the logs directory.
|
|
76
44
|
* Returns array of { agentName, sessionTimestamp, path }.
|
|
45
|
+
* @internal Exported for testing.
|
|
77
46
|
*/
|
|
78
|
-
async function discoverLogFiles(
|
|
47
|
+
export async function discoverLogFiles(
|
|
79
48
|
logsDir: string,
|
|
80
49
|
agentFilter?: string,
|
|
81
50
|
): Promise<
|
|
@@ -145,8 +114,9 @@ async function discoverLogFiles(
|
|
|
145
114
|
/**
|
|
146
115
|
* Parse a single NDJSON file and return log events.
|
|
147
116
|
* Silently skips invalid lines.
|
|
117
|
+
* @internal Exported for testing.
|
|
148
118
|
*/
|
|
149
|
-
async function parseLogFile(path: string): Promise<LogEvent[]> {
|
|
119
|
+
export async function parseLogFile(path: string): Promise<LogEvent[]> {
|
|
150
120
|
const events: LogEvent[] = [];
|
|
151
121
|
|
|
152
122
|
try {
|
|
@@ -184,8 +154,9 @@ async function parseLogFile(path: string): Promise<LogEvent[]> {
|
|
|
184
154
|
|
|
185
155
|
/**
|
|
186
156
|
* Apply filters to log events.
|
|
157
|
+
* @internal Exported for testing.
|
|
187
158
|
*/
|
|
188
|
-
function filterEvents(
|
|
159
|
+
export function filterEvents(
|
|
189
160
|
events: LogEvent[],
|
|
190
161
|
filters: {
|
|
191
162
|
level?: string;
|
|
@@ -255,6 +226,95 @@ function printLogs(events: LogEvent[]): void {
|
|
|
255
226
|
}
|
|
256
227
|
}
|
|
257
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Process one poll tick of log tailing: scan discovered log files for new data
|
|
231
|
+
* since lastKnownSizes, parse new lines, apply filters, and return the count
|
|
232
|
+
* of new events found.
|
|
233
|
+
* @internal Exported for testing.
|
|
234
|
+
*
|
|
235
|
+
* @param logFiles - Array of discovered log file paths
|
|
236
|
+
* @param lastKnownSizes - Map of file path to last-seen byte offset (mutated in place)
|
|
237
|
+
* @param filters - Level filter to apply
|
|
238
|
+
* @returns Number of new events emitted
|
|
239
|
+
*/
|
|
240
|
+
export async function pollLogTick(
|
|
241
|
+
logFiles: Array<{ path: string }>,
|
|
242
|
+
lastKnownSizes: Map<string, number>,
|
|
243
|
+
filters: { level?: string },
|
|
244
|
+
): Promise<number> {
|
|
245
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
246
|
+
let emitted = 0;
|
|
247
|
+
|
|
248
|
+
for (const { path } of logFiles) {
|
|
249
|
+
const file = Bun.file(path);
|
|
250
|
+
let fileSize: number;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const fileStat = await stat(path);
|
|
254
|
+
fileSize = fileStat.size;
|
|
255
|
+
} catch {
|
|
256
|
+
continue; // File disappeared
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const lastPosition = lastKnownSizes.get(path) ?? 0;
|
|
260
|
+
|
|
261
|
+
if (fileSize > lastPosition) {
|
|
262
|
+
// New data available
|
|
263
|
+
try {
|
|
264
|
+
const fullText = await file.text();
|
|
265
|
+
const newText = fullText.slice(lastPosition);
|
|
266
|
+
const lines = newText.split("\n");
|
|
267
|
+
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (line.trim() === "") {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const parsed: unknown = JSON.parse(line);
|
|
275
|
+
if (
|
|
276
|
+
typeof parsed === "object" &&
|
|
277
|
+
parsed !== null &&
|
|
278
|
+
"timestamp" in parsed &&
|
|
279
|
+
"event" in parsed
|
|
280
|
+
) {
|
|
281
|
+
const event = parsed as LogEvent;
|
|
282
|
+
|
|
283
|
+
// Apply level filter
|
|
284
|
+
if (filters.level !== undefined && event.level !== filters.level) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Print immediately
|
|
289
|
+
const time = formatAbsoluteTime(event.timestamp);
|
|
290
|
+
const levelColorFn = logLevelColor(event.level);
|
|
291
|
+
const levelStr = logLevelLabel(event.level);
|
|
292
|
+
|
|
293
|
+
const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
|
|
294
|
+
const detail = buildLogDetail(event);
|
|
295
|
+
const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
|
|
296
|
+
|
|
297
|
+
w(
|
|
298
|
+
`${time} ${levelColorFn(levelStr)} ` +
|
|
299
|
+
`${event.event} ${color.dim(agentLabel)}${detailSuffix}\n`,
|
|
300
|
+
);
|
|
301
|
+
emitted++;
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
// Invalid JSON line, skip
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
lastKnownSizes.set(path, fileSize);
|
|
309
|
+
} catch {
|
|
310
|
+
// File read error, skip
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return emitted;
|
|
316
|
+
}
|
|
317
|
+
|
|
258
318
|
/**
|
|
259
319
|
* Follow mode: tail logs in real time.
|
|
260
320
|
*/
|
|
@@ -274,72 +334,7 @@ async function followLogs(
|
|
|
274
334
|
|
|
275
335
|
while (true) {
|
|
276
336
|
const discovered = await discoverLogFiles(logsDir, filters.agent);
|
|
277
|
-
|
|
278
|
-
for (const { path } of discovered) {
|
|
279
|
-
const file = Bun.file(path);
|
|
280
|
-
let fileSize: number;
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
const fileStat = await stat(path);
|
|
284
|
-
fileSize = fileStat.size;
|
|
285
|
-
} catch {
|
|
286
|
-
continue; // File disappeared
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const lastPosition = filePositions.get(path) ?? 0;
|
|
290
|
-
|
|
291
|
-
if (fileSize > lastPosition) {
|
|
292
|
-
// New data available
|
|
293
|
-
try {
|
|
294
|
-
const fullText = await file.text();
|
|
295
|
-
const newText = fullText.slice(lastPosition);
|
|
296
|
-
const lines = newText.split("\n");
|
|
297
|
-
|
|
298
|
-
for (const line of lines) {
|
|
299
|
-
if (line.trim() === "") {
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
const parsed: unknown = JSON.parse(line);
|
|
305
|
-
if (
|
|
306
|
-
typeof parsed === "object" &&
|
|
307
|
-
parsed !== null &&
|
|
308
|
-
"timestamp" in parsed &&
|
|
309
|
-
"event" in parsed
|
|
310
|
-
) {
|
|
311
|
-
const event = parsed as LogEvent;
|
|
312
|
-
|
|
313
|
-
// Apply level filter
|
|
314
|
-
if (filters.level !== undefined && event.level !== filters.level) {
|
|
315
|
-
continue;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Print immediately
|
|
319
|
-
const time = formatAbsoluteTime(event.timestamp);
|
|
320
|
-
const levelColorFn = logLevelColor(event.level);
|
|
321
|
-
const levelStr = logLevelLabel(event.level);
|
|
322
|
-
|
|
323
|
-
const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
|
|
324
|
-
const detail = buildLogDetail(event);
|
|
325
|
-
const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
|
|
326
|
-
|
|
327
|
-
w(
|
|
328
|
-
`${time} ${levelColorFn(levelStr)} ` +
|
|
329
|
-
`${event.event} ${color.dim(agentLabel)}${detailSuffix}\n`,
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
} catch {
|
|
333
|
-
// Invalid JSON line, skip
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
filePositions.set(path, fileSize);
|
|
338
|
-
} catch {
|
|
339
|
-
// File read error, skip
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
337
|
+
await pollLogTick(discovered, filePositions, { level: filters.level });
|
|
343
338
|
|
|
344
339
|
// Sleep for 1 second before next poll
|
|
345
340
|
await Bun.sleep(1000);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
type CoordinatorDeps,
|
|
4
|
+
createPersistentAgentCommand,
|
|
5
|
+
type PersistentAgentSpec,
|
|
6
|
+
persistentAgentCommand,
|
|
7
|
+
} from "./coordinator.ts";
|
|
8
|
+
|
|
9
|
+
const ORCHESTRATOR_SPEC: PersistentAgentSpec = {
|
|
10
|
+
commandName: "orchestrator",
|
|
11
|
+
displayName: "Orchestrator",
|
|
12
|
+
agentName: "orchestrator",
|
|
13
|
+
capability: "orchestrator",
|
|
14
|
+
agentDefFile: "orchestrator.md",
|
|
15
|
+
beaconBuilder: buildOrchestratorBeacon,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the startup beacon for the ecosystem-level orchestrator session.
|
|
20
|
+
*/
|
|
21
|
+
export function buildOrchestratorBeacon(cliName = "bd"): string {
|
|
22
|
+
const timestamp = new Date().toISOString();
|
|
23
|
+
const parts = [
|
|
24
|
+
`[OVERSTORY] orchestrator (orchestrator) ${timestamp}`,
|
|
25
|
+
"Depth: 0 | Parent: none | Role: persistent ecosystem orchestrator",
|
|
26
|
+
"HIERARCHY: You start per-repo coordinators with ov coordinator start --project <path>. Do NOT use ov sling directly.",
|
|
27
|
+
"DELEGATION: Work flows through sub-repo coordinators. Dispatch objectives by mail, then monitor coordinator progress and escalate only when needed.",
|
|
28
|
+
`Startup: run mulch prime, check mail (ov mail check --agent orchestrator), check ${cliName} ready, inspect ecosystem status, then begin coordination`,
|
|
29
|
+
];
|
|
30
|
+
return parts.join(" — ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createOrchestratorCommand(deps: CoordinatorDeps = {}): Command {
|
|
34
|
+
return createPersistentAgentCommand(ORCHESTRATOR_SPEC, deps);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function orchestratorCommand(
|
|
38
|
+
args: string[],
|
|
39
|
+
deps: CoordinatorDeps = {},
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
await persistentAgentCommand(args, ORCHESTRATOR_SPEC, deps);
|
|
42
|
+
}
|
|
@@ -3,8 +3,8 @@ import { mkdir, mkdtemp } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
6
|
-
import type { AgentSession } from "../types.ts";
|
|
7
|
-
import { primeCommand } from "./prime.ts";
|
|
6
|
+
import type { AgentManifest, AgentSession, SessionMetrics } from "../types.ts";
|
|
7
|
+
import { formatManifest, formatMetrics, primeCommand } from "./prime.ts";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Tests for `overstory prime` command.
|
|
@@ -435,3 +435,178 @@ sessions.db
|
|
|
435
435
|
});
|
|
436
436
|
});
|
|
437
437
|
});
|
|
438
|
+
|
|
439
|
+
describe("formatManifest", () => {
|
|
440
|
+
test("returns 'No agents registered.' for empty agents record", () => {
|
|
441
|
+
const manifest: AgentManifest = {
|
|
442
|
+
version: "1",
|
|
443
|
+
agents: {},
|
|
444
|
+
capabilityIndex: {},
|
|
445
|
+
};
|
|
446
|
+
expect(formatManifest(manifest)).toBe("No agents registered.");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("formats single agent with capabilities", () => {
|
|
450
|
+
const manifest: AgentManifest = {
|
|
451
|
+
version: "1",
|
|
452
|
+
agents: {
|
|
453
|
+
scout: {
|
|
454
|
+
file: "agents/scout.md",
|
|
455
|
+
model: "sonnet",
|
|
456
|
+
tools: ["Read", "Glob"],
|
|
457
|
+
capabilities: ["explore", "analyze"],
|
|
458
|
+
canSpawn: false,
|
|
459
|
+
constraints: [],
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
capabilityIndex: { explore: ["scout"], analyze: ["scout"] },
|
|
463
|
+
};
|
|
464
|
+
const result = formatManifest(manifest);
|
|
465
|
+
expect(result).toContain("**scout**");
|
|
466
|
+
expect(result).toContain("[sonnet]");
|
|
467
|
+
expect(result).toContain("explore, analyze");
|
|
468
|
+
expect(result).not.toContain("(can spawn)");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("marks agents that can spawn", () => {
|
|
472
|
+
const manifest: AgentManifest = {
|
|
473
|
+
version: "1",
|
|
474
|
+
agents: {
|
|
475
|
+
lead: {
|
|
476
|
+
file: "agents/lead.md",
|
|
477
|
+
model: "opus",
|
|
478
|
+
tools: ["Read", "Bash"],
|
|
479
|
+
capabilities: ["coordinate"],
|
|
480
|
+
canSpawn: true,
|
|
481
|
+
constraints: [],
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
capabilityIndex: { coordinate: ["lead"] },
|
|
485
|
+
};
|
|
486
|
+
const result = formatManifest(manifest);
|
|
487
|
+
expect(result).toContain("(can spawn)");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("formats multiple agents as separate lines", () => {
|
|
491
|
+
const manifest: AgentManifest = {
|
|
492
|
+
version: "1",
|
|
493
|
+
agents: {
|
|
494
|
+
scout: {
|
|
495
|
+
file: "agents/scout.md",
|
|
496
|
+
model: "sonnet",
|
|
497
|
+
tools: [],
|
|
498
|
+
capabilities: ["explore"],
|
|
499
|
+
canSpawn: false,
|
|
500
|
+
constraints: [],
|
|
501
|
+
},
|
|
502
|
+
builder: {
|
|
503
|
+
file: "agents/builder.md",
|
|
504
|
+
model: "opus",
|
|
505
|
+
tools: [],
|
|
506
|
+
capabilities: ["implement"],
|
|
507
|
+
canSpawn: false,
|
|
508
|
+
constraints: [],
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
capabilityIndex: {},
|
|
512
|
+
};
|
|
513
|
+
const result = formatManifest(manifest);
|
|
514
|
+
const lines = result.split("\n");
|
|
515
|
+
expect(lines).toHaveLength(2);
|
|
516
|
+
expect(lines[0]).toContain("scout");
|
|
517
|
+
expect(lines[1]).toContain("builder");
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("formatMetrics", () => {
|
|
522
|
+
test("returns 'No recent sessions.' for empty array", () => {
|
|
523
|
+
expect(formatMetrics([])).toBe("No recent sessions.");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("formats a completed session with duration and merge result", () => {
|
|
527
|
+
const sessions: SessionMetrics[] = [
|
|
528
|
+
{
|
|
529
|
+
agentName: "builder-1",
|
|
530
|
+
taskId: "task-001",
|
|
531
|
+
capability: "builder",
|
|
532
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
533
|
+
completedAt: "2026-01-01T00:05:00Z",
|
|
534
|
+
durationMs: 300_000,
|
|
535
|
+
exitCode: 0,
|
|
536
|
+
mergeResult: "clean-merge",
|
|
537
|
+
parentAgent: "coordinator",
|
|
538
|
+
inputTokens: 0,
|
|
539
|
+
outputTokens: 0,
|
|
540
|
+
cacheReadTokens: 0,
|
|
541
|
+
cacheCreationTokens: 0,
|
|
542
|
+
estimatedCostUsd: null,
|
|
543
|
+
modelUsed: null,
|
|
544
|
+
runId: null,
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
const result = formatMetrics(sessions);
|
|
548
|
+
expect(result).toContain("builder-1");
|
|
549
|
+
expect(result).toContain("(builder)");
|
|
550
|
+
expect(result).toContain("task-001");
|
|
551
|
+
expect(result).toContain("completed");
|
|
552
|
+
expect(result).toContain("(300s)");
|
|
553
|
+
expect(result).toContain("[clean-merge]");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("formats an in-progress session without duration or merge result", () => {
|
|
557
|
+
const sessions: SessionMetrics[] = [
|
|
558
|
+
{
|
|
559
|
+
agentName: "scout-1",
|
|
560
|
+
taskId: "task-002",
|
|
561
|
+
capability: "scout",
|
|
562
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
563
|
+
completedAt: null,
|
|
564
|
+
durationMs: 0,
|
|
565
|
+
exitCode: null,
|
|
566
|
+
mergeResult: null,
|
|
567
|
+
parentAgent: null,
|
|
568
|
+
inputTokens: 0,
|
|
569
|
+
outputTokens: 0,
|
|
570
|
+
cacheReadTokens: 0,
|
|
571
|
+
cacheCreationTokens: 0,
|
|
572
|
+
estimatedCostUsd: null,
|
|
573
|
+
modelUsed: null,
|
|
574
|
+
runId: null,
|
|
575
|
+
},
|
|
576
|
+
];
|
|
577
|
+
const result = formatMetrics(sessions);
|
|
578
|
+
expect(result).toContain("scout-1");
|
|
579
|
+
expect(result).toContain("in-progress");
|
|
580
|
+
expect(result).not.toContain("[");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("formats multiple sessions as separate lines", () => {
|
|
584
|
+
const base: SessionMetrics = {
|
|
585
|
+
agentName: "builder-1",
|
|
586
|
+
taskId: "task-001",
|
|
587
|
+
capability: "builder",
|
|
588
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
589
|
+
completedAt: "2026-01-01T00:05:00Z",
|
|
590
|
+
durationMs: 300_000,
|
|
591
|
+
exitCode: 0,
|
|
592
|
+
mergeResult: null,
|
|
593
|
+
parentAgent: null,
|
|
594
|
+
inputTokens: 0,
|
|
595
|
+
outputTokens: 0,
|
|
596
|
+
cacheReadTokens: 0,
|
|
597
|
+
cacheCreationTokens: 0,
|
|
598
|
+
estimatedCostUsd: null,
|
|
599
|
+
modelUsed: null,
|
|
600
|
+
runId: null,
|
|
601
|
+
};
|
|
602
|
+
const sessions: SessionMetrics[] = [
|
|
603
|
+
base,
|
|
604
|
+
{ ...base, agentName: "builder-2", taskId: "task-002" },
|
|
605
|
+
];
|
|
606
|
+
const result = formatMetrics(sessions);
|
|
607
|
+
const lines = result.split("\n");
|
|
608
|
+
expect(lines).toHaveLength(2);
|
|
609
|
+
expect(lines[0]).toContain("builder-1");
|
|
610
|
+
expect(lines[1]).toContain("builder-2");
|
|
611
|
+
});
|
|
612
|
+
});
|
package/src/commands/prime.ts
CHANGED
|
@@ -31,8 +31,9 @@ export interface PrimeOptions {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Format the agent manifest section for output.
|
|
34
|
+
* @internal Exported for testing.
|
|
34
35
|
*/
|
|
35
|
-
function formatManifest(manifest: AgentManifest): string {
|
|
36
|
+
export function formatManifest(manifest: AgentManifest): string {
|
|
36
37
|
const lines: string[] = [];
|
|
37
38
|
for (const [name, def] of Object.entries(manifest.agents)) {
|
|
38
39
|
const caps = def.capabilities.join(", ");
|
|
@@ -44,8 +45,9 @@ function formatManifest(manifest: AgentManifest): string {
|
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Format recent session metrics for output.
|
|
48
|
+
* @internal Exported for testing.
|
|
47
49
|
*/
|
|
48
|
-
function formatMetrics(sessions: SessionMetrics[]): string {
|
|
50
|
+
export function formatMetrics(sessions: SessionMetrics[]): string {
|
|
49
51
|
if (sessions.length === 0) {
|
|
50
52
|
return "No recent sessions.";
|
|
51
53
|
}
|
package/src/commands/sling.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { join, resolve } from "node:path";
|
|
|
24
24
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
25
25
|
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
26
26
|
import { writeOverlay } from "../agents/overlay.ts";
|
|
27
|
+
import { createCanopyClient } from "../canopy/client.ts";
|
|
27
28
|
import { loadConfig } from "../config.ts";
|
|
28
29
|
import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
|
|
29
30
|
import { inferDomain } from "../insights/analyzer.ts";
|
|
@@ -153,6 +154,7 @@ export interface SlingOptions {
|
|
|
153
154
|
runtime?: string;
|
|
154
155
|
noScoutCheck?: boolean;
|
|
155
156
|
baseBranch?: string;
|
|
157
|
+
profile?: string;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
export interface AutoDispatchOptions {
|
|
@@ -369,11 +371,11 @@ export function checkParentAgentLimit(
|
|
|
369
371
|
}
|
|
370
372
|
|
|
371
373
|
/**
|
|
372
|
-
* Validate hierarchy constraints
|
|
374
|
+
* Validate hierarchy constraints for direct coordinator/human spawns.
|
|
373
375
|
*
|
|
374
376
|
* When parentAgent is null, the caller is the coordinator or a human.
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
+
* Direct spawns are allowed for "lead", "scout", and "builder".
|
|
378
|
+
* Other capabilities (reviewer, merger, etc.) must be spawned by a lead
|
|
377
379
|
* that passes --parent.
|
|
378
380
|
*
|
|
379
381
|
* @param parentAgent - The --parent flag value (null = coordinator/human)
|
|
@@ -784,6 +786,23 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
784
786
|
}
|
|
785
787
|
}
|
|
786
788
|
|
|
789
|
+
// 8b. Resolve canopy profile if specified
|
|
790
|
+
const profileName =
|
|
791
|
+
opts.profile ?? process.env.OVERSTORY_PROFILE ?? config.project.defaultProfile;
|
|
792
|
+
let profileContent: string | undefined;
|
|
793
|
+
if (profileName) {
|
|
794
|
+
try {
|
|
795
|
+
const canopy = createCanopyClient(config.project.root);
|
|
796
|
+
const rendered = await canopy.render(profileName);
|
|
797
|
+
if (rendered.success && rendered.sections.length > 0) {
|
|
798
|
+
profileContent = rendered.sections.map((s) => s.body).join("\n\n");
|
|
799
|
+
}
|
|
800
|
+
} catch {
|
|
801
|
+
// Non-fatal: canopy may not be installed or profile may not exist
|
|
802
|
+
profileContent = undefined;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
787
806
|
// Resolve runtime before overlayConfig so we can pass runtime.instructionPath
|
|
788
807
|
const runtime = getRuntime(opts.runtime, config, capability);
|
|
789
808
|
|
|
@@ -802,6 +821,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
802
821
|
canSpawn: agentDef.canSpawn,
|
|
803
822
|
capability,
|
|
804
823
|
baseDefinition,
|
|
824
|
+
profileContent,
|
|
805
825
|
mulchExpertise,
|
|
806
826
|
skipScout: skipScout && capability === "lead",
|
|
807
827
|
skipReview: opts.skipReview === true && capability === "lead",
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Command } from "commander";
|
|
11
11
|
import { jsonError, jsonOutput } from "../json.ts";
|
|
12
12
|
import { muted, printError, printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
13
|
+
import { fetchLatestVersion, getCurrentVersion } from "../utils/version.ts";
|
|
13
14
|
|
|
14
15
|
const OVERSTORY_PACKAGE = "@os-eco/overstory-cli";
|
|
15
16
|
|
|
@@ -26,23 +27,6 @@ export interface UpgradeOptions {
|
|
|
26
27
|
json?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
async function getCurrentVersion(): Promise<string> {
|
|
30
|
-
const pkgPath = new URL("../../package.json", import.meta.url);
|
|
31
|
-
const pkg = JSON.parse(await Bun.file(pkgPath).text()) as { version: string };
|
|
32
|
-
return pkg.version;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function fetchLatestVersion(packageName: string): Promise<string> {
|
|
36
|
-
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
37
|
-
if (!res.ok) {
|
|
38
|
-
throw new Error(
|
|
39
|
-
`Failed to fetch npm registry for ${packageName}: ${res.status} ${res.statusText}`,
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
const data = (await res.json()) as { version: string };
|
|
43
|
-
return data.version;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
30
|
async function runInstall(packageName: string): Promise<number> {
|
|
47
31
|
const proc = Bun.spawn(["bun", "install", "-g", `${packageName}@latest`], {
|
|
48
32
|
stdout: "inherit",
|
|
@@ -3,7 +3,8 @@ import { mkdtemp } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
6
|
-
import {
|
|
6
|
+
import type { HealthCheck } from "../types.ts";
|
|
7
|
+
import { formatCheck, watchCommand } from "./watch.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Tests for `overstory watch` command.
|
|
@@ -144,3 +145,68 @@ describe("watchCommand", () => {
|
|
|
144
145
|
// If it doesn't exist, that's also valid (spawn failed before writing new PID)
|
|
145
146
|
});
|
|
146
147
|
});
|
|
148
|
+
|
|
149
|
+
describe("formatCheck", () => {
|
|
150
|
+
function makeCheck(overrides: Partial<HealthCheck>): HealthCheck {
|
|
151
|
+
return {
|
|
152
|
+
agentName: "test-agent",
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
processAlive: true,
|
|
155
|
+
tmuxAlive: true,
|
|
156
|
+
pidAlive: true,
|
|
157
|
+
lastActivity: new Date().toISOString(),
|
|
158
|
+
state: "working",
|
|
159
|
+
action: "none",
|
|
160
|
+
reconciliationNote: null,
|
|
161
|
+
...overrides,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("terminate action uses x icon", () => {
|
|
166
|
+
const result = formatCheck(makeCheck({ action: "terminate" }));
|
|
167
|
+
expect(result).toMatch(/^x /);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("escalate action uses ! icon", () => {
|
|
171
|
+
const result = formatCheck(makeCheck({ action: "escalate" }));
|
|
172
|
+
expect(result).toMatch(/^! /);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("investigate action uses > icon", () => {
|
|
176
|
+
const result = formatCheck(makeCheck({ action: "investigate" }));
|
|
177
|
+
expect(result).toMatch(/^> /);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("pidAlive true shows up", () => {
|
|
181
|
+
const result = formatCheck(makeCheck({ pidAlive: true }));
|
|
182
|
+
expect(result).toContain("pid=up");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("pidAlive false shows down", () => {
|
|
186
|
+
const result = formatCheck(makeCheck({ pidAlive: false }));
|
|
187
|
+
expect(result).toContain("pid=down");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("pidAlive null shows n/a", () => {
|
|
191
|
+
const result = formatCheck(makeCheck({ pidAlive: null }));
|
|
192
|
+
expect(result).toContain("pid=n/a");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("includes reconciliation note when present", () => {
|
|
196
|
+
const result = formatCheck(makeCheck({ reconciliationNote: "stale session" }));
|
|
197
|
+
expect(result).toContain("[stale session]");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("no reconciliation note brackets when null", () => {
|
|
201
|
+
const result = formatCheck(makeCheck({ reconciliationNote: null }));
|
|
202
|
+
expect(result).not.toContain("[");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("includes agent name and state", () => {
|
|
206
|
+
const result = formatCheck(makeCheck({ agentName: "builder-1", state: "stalled" }));
|
|
207
|
+
expect(result).toContain("builder-1");
|
|
208
|
+
expect(result).toContain("stalled");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// PID and bin utility tests moved to src/utils/pid.test.ts and src/utils/bin.test.ts
|