@os-eco/overstory-cli 0.7.0 → 0.7.3
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 +7 -6
- package/agents/builder.md +1 -1
- package/agents/coordinator.md +12 -11
- package/agents/lead.md +6 -6
- package/agents/monitor.md +4 -4
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +5 -5
- package/agents/supervisor.md +36 -32
- package/package.json +1 -1
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/hooks-deployer.test.ts +6 -5
- package/src/agents/hooks-deployer.ts +7 -90
- package/src/agents/identity.test.ts +3 -2
- package/src/agents/manifest.test.ts +4 -3
- package/src/agents/overlay.test.ts +10 -9
- package/src/agents/overlay.ts +5 -5
- package/src/commands/agents.test.ts +10 -4
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/completions.test.ts +8 -5
- package/src/commands/completions.ts +38 -2
- package/src/commands/coordinator.test.ts +1 -0
- package/src/commands/coordinator.ts +15 -11
- package/src/commands/costs.test.ts +9 -3
- package/src/commands/dashboard.test.ts +265 -6
- package/src/commands/dashboard.ts +367 -64
- package/src/commands/doctor.test.ts +3 -2
- package/src/commands/errors.test.ts +3 -2
- package/src/commands/feed.test.ts +3 -2
- package/src/commands/feed.ts +2 -29
- package/src/commands/init.test.ts +1 -2
- package/src/commands/init.ts +1 -8
- package/src/commands/inspect.test.ts +17 -2
- package/src/commands/log.test.ts +262 -8
- package/src/commands/log.ts +232 -110
- package/src/commands/logs.test.ts +3 -2
- package/src/commands/mail.test.ts +8 -2
- package/src/commands/metrics.test.ts +4 -3
- package/src/commands/monitor.ts +15 -11
- package/src/commands/nudge.test.ts +4 -2
- package/src/commands/prime.test.ts +4 -2
- package/src/commands/prime.ts +6 -2
- package/src/commands/replay.test.ts +3 -2
- package/src/commands/run.test.ts +3 -1
- package/src/commands/sling.test.ts +142 -1
- package/src/commands/sling.ts +145 -24
- package/src/commands/status.test.ts +9 -8
- package/src/commands/stop.test.ts +1 -0
- package/src/commands/supervisor.ts +19 -12
- package/src/commands/trace.test.ts +4 -2
- package/src/commands/watch.test.ts +3 -2
- package/src/commands/worktree.test.ts +9 -0
- package/src/config.test.ts +3 -3
- package/src/config.ts +29 -0
- package/src/doctor/agents.test.ts +3 -2
- package/src/doctor/consistency.test.ts +14 -0
- package/src/doctor/logs.test.ts +3 -2
- package/src/doctor/structure.test.ts +3 -2
- package/src/e2e/init-sling-lifecycle.test.ts +3 -5
- package/src/index.ts +3 -1
- package/src/logging/color.ts +1 -1
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +42 -1
- package/src/logging/logger.test.ts +3 -2
- package/src/mail/broadcast.test.ts +1 -0
- package/src/mail/client.test.ts +3 -2
- package/src/mail/store.test.ts +3 -2
- package/src/merge/queue.test.ts +3 -2
- package/src/merge/resolver.test.ts +39 -0
- package/src/merge/resolver.ts +24 -5
- package/src/mulch/client.test.ts +63 -2
- package/src/mulch/client.ts +62 -1
- package/src/runtimes/claude.test.ts +5 -4
- package/src/runtimes/pi-guards.test.ts +457 -0
- package/src/runtimes/pi-guards.ts +349 -0
- package/src/runtimes/pi.test.ts +620 -0
- package/src/runtimes/pi.ts +244 -0
- package/src/runtimes/registry.test.ts +33 -0
- package/src/runtimes/registry.ts +15 -2
- package/src/runtimes/types.ts +63 -0
- package/src/schema-consistency.test.ts +5 -2
- package/src/sessions/compat.test.ts +3 -2
- package/src/sessions/compat.ts +1 -0
- package/src/sessions/store.test.ts +34 -2
- package/src/sessions/store.ts +37 -4
- package/src/test-helpers.ts +20 -1
- package/src/types.ts +17 -0
- package/src/watchdog/daemon.test.ts +11 -7
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -0
- package/src/watchdog/triage.test.ts +3 -2
- package/src/watchdog/triage.ts +14 -4
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
-
import { mkdtemp
|
|
12
|
+
import { mkdtemp } from "node:fs/promises";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
16
16
|
import { createEventStore } from "../events/store.ts";
|
|
17
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
17
18
|
import type { InsertEvent } from "../types.ts";
|
|
18
19
|
import { replayCommand } from "./replay.ts";
|
|
19
20
|
|
|
@@ -64,7 +65,7 @@ describe("replayCommand", () => {
|
|
|
64
65
|
afterEach(async () => {
|
|
65
66
|
process.stdout.write = originalWrite;
|
|
66
67
|
process.chdir(originalCwd);
|
|
67
|
-
await
|
|
68
|
+
await cleanupTempDir(tempDir);
|
|
68
69
|
});
|
|
69
70
|
|
|
70
71
|
function output(): string {
|
package/src/commands/run.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { SessionStore } from "../sessions/store.ts";
|
|
13
13
|
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
14
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
14
15
|
import type { AgentSession, InsertRun, RunStore } from "../types.ts";
|
|
15
16
|
|
|
16
17
|
let tempDir: string;
|
|
@@ -31,7 +32,7 @@ beforeEach(async () => {
|
|
|
31
32
|
afterEach(async () => {
|
|
32
33
|
runStore.close();
|
|
33
34
|
sessionStore.close();
|
|
34
|
-
await
|
|
35
|
+
await cleanupTempDir(tempDir);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
/** Write a run ID to current-run.txt. */
|
|
@@ -79,6 +80,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
|
79
80
|
lastActivity: "2026-02-13T10:30:00.000Z",
|
|
80
81
|
escalationLevel: 0,
|
|
81
82
|
stalledSince: null,
|
|
83
|
+
transcriptPath: null,
|
|
82
84
|
...overrides,
|
|
83
85
|
};
|
|
84
86
|
}
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
checkParentAgentLimit,
|
|
15
15
|
checkRunSessionLimit,
|
|
16
16
|
checkTaskLock,
|
|
17
|
+
extractMulchRecordIds,
|
|
17
18
|
inferDomainsFromFiles,
|
|
18
19
|
isRunningAsRoot,
|
|
19
20
|
parentHasScouts,
|
|
21
|
+
shouldShowScoutWarning,
|
|
20
22
|
validateHierarchy,
|
|
21
23
|
} from "./sling.ts";
|
|
22
24
|
|
|
@@ -275,6 +277,65 @@ describe("parentHasScouts", () => {
|
|
|
275
277
|
});
|
|
276
278
|
});
|
|
277
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Tests for shouldShowScoutWarning (overstory-6eyw).
|
|
282
|
+
*
|
|
283
|
+
* shouldShowScoutWarning determines whether the "spawning builder without scouts"
|
|
284
|
+
* warning should be emitted. It is a pure function extracted from slingCommand
|
|
285
|
+
* so it can be suppressed via --no-scout-check or --skip-scout.
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
describe("shouldShowScoutWarning", () => {
|
|
289
|
+
function makeSession(
|
|
290
|
+
parentAgent: string | null,
|
|
291
|
+
capability: string,
|
|
292
|
+
): { parentAgent: string | null; capability: string } {
|
|
293
|
+
return { parentAgent, capability };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const withScout = [makeSession("lead-alpha", "scout"), makeSession("lead-alpha", "builder")];
|
|
297
|
+
const withoutScout = [makeSession("lead-alpha", "builder")];
|
|
298
|
+
const empty: { parentAgent: string | null; capability: string }[] = [];
|
|
299
|
+
|
|
300
|
+
test("returns true when builder has parent but no scouts", () => {
|
|
301
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, false, false)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("returns false when builder has parent and scouts exist", () => {
|
|
305
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withScout, false, false)).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("returns false when capability is not builder", () => {
|
|
309
|
+
expect(shouldShowScoutWarning("scout", "lead-alpha", empty, false, false)).toBe(false);
|
|
310
|
+
expect(shouldShowScoutWarning("reviewer", "lead-alpha", empty, false, false)).toBe(false);
|
|
311
|
+
expect(shouldShowScoutWarning("lead", "lead-alpha", empty, false, false)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("returns false when parentAgent is null (coordinator spawn)", () => {
|
|
315
|
+
expect(shouldShowScoutWarning("builder", null, withoutScout, false, false)).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("returns false when noScoutCheck is true (flag suppresses warning)", () => {
|
|
319
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, true, false)).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("returns false when skipScout is true (lead opted out of scouting)", () => {
|
|
323
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, false, true)).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns false when both noScoutCheck and skipScout are true", () => {
|
|
327
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", withoutScout, true, true)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("returns false with empty sessions and no parent", () => {
|
|
331
|
+
expect(shouldShowScoutWarning("builder", null, empty, false, false)).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("returns true with empty sessions and a parent (no scouts ever spawned)", () => {
|
|
335
|
+
expect(shouldShowScoutWarning("builder", "lead-alpha", empty, false, false)).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
278
339
|
/**
|
|
279
340
|
* Tests for hierarchy validation in sling.
|
|
280
341
|
*
|
|
@@ -369,6 +430,7 @@ function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
|
|
|
369
430
|
taskId: "overstory-abc",
|
|
370
431
|
parentAgent: null,
|
|
371
432
|
depth: 0,
|
|
433
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
372
434
|
...overrides,
|
|
373
435
|
};
|
|
374
436
|
}
|
|
@@ -409,12 +471,20 @@ describe("buildBeacon", () => {
|
|
|
409
471
|
const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "overstory-xyz" });
|
|
410
472
|
const beacon = buildBeacon(opts);
|
|
411
473
|
|
|
412
|
-
expect(beacon).toContain(
|
|
474
|
+
expect(beacon).toContain(`read ${opts.instructionPath}`);
|
|
413
475
|
expect(beacon).toContain("mulch prime");
|
|
414
476
|
expect(beacon).toContain("ov mail check --agent scout-1");
|
|
415
477
|
expect(beacon).toContain("begin task overstory-xyz");
|
|
416
478
|
});
|
|
417
479
|
|
|
480
|
+
test("uses custom instructionPath in startup instructions", () => {
|
|
481
|
+
const opts = makeBeaconOpts({ instructionPath: "AGENTS.md" });
|
|
482
|
+
const beacon = buildBeacon(opts);
|
|
483
|
+
|
|
484
|
+
expect(beacon).toContain("read AGENTS.md");
|
|
485
|
+
expect(beacon).not.toContain(".claude/CLAUDE.md");
|
|
486
|
+
});
|
|
487
|
+
|
|
418
488
|
test("uses agent name in mail check command", () => {
|
|
419
489
|
const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
|
|
420
490
|
|
|
@@ -1001,6 +1071,7 @@ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDis
|
|
|
1001
1071
|
capability: "builder",
|
|
1002
1072
|
specPath: "/path/to/spec.md",
|
|
1003
1073
|
parentAgent: "lead-alpha",
|
|
1074
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1004
1075
|
...overrides,
|
|
1005
1076
|
};
|
|
1006
1077
|
}
|
|
@@ -1013,6 +1084,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1013
1084
|
capability: "builder",
|
|
1014
1085
|
specPath: "/path/to/spec.md",
|
|
1015
1086
|
parentAgent: "lead-alpha",
|
|
1087
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1016
1088
|
});
|
|
1017
1089
|
expect(dispatch.from).toBe("lead-alpha");
|
|
1018
1090
|
expect(dispatch.to).toBe("builder-1");
|
|
@@ -1027,6 +1099,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1027
1099
|
capability: "lead",
|
|
1028
1100
|
specPath: null,
|
|
1029
1101
|
parentAgent: null,
|
|
1102
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1030
1103
|
});
|
|
1031
1104
|
expect(dispatch.from).toBe("orchestrator");
|
|
1032
1105
|
expect(dispatch.body).toContain("No spec file");
|
|
@@ -1039,6 +1112,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1039
1112
|
capability: "scout",
|
|
1040
1113
|
specPath: null,
|
|
1041
1114
|
parentAgent: "lead-alpha",
|
|
1115
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1042
1116
|
});
|
|
1043
1117
|
expect(dispatch.body).toContain("scout");
|
|
1044
1118
|
});
|
|
@@ -1050,6 +1124,7 @@ describe("buildAutoDispatch", () => {
|
|
|
1050
1124
|
capability: "builder",
|
|
1051
1125
|
specPath: "/abs/path/to/spec.md",
|
|
1052
1126
|
parentAgent: "lead-alpha",
|
|
1127
|
+
instructionPath: ".claude/CLAUDE.md",
|
|
1053
1128
|
});
|
|
1054
1129
|
expect(dispatch.body).toContain("/abs/path/to/spec.md");
|
|
1055
1130
|
});
|
|
@@ -1133,3 +1208,69 @@ describe("sling runtime integration", () => {
|
|
|
1133
1208
|
expect(state.phase).toBe("loading");
|
|
1134
1209
|
});
|
|
1135
1210
|
});
|
|
1211
|
+
|
|
1212
|
+
describe("extractMulchRecordIds", () => {
|
|
1213
|
+
test("returns empty array for empty string", () => {
|
|
1214
|
+
expect(extractMulchRecordIds("")).toEqual([]);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("returns empty when no mx-IDs present", () => {
|
|
1218
|
+
const text = "## agents (2 records)\n- convention without ID";
|
|
1219
|
+
expect(extractMulchRecordIds(text)).toEqual([]);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
test("extracts single ID from a domain", () => {
|
|
1223
|
+
const text = "## agents (1 records)\n- [convention] Some. (mx-abc123)";
|
|
1224
|
+
expect(extractMulchRecordIds(text)).toEqual([{ id: "mx-abc123", domain: "agents" }]);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test("extracts multiple IDs from same domain", () => {
|
|
1228
|
+
const text = ["## typescript", "- first. (mx-aaa111)", "- second. (mx-bbb222)"].join("\n");
|
|
1229
|
+
expect(extractMulchRecordIds(text)).toEqual([
|
|
1230
|
+
{ id: "mx-aaa111", domain: "typescript" },
|
|
1231
|
+
{ id: "mx-bbb222", domain: "typescript" },
|
|
1232
|
+
]);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("extracts IDs from multiple domains", () => {
|
|
1236
|
+
const text = ["## agents", "- agent. (mx-111aaa)", "## typescript", "- ts. (mx-222bbb)"].join(
|
|
1237
|
+
"\n",
|
|
1238
|
+
);
|
|
1239
|
+
expect(extractMulchRecordIds(text)).toEqual([
|
|
1240
|
+
{ id: "mx-111aaa", domain: "agents" },
|
|
1241
|
+
{ id: "mx-222bbb", domain: "typescript" },
|
|
1242
|
+
]);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("ignores non-domain headings with no mx-IDs", () => {
|
|
1246
|
+
const text = [
|
|
1247
|
+
"## Quick Reference",
|
|
1248
|
+
"- use mulch search",
|
|
1249
|
+
"## agents",
|
|
1250
|
+
"- real. (mx-deadbeef)",
|
|
1251
|
+
].join("\n");
|
|
1252
|
+
expect(extractMulchRecordIds(text)).toEqual([{ id: "mx-deadbeef", domain: "agents" }]);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test("deduplicates repeated pairs", () => {
|
|
1256
|
+
const text = ["## agents", "- first. (mx-aabbcc)", "- dup. (mx-aabbcc)"].join("\n");
|
|
1257
|
+
expect(extractMulchRecordIds(text)).toEqual([{ id: "mx-aabbcc", domain: "agents" }]);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
test("handles realistic ml prime output", () => {
|
|
1261
|
+
const text = [
|
|
1262
|
+
"## agents (3 records, updated just now)",
|
|
1263
|
+
"- [convention] lead.md convention. (mx-636708)",
|
|
1264
|
+
"- [convention] writeOverlay(). (mx-b7fa3d)",
|
|
1265
|
+
"## typescript (2 records, updated just now)",
|
|
1266
|
+
"- [convention] No any types. (mx-2ce43d)",
|
|
1267
|
+
"## Quick Reference",
|
|
1268
|
+
"- mulch search",
|
|
1269
|
+
].join("\n");
|
|
1270
|
+
const result = extractMulchRecordIds(text);
|
|
1271
|
+
expect(result).toHaveLength(3);
|
|
1272
|
+
expect(result).toContainEqual({ id: "mx-636708", domain: "agents" });
|
|
1273
|
+
expect(result).toContainEqual({ id: "mx-b7fa3d", domain: "agents" });
|
|
1274
|
+
expect(result).toContainEqual({ id: "mx-2ce43d", domain: "typescript" });
|
|
1275
|
+
});
|
|
1276
|
+
});
|
package/src/commands/sling.ts
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
|
|
21
21
|
import { mkdir } from "node:fs/promises";
|
|
22
22
|
import { join, resolve } from "node:path";
|
|
23
|
-
import { deployHooks } from "../agents/hooks-deployer.ts";
|
|
24
23
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
25
24
|
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
26
25
|
import { writeOverlay } from "../agents/overlay.ts";
|
|
@@ -124,6 +123,7 @@ export interface SlingOptions {
|
|
|
124
123
|
skipReview?: boolean;
|
|
125
124
|
dispatchMaxAgents?: string;
|
|
126
125
|
runtime?: string;
|
|
126
|
+
noScoutCheck?: boolean;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export interface AutoDispatchOptions {
|
|
@@ -132,6 +132,7 @@ export interface AutoDispatchOptions {
|
|
|
132
132
|
capability: string;
|
|
133
133
|
specPath: string | null;
|
|
134
134
|
parentAgent: string | null;
|
|
135
|
+
instructionPath: string;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
/**
|
|
@@ -154,7 +155,7 @@ export function buildAutoDispatch(opts: AutoDispatchOptions): {
|
|
|
154
155
|
const body = [
|
|
155
156
|
`You have been assigned task ${opts.taskId} as a ${opts.capability} agent.`,
|
|
156
157
|
specLine,
|
|
157
|
-
`Read your overlay at .
|
|
158
|
+
`Read your overlay at ${opts.instructionPath} and begin immediately.`,
|
|
158
159
|
].join(" ");
|
|
159
160
|
|
|
160
161
|
return {
|
|
@@ -174,6 +175,7 @@ export interface BeaconOptions {
|
|
|
174
175
|
taskId: string;
|
|
175
176
|
parentAgent: string | null;
|
|
176
177
|
depth: number;
|
|
178
|
+
instructionPath: string;
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
/**
|
|
@@ -198,7 +200,7 @@ export function buildBeacon(opts: BeaconOptions): string {
|
|
|
198
200
|
const parts = [
|
|
199
201
|
`[OVERSTORY] ${opts.agentName} (${opts.capability}) ${timestamp} task:${opts.taskId}`,
|
|
200
202
|
`Depth: ${opts.depth} | Parent: ${parent}`,
|
|
201
|
-
`Startup: read .
|
|
203
|
+
`Startup: read ${opts.instructionPath}, run mulch prime, check mail (ov mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
|
|
202
204
|
];
|
|
203
205
|
return parts.join(" — ");
|
|
204
206
|
}
|
|
@@ -214,6 +216,38 @@ export function parentHasScouts(
|
|
|
214
216
|
return sessions.some((s) => s.parentAgent === parentAgent && s.capability === "scout");
|
|
215
217
|
}
|
|
216
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Determine whether to emit the scout-before-build warning.
|
|
221
|
+
*
|
|
222
|
+
* Returns true when all of the following hold:
|
|
223
|
+
* - The incoming capability is "builder" (only builders trigger the check)
|
|
224
|
+
* - A parent agent is set (orphaned builders don't trigger it)
|
|
225
|
+
* - The parent has not yet spawned any scouts
|
|
226
|
+
* - noScoutCheck is false (caller has not suppressed the warning)
|
|
227
|
+
* - skipScout is false (the lead is not intentionally running without scouts)
|
|
228
|
+
*
|
|
229
|
+
* Extracted from slingCommand for testability (overstory-6eyw).
|
|
230
|
+
*
|
|
231
|
+
* @param capability - The requested agent capability
|
|
232
|
+
* @param parentAgent - The --parent flag value (null = coordinator/human)
|
|
233
|
+
* @param sessions - All sessions (not just active) for parentHasScouts query
|
|
234
|
+
* @param noScoutCheck - True when --no-scout-check flag is set
|
|
235
|
+
* @param skipScout - True when --skip-scout flag is set (lead opted out of scouting)
|
|
236
|
+
*/
|
|
237
|
+
export function shouldShowScoutWarning(
|
|
238
|
+
capability: string,
|
|
239
|
+
parentAgent: string | null,
|
|
240
|
+
sessions: ReadonlyArray<{ parentAgent: string | null; capability: string }>,
|
|
241
|
+
noScoutCheck: boolean,
|
|
242
|
+
skipScout: boolean,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (capability !== "builder") return false;
|
|
245
|
+
if (parentAgent === null) return false;
|
|
246
|
+
if (noScoutCheck) return false;
|
|
247
|
+
if (skipScout) return false;
|
|
248
|
+
return !parentHasScouts(sessions, parentAgent);
|
|
249
|
+
}
|
|
250
|
+
|
|
217
251
|
/**
|
|
218
252
|
* Check if any active agent is already working on the given task ID.
|
|
219
253
|
* Returns the agent name if locked, or null if the task is free.
|
|
@@ -289,7 +323,7 @@ export function checkParentAgentLimit(
|
|
|
289
323
|
*
|
|
290
324
|
* When parentAgent is null, the caller is the coordinator or a human.
|
|
291
325
|
* Only "lead" capability is allowed in that case. All other capabilities
|
|
292
|
-
* (builder, scout, reviewer, merger) must be spawned by a lead
|
|
326
|
+
* (builder, scout, reviewer, merger) must be spawned by a lead
|
|
293
327
|
* that passes --parent.
|
|
294
328
|
*
|
|
295
329
|
* @param parentAgent - The --parent flag value (null = coordinator/human)
|
|
@@ -318,6 +352,43 @@ export function validateHierarchy(
|
|
|
318
352
|
}
|
|
319
353
|
}
|
|
320
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Extract mulch record IDs and their domains from mulch prime output text.
|
|
357
|
+
* Parses the markdown structure produced by ml prime: domain headings
|
|
358
|
+
* (## <name>) followed by record lines containing (mx-XXXXXX) identifiers.
|
|
359
|
+
* @param primeText - The output text from ml prime
|
|
360
|
+
* @returns Array of {id, domain} pairs. Deduplicated.
|
|
361
|
+
*/
|
|
362
|
+
export function extractMulchRecordIds(primeText: string): Array<{ id: string; domain: string }> {
|
|
363
|
+
const results: Array<{ id: string; domain: string }> = [];
|
|
364
|
+
const seen = new Set<string>();
|
|
365
|
+
let currentDomain = "";
|
|
366
|
+
|
|
367
|
+
for (const line of primeText.split("\n")) {
|
|
368
|
+
const domainMatch = line.match(/^## ([\w-]+)/);
|
|
369
|
+
if (domainMatch) {
|
|
370
|
+
currentDomain = domainMatch[1] ?? "";
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (currentDomain) {
|
|
374
|
+
const idRegex = /\(mx-([a-f0-9]+)\)/g;
|
|
375
|
+
let match = idRegex.exec(line);
|
|
376
|
+
while (match !== null) {
|
|
377
|
+
const shortId = match[1] ?? "";
|
|
378
|
+
if (shortId) {
|
|
379
|
+
const key = `${currentDomain}:mx-${shortId}`;
|
|
380
|
+
if (!seen.has(key)) {
|
|
381
|
+
seen.add(key);
|
|
382
|
+
results.push({ id: `mx-${shortId}`, domain: currentDomain });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
match = idRegex.exec(line);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return results;
|
|
390
|
+
}
|
|
391
|
+
|
|
321
392
|
/**
|
|
322
393
|
* Entry point for `ov sling <task-id> [flags]`.
|
|
323
394
|
*
|
|
@@ -543,7 +614,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
543
614
|
// 5c. Structural enforcement: warn when a lead spawns a builder without prior scouts.
|
|
544
615
|
// This is a non-blocking warning — it does not prevent the spawn, but surfaces
|
|
545
616
|
// the scout-skip pattern so agents and operators can see it happening.
|
|
546
|
-
|
|
617
|
+
// Use --no-scout-check to suppress this warning when intentionally skipping scouts.
|
|
618
|
+
if (
|
|
619
|
+
shouldShowScoutWarning(
|
|
620
|
+
capability,
|
|
621
|
+
parentAgent,
|
|
622
|
+
store.getAll(),
|
|
623
|
+
opts.noScoutCheck ?? false,
|
|
624
|
+
skipScout,
|
|
625
|
+
)
|
|
626
|
+
) {
|
|
547
627
|
process.stderr.write(
|
|
548
628
|
`Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
|
|
549
629
|
);
|
|
@@ -595,7 +675,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
595
675
|
if (config.mulch.enabled && fileScope.length > 0) {
|
|
596
676
|
try {
|
|
597
677
|
const mulch = createMulchClient(config.project.root);
|
|
598
|
-
mulchExpertise = await mulch.prime(undefined, undefined, {
|
|
678
|
+
mulchExpertise = await mulch.prime(undefined, undefined, {
|
|
679
|
+
files: fileScope,
|
|
680
|
+
sortByScore: true,
|
|
681
|
+
});
|
|
599
682
|
} catch {
|
|
600
683
|
// Non-fatal: mulch expertise is supplementary context
|
|
601
684
|
mulchExpertise = undefined;
|
|
@@ -629,8 +712,11 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
629
712
|
trackerName: resolvedBackend,
|
|
630
713
|
};
|
|
631
714
|
|
|
715
|
+
// Resolve runtime before writeOverlay so we can pass runtime.instructionPath
|
|
716
|
+
const runtime = getRuntime(opts.runtime, config);
|
|
717
|
+
|
|
632
718
|
try {
|
|
633
|
-
await writeOverlay(worktreePath, overlayConfig, config.project.root);
|
|
719
|
+
await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
|
|
634
720
|
} catch (err) {
|
|
635
721
|
// Clean up the orphaned worktree created in step 7 (overstory-p4st)
|
|
636
722
|
try {
|
|
@@ -646,8 +732,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
646
732
|
throw err;
|
|
647
733
|
}
|
|
648
734
|
|
|
649
|
-
// 9.
|
|
650
|
-
|
|
735
|
+
// 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
|
|
736
|
+
const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
|
|
737
|
+
|
|
738
|
+
// 9a. Deploy hooks config (capability-specific guards)
|
|
739
|
+
await runtime.deployConfig(worktreePath, undefined, {
|
|
740
|
+
agentName: name,
|
|
741
|
+
capability,
|
|
742
|
+
worktreePath,
|
|
743
|
+
qualityGates: config.project.qualityGates,
|
|
744
|
+
});
|
|
651
745
|
|
|
652
746
|
// 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
|
|
653
747
|
// This eliminates the race where coordinator sends dispatch AFTER agent boots.
|
|
@@ -657,6 +751,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
657
751
|
capability,
|
|
658
752
|
specPath: absoluteSpecPath,
|
|
659
753
|
parentAgent,
|
|
754
|
+
instructionPath: runtime.instructionPath,
|
|
660
755
|
});
|
|
661
756
|
const mailStore = createMailStore(join(overstoryDir, "mail.db"));
|
|
662
757
|
try {
|
|
@@ -696,13 +791,27 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
696
791
|
});
|
|
697
792
|
}
|
|
698
793
|
|
|
699
|
-
// 11b.
|
|
794
|
+
// 11b. Save applied mulch record IDs for session-end outcome tracking.
|
|
795
|
+
// Written to .overstory/agents/{name}/applied-records.json so log.ts
|
|
796
|
+
// can append outcomes when the session completes.
|
|
797
|
+
if (mulchExpertise) {
|
|
798
|
+
const appliedRecords = extractMulchRecordIds(mulchExpertise);
|
|
799
|
+
if (appliedRecords.length > 0) {
|
|
800
|
+
const appliedRecordsPath = join(identityBaseDir, name, "applied-records.json");
|
|
801
|
+
const appliedData = { taskId, agentName: name, capability, records: appliedRecords };
|
|
802
|
+
try {
|
|
803
|
+
await Bun.write(appliedRecordsPath, `${JSON.stringify(appliedData, null, "\t")}\n`);
|
|
804
|
+
} catch {
|
|
805
|
+
// Non-fatal: outcome tracking is supplementary context
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
700
811
|
await ensureTmuxAvailable();
|
|
701
812
|
|
|
702
813
|
// 12. Create tmux session running claude in interactive mode
|
|
703
814
|
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
704
|
-
const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
|
|
705
|
-
const runtime = getRuntime(opts.runtime, config);
|
|
706
815
|
const spawnCmd = runtime.buildSpawnCommand({
|
|
707
816
|
model: resolvedModel.model,
|
|
708
817
|
permissionMode: "bypass",
|
|
@@ -740,6 +849,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
740
849
|
lastActivity: new Date().toISOString(),
|
|
741
850
|
escalationLevel: 0,
|
|
742
851
|
stalledSince: null,
|
|
852
|
+
transcriptPath: null,
|
|
743
853
|
};
|
|
744
854
|
|
|
745
855
|
store.upsert(session);
|
|
@@ -765,6 +875,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
765
875
|
taskId,
|
|
766
876
|
parentAgent,
|
|
767
877
|
depth,
|
|
878
|
+
instructionPath: runtime.instructionPath,
|
|
768
879
|
});
|
|
769
880
|
await sendKeys(tmuxSessionName, beacon);
|
|
770
881
|
|
|
@@ -780,20 +891,30 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
780
891
|
// screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
|
|
781
892
|
// sometimes consumes the Enter keystroke during late initialization, swallowing
|
|
782
893
|
// the beacon text entirely (overstory-3271).
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
894
|
+
//
|
|
895
|
+
// Skipped for runtimes that return false from requiresBeaconVerification().
|
|
896
|
+
// Pi's TUI idle and processing states are indistinguishable via detectReady
|
|
897
|
+
// (both show "pi v..." header and the token-usage status bar), so the loop
|
|
898
|
+
// would incorrectly conclude the beacon was not received and spam duplicate
|
|
899
|
+
// startup messages.
|
|
900
|
+
const needsVerification =
|
|
901
|
+
!runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
|
|
902
|
+
if (needsVerification) {
|
|
903
|
+
const verifyAttempts = 5;
|
|
904
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
905
|
+
await Bun.sleep(2_000);
|
|
906
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
907
|
+
if (paneContent) {
|
|
908
|
+
const readyState = runtime.detectReady(paneContent);
|
|
909
|
+
if (readyState.phase !== "ready") {
|
|
910
|
+
break; // Agent is processing — beacon was received
|
|
911
|
+
}
|
|
791
912
|
}
|
|
913
|
+
// Still at welcome/idle screen — resend beacon
|
|
914
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
915
|
+
await Bun.sleep(1_000);
|
|
916
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
792
917
|
}
|
|
793
|
-
// Still at welcome/idle screen — resend beacon
|
|
794
|
-
await sendKeys(tmuxSessionName, beacon);
|
|
795
|
-
await Bun.sleep(1_000);
|
|
796
|
-
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
797
918
|
}
|
|
798
919
|
|
|
799
920
|
// 14. Output result
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdir, mkdtemp
|
|
2
|
+
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { stripAnsi } from "../logging/color.ts";
|
|
6
6
|
import { createSessionStore } from "../sessions/store.ts";
|
|
7
|
-
import { createTempGitRepo } from "../test-helpers.ts";
|
|
7
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
8
8
|
import type { AgentSession } from "../types.ts";
|
|
9
9
|
import {
|
|
10
10
|
gatherStatus,
|
|
@@ -40,6 +40,7 @@ function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
|
40
40
|
lastActivity: new Date().toISOString(),
|
|
41
41
|
escalationLevel: 0,
|
|
42
42
|
stalledSince: null,
|
|
43
|
+
transcriptPath: null,
|
|
43
44
|
...overrides,
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -343,7 +344,7 @@ describe("run scoping", () => {
|
|
|
343
344
|
// out-of-scope builder must NOT appear
|
|
344
345
|
expect(names).not.toContain("builder-2");
|
|
345
346
|
} finally {
|
|
346
|
-
await
|
|
347
|
+
await cleanupTempDir(tempDir);
|
|
347
348
|
}
|
|
348
349
|
});
|
|
349
350
|
});
|
|
@@ -390,7 +391,7 @@ describe("--watch deprecation", () => {
|
|
|
390
391
|
} finally {
|
|
391
392
|
process.stderr.write = originalStderr;
|
|
392
393
|
process.chdir(originalCwd);
|
|
393
|
-
await
|
|
394
|
+
await cleanupTempDir(tmpDir);
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
const err = stderrChunks.join("");
|
|
@@ -431,7 +432,7 @@ describe("gatherStatus reconciliation", () => {
|
|
|
431
432
|
expect(agent).toBeDefined();
|
|
432
433
|
expect(agent?.state).toBe("zombie");
|
|
433
434
|
} finally {
|
|
434
|
-
await
|
|
435
|
+
await cleanupTempDir(tempDir);
|
|
435
436
|
}
|
|
436
437
|
});
|
|
437
438
|
|
|
@@ -460,7 +461,7 @@ describe("gatherStatus reconciliation", () => {
|
|
|
460
461
|
expect(agent).toBeDefined();
|
|
461
462
|
expect(agent?.state).toBe("completed");
|
|
462
463
|
} finally {
|
|
463
|
-
await
|
|
464
|
+
await cleanupTempDir(tempDir);
|
|
464
465
|
}
|
|
465
466
|
});
|
|
466
467
|
|
|
@@ -490,7 +491,7 @@ describe("gatherStatus reconciliation", () => {
|
|
|
490
491
|
expect(agent).toBeDefined();
|
|
491
492
|
expect(agent?.state).toBe("zombie");
|
|
492
493
|
} finally {
|
|
493
|
-
await
|
|
494
|
+
await cleanupTempDir(tempDir);
|
|
494
495
|
}
|
|
495
496
|
});
|
|
496
497
|
});
|
|
@@ -521,7 +522,7 @@ describe("subprocess caching (invalidateStatusCache)", () => {
|
|
|
521
522
|
expect(Array.isArray(result1.worktrees)).toBe(true);
|
|
522
523
|
expect(Array.isArray(result2.worktrees)).toBe(true);
|
|
523
524
|
} finally {
|
|
524
|
-
await
|
|
525
|
+
await cleanupTempDir(tempDir);
|
|
525
526
|
}
|
|
526
527
|
});
|
|
527
528
|
});
|