@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.
Files changed (91) hide show
  1. package/README.md +7 -6
  2. package/agents/builder.md +1 -1
  3. package/agents/coordinator.md +12 -11
  4. package/agents/lead.md +6 -6
  5. package/agents/monitor.md +4 -4
  6. package/agents/reviewer.md +1 -1
  7. package/agents/scout.md +5 -5
  8. package/agents/supervisor.md +36 -32
  9. package/package.json +1 -1
  10. package/src/agents/guard-rules.ts +97 -0
  11. package/src/agents/hooks-deployer.test.ts +6 -5
  12. package/src/agents/hooks-deployer.ts +7 -90
  13. package/src/agents/identity.test.ts +3 -2
  14. package/src/agents/manifest.test.ts +4 -3
  15. package/src/agents/overlay.test.ts +10 -9
  16. package/src/agents/overlay.ts +5 -5
  17. package/src/commands/agents.test.ts +10 -4
  18. package/src/commands/clean.test.ts +3 -0
  19. package/src/commands/completions.test.ts +8 -5
  20. package/src/commands/completions.ts +38 -2
  21. package/src/commands/coordinator.test.ts +1 -0
  22. package/src/commands/coordinator.ts +15 -11
  23. package/src/commands/costs.test.ts +9 -3
  24. package/src/commands/dashboard.test.ts +265 -6
  25. package/src/commands/dashboard.ts +367 -64
  26. package/src/commands/doctor.test.ts +3 -2
  27. package/src/commands/errors.test.ts +3 -2
  28. package/src/commands/feed.test.ts +3 -2
  29. package/src/commands/feed.ts +2 -29
  30. package/src/commands/init.test.ts +1 -2
  31. package/src/commands/init.ts +1 -8
  32. package/src/commands/inspect.test.ts +17 -2
  33. package/src/commands/log.test.ts +262 -8
  34. package/src/commands/log.ts +232 -110
  35. package/src/commands/logs.test.ts +3 -2
  36. package/src/commands/mail.test.ts +8 -2
  37. package/src/commands/metrics.test.ts +4 -3
  38. package/src/commands/monitor.ts +15 -11
  39. package/src/commands/nudge.test.ts +4 -2
  40. package/src/commands/prime.test.ts +4 -2
  41. package/src/commands/prime.ts +6 -2
  42. package/src/commands/replay.test.ts +3 -2
  43. package/src/commands/run.test.ts +3 -1
  44. package/src/commands/sling.test.ts +142 -1
  45. package/src/commands/sling.ts +145 -24
  46. package/src/commands/status.test.ts +9 -8
  47. package/src/commands/stop.test.ts +1 -0
  48. package/src/commands/supervisor.ts +19 -12
  49. package/src/commands/trace.test.ts +4 -2
  50. package/src/commands/watch.test.ts +3 -2
  51. package/src/commands/worktree.test.ts +9 -0
  52. package/src/config.test.ts +3 -3
  53. package/src/config.ts +29 -0
  54. package/src/doctor/agents.test.ts +3 -2
  55. package/src/doctor/consistency.test.ts +14 -0
  56. package/src/doctor/logs.test.ts +3 -2
  57. package/src/doctor/structure.test.ts +3 -2
  58. package/src/e2e/init-sling-lifecycle.test.ts +3 -5
  59. package/src/index.ts +3 -1
  60. package/src/logging/color.ts +1 -1
  61. package/src/logging/format.test.ts +110 -0
  62. package/src/logging/format.ts +42 -1
  63. package/src/logging/logger.test.ts +3 -2
  64. package/src/mail/broadcast.test.ts +1 -0
  65. package/src/mail/client.test.ts +3 -2
  66. package/src/mail/store.test.ts +3 -2
  67. package/src/merge/queue.test.ts +3 -2
  68. package/src/merge/resolver.test.ts +39 -0
  69. package/src/merge/resolver.ts +24 -5
  70. package/src/mulch/client.test.ts +63 -2
  71. package/src/mulch/client.ts +62 -1
  72. package/src/runtimes/claude.test.ts +5 -4
  73. package/src/runtimes/pi-guards.test.ts +457 -0
  74. package/src/runtimes/pi-guards.ts +349 -0
  75. package/src/runtimes/pi.test.ts +620 -0
  76. package/src/runtimes/pi.ts +244 -0
  77. package/src/runtimes/registry.test.ts +33 -0
  78. package/src/runtimes/registry.ts +15 -2
  79. package/src/runtimes/types.ts +63 -0
  80. package/src/schema-consistency.test.ts +5 -2
  81. package/src/sessions/compat.test.ts +3 -2
  82. package/src/sessions/compat.ts +1 -0
  83. package/src/sessions/store.test.ts +34 -2
  84. package/src/sessions/store.ts +37 -4
  85. package/src/test-helpers.ts +20 -1
  86. package/src/types.ts +17 -0
  87. package/src/watchdog/daemon.test.ts +11 -7
  88. package/src/watchdog/daemon.ts +1 -1
  89. package/src/watchdog/health.test.ts +1 -0
  90. package/src/watchdog/triage.test.ts +3 -2
  91. 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, rm } from "node:fs/promises";
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 rm(tempDir, { recursive: true, force: true });
68
+ await cleanupTempDir(tempDir);
68
69
  });
69
70
 
70
71
  function output(): string {
@@ -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 rm(tempDir, { recursive: true, force: true });
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("read .claude/CLAUDE.md");
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
+ });
@@ -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 .claude/CLAUDE.md and begin immediately.`,
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 .claude/CLAUDE.md, run mulch prime, check mail (ov mail check --agent ${opts.agentName}), then begin task ${opts.taskId}`,
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 or supervisor
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
- if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
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, { files: fileScope });
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. Deploy hooks config (capability-specific guards)
650
- await deployHooks(worktreePath, name, capability, config.project.qualityGates);
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. Preflight: verify tmux is available before attempting session creation
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
- const verifyAttempts = 5;
784
- for (let v = 0; v < verifyAttempts; v++) {
785
- await Bun.sleep(2_000);
786
- const paneContent = await capturePaneContent(tmuxSessionName);
787
- if (paneContent) {
788
- const readyState = runtime.detectReady(paneContent);
789
- if (readyState.phase !== "ready") {
790
- break; // Agent is processing — beacon was received
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, rm } from "node:fs/promises";
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 rm(tempDir, { recursive: true, force: true });
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 rm(tmpDir, { recursive: true, force: true });
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 rm(tempDir, { recursive: true, force: true });
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 rm(tempDir, { recursive: true, force: true });
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 rm(tempDir, { recursive: true, force: true });
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 rm(tempDir, { recursive: true, force: true });
525
+ await cleanupTempDir(tempDir);
525
526
  }
526
527
  });
527
528
  });
@@ -148,6 +148,7 @@ function makeAgentSession(overrides: Partial<AgentSession> = {}): AgentSession {
148
148
  lastActivity: new Date().toISOString(),
149
149
  escalationLevel: 0,
150
150
  stalledSince: null,
151
+ transcriptPath: null,
151
152
  ...overrides,
152
153
  };
153
154
  }