@os-eco/overstory-cli 0.7.2 → 0.7.4

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 (70) hide show
  1. package/README.md +21 -9
  2. package/agents/builder.md +6 -0
  3. package/agents/coordinator.md +2 -2
  4. package/agents/lead.md +4 -1
  5. package/agents/merger.md +3 -2
  6. package/agents/monitor.md +1 -1
  7. package/agents/reviewer.md +1 -0
  8. package/agents/scout.md +1 -0
  9. package/package.json +2 -2
  10. package/src/agents/hooks-deployer.test.ts +6 -5
  11. package/src/agents/identity.test.ts +3 -2
  12. package/src/agents/manifest.test.ts +4 -3
  13. package/src/agents/overlay.test.ts +3 -2
  14. package/src/commands/agents.test.ts +5 -4
  15. package/src/commands/agents.ts +18 -8
  16. package/src/commands/completions.test.ts +8 -5
  17. package/src/commands/completions.ts +37 -1
  18. package/src/commands/costs.test.ts +4 -3
  19. package/src/commands/dashboard.test.ts +265 -6
  20. package/src/commands/dashboard.ts +367 -64
  21. package/src/commands/doctor.test.ts +3 -2
  22. package/src/commands/errors.test.ts +3 -2
  23. package/src/commands/feed.test.ts +3 -2
  24. package/src/commands/feed.ts +2 -29
  25. package/src/commands/inspect.test.ts +3 -2
  26. package/src/commands/log.test.ts +248 -8
  27. package/src/commands/log.ts +193 -110
  28. package/src/commands/logs.test.ts +3 -2
  29. package/src/commands/mail.test.ts +3 -2
  30. package/src/commands/metrics.test.ts +4 -3
  31. package/src/commands/nudge.test.ts +3 -2
  32. package/src/commands/prime.test.ts +3 -2
  33. package/src/commands/prime.ts +1 -16
  34. package/src/commands/replay.test.ts +3 -2
  35. package/src/commands/run.test.ts +2 -1
  36. package/src/commands/sling.test.ts +127 -0
  37. package/src/commands/sling.ts +101 -3
  38. package/src/commands/status.test.ts +8 -8
  39. package/src/commands/trace.test.ts +3 -2
  40. package/src/commands/watch.test.ts +3 -2
  41. package/src/config.test.ts +3 -3
  42. package/src/doctor/agents.test.ts +3 -2
  43. package/src/doctor/logs.test.ts +3 -2
  44. package/src/doctor/structure.test.ts +3 -2
  45. package/src/index.ts +3 -1
  46. package/src/logging/color.ts +1 -1
  47. package/src/logging/format.test.ts +110 -0
  48. package/src/logging/format.ts +42 -1
  49. package/src/logging/logger.test.ts +3 -2
  50. package/src/mail/client.test.ts +3 -2
  51. package/src/mail/store.test.ts +3 -2
  52. package/src/merge/queue.test.ts +3 -2
  53. package/src/merge/resolver.test.ts +39 -0
  54. package/src/merge/resolver.ts +1 -1
  55. package/src/metrics/pricing.ts +80 -0
  56. package/src/metrics/transcript.test.ts +58 -1
  57. package/src/metrics/transcript.ts +9 -68
  58. package/src/mulch/client.test.ts +63 -2
  59. package/src/mulch/client.ts +62 -1
  60. package/src/runtimes/claude.test.ts +4 -3
  61. package/src/runtimes/pi-guards.test.ts +55 -2
  62. package/src/runtimes/pi-guards.ts +26 -9
  63. package/src/schema-consistency.test.ts +4 -2
  64. package/src/sessions/compat.test.ts +3 -2
  65. package/src/sessions/store.test.ts +3 -2
  66. package/src/test-helpers.ts +20 -1
  67. package/src/tracker/beads.test.ts +454 -0
  68. package/src/tracker/seeds.test.ts +461 -0
  69. package/src/watchdog/daemon.test.ts +4 -3
  70. package/src/watchdog/triage.test.ts +3 -2
@@ -370,6 +370,76 @@ export async function autoRecordExpertise(params: {
370
370
  return recordedDomains;
371
371
  }
372
372
 
373
+ interface AppliedRecordsData {
374
+ taskId: string | null;
375
+ agentName: string;
376
+ capability: string;
377
+ records: Array<{ id: string; domain: string }>;
378
+ }
379
+
380
+ /**
381
+ * Append outcome entries to the mulch records that were applied when this agent was spawned.
382
+ *
383
+ * At spawn time, sling.ts writes .overstory/agents/{name}/applied-records.json listing
384
+ * the mx-* IDs from the prime output. At session-end, this function reads that file,
385
+ * appends a "success" outcome to each record, and deletes the file.
386
+ *
387
+ * @returns Number of records successfully updated.
388
+ */
389
+ export async function appendOutcomeToAppliedRecords(params: {
390
+ mulchClient: MulchClient;
391
+ agentName: string;
392
+ capability: string;
393
+ taskId: string | null;
394
+ projectRoot: string;
395
+ }): Promise<number> {
396
+ const appliedRecordsPath = join(
397
+ params.projectRoot,
398
+ ".overstory",
399
+ "agents",
400
+ params.agentName,
401
+ "applied-records.json",
402
+ );
403
+ const appliedFile = Bun.file(appliedRecordsPath);
404
+ if (!(await appliedFile.exists())) return 0;
405
+
406
+ let data: AppliedRecordsData;
407
+ try {
408
+ data = JSON.parse(await appliedFile.text()) as AppliedRecordsData;
409
+ } catch {
410
+ return 0;
411
+ }
412
+
413
+ const { records } = data;
414
+ if (!records || records.length === 0) return 0;
415
+
416
+ const taskSuffix = params.taskId ? ` for task ${params.taskId}` : "";
417
+ const outcome = {
418
+ status: "success" as const,
419
+ agent: params.agentName,
420
+ notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.`,
421
+ };
422
+
423
+ let appended = 0;
424
+ for (const { id, domain } of records) {
425
+ try {
426
+ await params.mulchClient.appendOutcome(domain, id, outcome);
427
+ appended++;
428
+ } catch {
429
+ // Non-fatal per record
430
+ }
431
+ }
432
+
433
+ try {
434
+ const { unlink } = await import("node:fs/promises");
435
+ await unlink(appliedRecordsPath);
436
+ } catch {
437
+ // Non-fatal: file may already be gone
438
+ }
439
+
440
+ return appended;
441
+ }
442
+
373
443
  /**
374
444
  * Core implementation for the log command.
375
445
  */
@@ -427,29 +497,28 @@ async function runLog(opts: {
427
497
  logger.toolStart(toolName, toolInput ?? {});
428
498
  updateLastActivity(config.project.root, opts.agent);
429
499
 
430
- // When --stdin is used, also write to EventStore for structured observability
431
- if (opts.stdin) {
432
- try {
433
- const eventsDbPath = join(config.project.root, ".overstory", "events.db");
434
- const eventStore = createEventStore(eventsDbPath);
435
- const filtered = toolInput
436
- ? filterToolArgs(toolName, toolInput)
437
- : { args: {}, summary: toolName };
438
- eventStore.insert({
439
- runId: null,
440
- agentName: opts.agent,
441
- sessionId,
442
- eventType: "tool_start",
443
- toolName,
444
- toolArgs: JSON.stringify(filtered.args),
445
- toolDurationMs: null,
446
- level: "info",
447
- data: JSON.stringify({ summary: filtered.summary }),
448
- });
449
- eventStore.close();
450
- } catch {
451
- // Non-fatal: EventStore write should not break hook execution
452
- }
500
+ // Always write to EventStore for structured observability
501
+ // (works for both Claude Code --stdin and Pi runtime --tool-name agents)
502
+ try {
503
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
504
+ const eventStore = createEventStore(eventsDbPath);
505
+ const filtered = toolInput
506
+ ? filterToolArgs(toolName, toolInput)
507
+ : { args: {}, summary: toolName };
508
+ eventStore.insert({
509
+ runId: null,
510
+ agentName: opts.agent,
511
+ sessionId,
512
+ eventType: "tool_start",
513
+ toolName,
514
+ toolArgs: JSON.stringify(filtered.args),
515
+ toolDurationMs: null,
516
+ level: "info",
517
+ data: JSON.stringify({ summary: filtered.summary }),
518
+ });
519
+ eventStore.close();
520
+ } catch {
521
+ // Non-fatal: EventStore write should not break hook execution
453
522
  }
454
523
  break;
455
524
  }
@@ -458,79 +527,78 @@ async function runLog(opts: {
458
527
  logger.toolEnd(toolName, 0);
459
528
  updateLastActivity(config.project.root, opts.agent);
460
529
 
461
- // When --stdin is used, write to EventStore and correlate with tool-start
462
- if (opts.stdin) {
463
- try {
464
- const eventsDbPath = join(config.project.root, ".overstory", "events.db");
465
- const eventStore = createEventStore(eventsDbPath);
466
- const filtered = toolInput
467
- ? filterToolArgs(toolName, toolInput)
468
- : { args: {}, summary: toolName };
469
- eventStore.insert({
470
- runId: null,
471
- agentName: opts.agent,
472
- sessionId,
473
- eventType: "tool_end",
474
- toolName,
475
- toolArgs: JSON.stringify(filtered.args),
476
- toolDurationMs: null,
477
- level: "info",
478
- data: JSON.stringify({ summary: filtered.summary }),
479
- });
480
- const correlation = eventStore.correlateToolEnd(opts.agent, toolName);
481
- if (correlation) {
482
- logger.toolEnd(toolName, correlation.durationMs);
483
- }
484
- eventStore.close();
485
- } catch {
486
- // Non-fatal: EventStore write should not break hook execution
530
+ // Always write to EventStore for structured observability
531
+ // (works for both Claude Code --stdin and Pi runtime --tool-name agents)
532
+ try {
533
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
534
+ const eventStore = createEventStore(eventsDbPath);
535
+ const filtered = toolInput
536
+ ? filterToolArgs(toolName, toolInput)
537
+ : { args: {}, summary: toolName };
538
+ eventStore.insert({
539
+ runId: null,
540
+ agentName: opts.agent,
541
+ sessionId,
542
+ eventType: "tool_end",
543
+ toolName,
544
+ toolArgs: JSON.stringify(filtered.args),
545
+ toolDurationMs: null,
546
+ level: "info",
547
+ data: JSON.stringify({ summary: filtered.summary }),
548
+ });
549
+ const correlation = eventStore.correlateToolEnd(opts.agent, toolName);
550
+ if (correlation) {
551
+ logger.toolEnd(toolName, correlation.durationMs);
487
552
  }
553
+ eventStore.close();
554
+ } catch {
555
+ // Non-fatal: EventStore write should not break hook execution
556
+ }
488
557
 
489
- // Throttled token snapshot recording
490
- if (sessionId) {
491
- try {
492
- // Throttle check
493
- const snapshotMarkerPath = join(logsBase, opts.agent, ".last-snapshot");
494
- const SNAPSHOT_INTERVAL_MS = 30_000;
495
- const snapshotMarkerFile = Bun.file(snapshotMarkerPath);
496
- let shouldSnapshot = true;
497
-
498
- if (await snapshotMarkerFile.exists()) {
499
- const lastTs = Number.parseInt(await snapshotMarkerFile.text(), 10);
500
- if (!Number.isNaN(lastTs) && Date.now() - lastTs < SNAPSHOT_INTERVAL_MS) {
501
- shouldSnapshot = false;
502
- }
558
+ // Throttled token snapshot recording (requires sessionId from --stdin; skipped for Pi agents)
559
+ if (sessionId) {
560
+ try {
561
+ // Throttle check
562
+ const snapshotMarkerPath = join(logsBase, opts.agent, ".last-snapshot");
563
+ const SNAPSHOT_INTERVAL_MS = 30_000;
564
+ const snapshotMarkerFile = Bun.file(snapshotMarkerPath);
565
+ let shouldSnapshot = true;
566
+
567
+ if (await snapshotMarkerFile.exists()) {
568
+ const lastTs = Number.parseInt(await snapshotMarkerFile.text(), 10);
569
+ if (!Number.isNaN(lastTs) && Date.now() - lastTs < SNAPSHOT_INTERVAL_MS) {
570
+ shouldSnapshot = false;
503
571
  }
572
+ }
504
573
 
505
- if (shouldSnapshot) {
506
- const resolvedTranscriptPath = await resolveTranscriptPath(
507
- config.project.root,
508
- sessionId,
509
- logsBase,
510
- opts.agent,
511
- );
512
- if (resolvedTranscriptPath) {
513
- const usage = await parseTranscriptUsage(resolvedTranscriptPath);
514
- const cost = estimateCost(usage);
515
- const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
516
- const metricsStore = createMetricsStore(metricsDbPath);
517
- metricsStore.recordSnapshot({
518
- agentName: opts.agent,
519
- inputTokens: usage.inputTokens,
520
- outputTokens: usage.outputTokens,
521
- cacheReadTokens: usage.cacheReadTokens,
522
- cacheCreationTokens: usage.cacheCreationTokens,
523
- estimatedCostUsd: cost,
524
- modelUsed: usage.modelUsed,
525
- createdAt: new Date().toISOString(),
526
- });
527
- metricsStore.close();
528
- await Bun.write(snapshotMarkerPath, String(Date.now()));
529
- }
574
+ if (shouldSnapshot) {
575
+ const resolvedTranscriptPath = await resolveTranscriptPath(
576
+ config.project.root,
577
+ sessionId,
578
+ logsBase,
579
+ opts.agent,
580
+ );
581
+ if (resolvedTranscriptPath) {
582
+ const usage = await parseTranscriptUsage(resolvedTranscriptPath);
583
+ const cost = estimateCost(usage);
584
+ const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
585
+ const metricsStore = createMetricsStore(metricsDbPath);
586
+ metricsStore.recordSnapshot({
587
+ agentName: opts.agent,
588
+ inputTokens: usage.inputTokens,
589
+ outputTokens: usage.outputTokens,
590
+ cacheReadTokens: usage.cacheReadTokens,
591
+ cacheCreationTokens: usage.cacheCreationTokens,
592
+ estimatedCostUsd: cost,
593
+ modelUsed: usage.modelUsed,
594
+ createdAt: new Date().toISOString(),
595
+ });
596
+ metricsStore.close();
597
+ await Bun.write(snapshotMarkerPath, String(Date.now()));
530
598
  }
531
- } catch {
532
- // Non-fatal: snapshot recording should not break tool-end handling
533
599
  }
600
+ } catch {
601
+ // Non-fatal: snapshot recording should not break tool-end handling
534
602
  }
535
603
  }
536
604
  break;
@@ -681,29 +749,44 @@ async function runLog(opts: {
681
749
  // Non-fatal: mulch learn/record should not break session-end handling
682
750
  }
683
751
  }
684
- }
685
752
 
686
- // Write session-end event to EventStore when --stdin is used
687
- if (opts.stdin) {
688
- try {
689
- const eventsDbPath = join(config.project.root, ".overstory", "events.db");
690
- const eventStore = createEventStore(eventsDbPath);
691
- eventStore.insert({
692
- runId: null,
693
- agentName: opts.agent,
694
- sessionId,
695
- eventType: "session_end",
696
- toolName: null,
697
- toolArgs: null,
698
- toolDurationMs: null,
699
- level: "info",
700
- data: transcriptPath ? JSON.stringify({ transcriptPath }) : null,
701
- });
702
- eventStore.close();
703
- } catch {
704
- // Non-fatal: EventStore write should not break session-end
753
+ // Append outcomes to applied mulch records (outcome feedback loop).
754
+ // Reads applied-records.json written by sling.ts at spawn time.
755
+ if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
756
+ try {
757
+ const mulchClient = createMulchClient(config.project.root);
758
+ await appendOutcomeToAppliedRecords({
759
+ mulchClient,
760
+ agentName: opts.agent,
761
+ capability: agentSession.capability,
762
+ taskId,
763
+ projectRoot: config.project.root,
764
+ });
765
+ } catch {
766
+ // Non-fatal
767
+ }
705
768
  }
706
769
  }
770
+
771
+ // Always write session-end event to EventStore (not just when --stdin is used)
772
+ try {
773
+ const eventsDbPath = join(config.project.root, ".overstory", "events.db");
774
+ const eventStore = createEventStore(eventsDbPath);
775
+ eventStore.insert({
776
+ runId: null,
777
+ agentName: opts.agent,
778
+ sessionId,
779
+ eventType: "session_end",
780
+ toolName: null,
781
+ toolArgs: null,
782
+ toolDurationMs: null,
783
+ level: "info",
784
+ data: transcriptPath ? JSON.stringify({ transcriptPath }) : null,
785
+ });
786
+ eventStore.close();
787
+ } catch {
788
+ // Non-fatal: EventStore write should not break session-end
789
+ }
707
790
  }
708
791
  // Clear the current session marker
709
792
  {
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { ValidationError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { LogEvent } from "../types.ts";
7
8
  import { logsCommand } from "./logs.ts";
8
9
 
@@ -51,7 +52,7 @@ describe("logsCommand", () => {
51
52
 
52
53
  // Clean up temp directory
53
54
  try {
54
- await rm(tmpDir, { recursive: true, force: true });
55
+ await cleanupTempDir(tmpDir);
55
56
  } catch {
56
57
  // Ignore cleanup errors
57
58
  }
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
9
+ import { mkdir, mkdtemp, readdir } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { createEventStore } from "../events/store.ts";
13
13
  import { stripAnsi } from "../logging/color.ts";
14
14
  import { createMailClient } from "../mail/client.ts";
15
15
  import { createMailStore } from "../mail/store.ts";
16
+ import { cleanupTempDir } from "../test-helpers.ts";
16
17
  import type { StoredEvent } from "../types.ts";
17
18
  import { AUTO_NUDGE_TYPES, isDispatchNudge, mailCommand, shouldAutoNudge } from "./mail.ts";
18
19
 
@@ -70,7 +71,7 @@ describe("mailCommand", () => {
70
71
  process.stdout.write = origWrite;
71
72
  process.stderr.write = origStderrWrite;
72
73
  process.chdir(origCwd);
73
- await rm(tempDir, { recursive: true, force: true });
74
+ await cleanupTempDir(tempDir);
74
75
  });
75
76
 
76
77
  describe("list", () => {
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createMetricsStore } from "../metrics/store.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { SessionMetrics } from "../types.ts";
7
8
  import { metricsCommand } from "./metrics.ts";
8
9
 
@@ -44,7 +45,7 @@ describe("metricsCommand", () => {
44
45
  afterEach(async () => {
45
46
  process.stdout.write = originalWrite;
46
47
  process.chdir(originalCwd);
47
- await rm(tempDir, { recursive: true, force: true });
48
+ await cleanupTempDir(tempDir);
48
49
  });
49
50
 
50
51
  function output(): string {
@@ -366,7 +367,7 @@ describe("formatDuration helper", () => {
366
367
  afterEach(async () => {
367
368
  process.stdout.write = originalWrite;
368
369
  process.chdir(originalCwd);
369
- await rm(tempDir, { recursive: true, force: true });
370
+ await cleanupTempDir(tempDir);
370
371
  });
371
372
 
372
373
  function output(): string {
@@ -1,10 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync } from "node:fs";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdtemp } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createEventStore } from "../events/store.ts";
7
7
  import { createSessionStore } from "../sessions/store.ts";
8
+ import { cleanupTempDir } from "../test-helpers.ts";
8
9
  import type { AgentSession, StoredEvent } from "../types.ts";
9
10
 
10
11
  /**
@@ -22,7 +23,7 @@ beforeEach(async () => {
22
23
  });
23
24
 
24
25
  afterEach(async () => {
25
- await rm(tempDir, { recursive: true, force: true });
26
+ await cleanupTempDir(tempDir);
26
27
  });
27
28
 
28
29
  /**
@@ -1,5 +1,5 @@
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 { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
@@ -55,7 +55,7 @@ describe("primeCommand", () => {
55
55
  process.stdout.write = originalWrite;
56
56
  process.stderr.write = originalStderrWrite;
57
57
  process.chdir(originalCwd);
58
- await rm(tempDir, { recursive: true, force: true });
58
+ await cleanupTempDir(tempDir);
59
59
  });
60
60
 
61
61
  function output(): string {
@@ -366,6 +366,7 @@ recentTasks: []
366
366
  !hooks.json
367
367
  !groups.json
368
368
  !agent-defs/
369
+ !README.md
369
370
  `;
370
371
 
371
372
  test("creates .overstory/.gitignore if missing", async () => {
@@ -18,22 +18,7 @@ import { createMulchClient } from "../mulch/client.ts";
18
18
  import { openSessionStore } from "../sessions/compat.ts";
19
19
  import type { AgentIdentity, AgentManifest, SessionCheckpoint, SessionMetrics } from "../types.ts";
20
20
  import { getCurrentSessionName } from "../worktree/tmux.ts";
21
-
22
- /**
23
- * Gitignore content for .overstory/.gitignore.
24
- * TODO: Import from init.ts once it's exported (parallel branch change).
25
- * Wildcard+whitelist pattern: ignore everything except tracked config files.
26
- */
27
- const OVERSTORY_GITIGNORE = `# Wildcard+whitelist: ignore everything, whitelist tracked files
28
- # Auto-healed by ov prime on each session start
29
- *
30
- !.gitignore
31
- !config.yaml
32
- !agent-manifest.json
33
- !hooks.json
34
- !groups.json
35
- !agent-defs/
36
- `;
21
+ import { OVERSTORY_GITIGNORE } from "./init.ts";
37
22
 
38
23
  export interface PrimeOptions {
39
24
  agent?: string;
@@ -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. */
@@ -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
  *
@@ -1147,3 +1208,69 @@ describe("sling runtime integration", () => {
1147
1208
  expect(state.phase).toBe("loading");
1148
1209
  });
1149
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
+ });