@os-eco/overstory-cli 0.10.3 → 0.11.0

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 (44) hide show
  1. package/README.md +4 -2
  2. package/agents/builder.md +10 -1
  3. package/agents/lead.md +106 -5
  4. package/package.json +1 -1
  5. package/src/agents/headless-mail-injector.ts +8 -0
  6. package/src/agents/mail-poll-detect.test.ts +153 -0
  7. package/src/agents/mail-poll-detect.ts +73 -0
  8. package/src/agents/overlay.test.ts +56 -0
  9. package/src/agents/overlay.ts +33 -0
  10. package/src/agents/scope-detect.test.ts +190 -0
  11. package/src/agents/scope-detect.ts +146 -0
  12. package/src/agents/turn-runner.test.ts +862 -0
  13. package/src/agents/turn-runner.ts +225 -8
  14. package/src/commands/agents.ts +9 -0
  15. package/src/commands/coordinator.test.ts +127 -0
  16. package/src/commands/coordinator.ts +71 -4
  17. package/src/commands/dashboard.ts +1 -1
  18. package/src/commands/log.test.ts +131 -0
  19. package/src/commands/log.ts +37 -2
  20. package/src/commands/merge.test.ts +118 -0
  21. package/src/commands/merge.ts +51 -8
  22. package/src/commands/sling.test.ts +104 -0
  23. package/src/commands/sling.ts +95 -8
  24. package/src/commands/stop.test.ts +81 -0
  25. package/src/index.ts +5 -1
  26. package/src/insights/quality-gates.test.ts +141 -0
  27. package/src/insights/quality-gates.ts +156 -0
  28. package/src/logging/theme.ts +4 -0
  29. package/src/merge/predict.test.ts +387 -0
  30. package/src/merge/predict.ts +249 -0
  31. package/src/merge/resolver.ts +1 -1
  32. package/src/mulch/client.ts +3 -3
  33. package/src/sessions/store.test.ts +267 -5
  34. package/src/sessions/store.ts +105 -7
  35. package/src/types.ts +51 -1
  36. package/src/watchdog/daemon.test.ts +124 -2
  37. package/src/watchdog/daemon.ts +27 -12
  38. package/src/watchdog/health.test.ts +133 -8
  39. package/src/watchdog/health.ts +37 -5
  40. package/src/worktree/manager.test.ts +218 -1
  41. package/src/worktree/manager.ts +55 -0
  42. package/src/worktree/tmux.test.ts +25 -0
  43. package/src/worktree/tmux.ts +17 -0
  44. package/templates/overlay.md.tmpl +2 -0
@@ -1299,6 +1299,81 @@ describe("logCommand", () => {
1299
1299
  expect(mail?.body).toContain("10 tool calls");
1300
1300
  expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
1301
1301
  });
1302
+
1303
+ test("threads outcomeStatus into per-domain reference and per-insight records", async () => {
1304
+ const learnResult: MulchLearnResult = {
1305
+ success: true,
1306
+ command: "mulch learn",
1307
+ changedFiles: ["src/foo.ts"],
1308
+ suggestedDomains: ["typescript"],
1309
+ unmatchedFiles: [],
1310
+ };
1311
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
1312
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-outcome.db");
1313
+
1314
+ // Seed events so analyzer emits at least one insight (10+ tool calls).
1315
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1316
+ const eventStore = createEventStore(eventsDbPath);
1317
+ const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
1318
+ for (let i = 0; i < 10; i++) {
1319
+ eventStore.insert({
1320
+ runId: null,
1321
+ agentName: "outcome-agent",
1322
+ sessionId: "sess-outcome",
1323
+ eventType: "tool_start",
1324
+ toolName: "Read",
1325
+ toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
1326
+ toolDurationMs: null,
1327
+ level: "info",
1328
+ data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
1329
+ });
1330
+ }
1331
+ eventStore.close();
1332
+
1333
+ await autoRecordExpertise({
1334
+ mulchClient: client,
1335
+ agentName: "outcome-agent",
1336
+ capability: "builder",
1337
+ taskId: "bead-outcome",
1338
+ mailDbPath,
1339
+ parentAgent: "parent-agent",
1340
+ projectRoot: tempDir,
1341
+ sessionStartedAt,
1342
+ outcomeStatus: "partial",
1343
+ });
1344
+
1345
+ expect(recordCalls.length).toBeGreaterThanOrEqual(2);
1346
+ for (const call of recordCalls) {
1347
+ expect(call.options.outcomeStatus).toBe("partial");
1348
+ expect(call.options.outcomeAgent).toBe("outcome-agent");
1349
+ }
1350
+ });
1351
+
1352
+ test("omits outcomeStatus when caller does not supply one", async () => {
1353
+ const learnResult: MulchLearnResult = {
1354
+ success: true,
1355
+ command: "mulch learn",
1356
+ changedFiles: ["src/foo.ts"],
1357
+ suggestedDomains: ["typescript"],
1358
+ unmatchedFiles: [],
1359
+ };
1360
+ const { client, recordCalls } = createFakeMulchClient(learnResult);
1361
+ const mailDbPath = join(tempDir, ".overstory", "auto-record-no-outcome.db");
1362
+
1363
+ await autoRecordExpertise({
1364
+ mulchClient: client,
1365
+ agentName: "no-outcome-agent",
1366
+ capability: "builder",
1367
+ taskId: null,
1368
+ mailDbPath,
1369
+ parentAgent: null,
1370
+ projectRoot: tempDir,
1371
+ sessionStartedAt: new Date().toISOString(),
1372
+ });
1373
+
1374
+ expect(recordCalls).toHaveLength(1);
1375
+ expect(recordCalls[0]?.options.outcomeStatus).toBeUndefined();
1376
+ });
1302
1377
  });
1303
1378
 
1304
1379
  /**
@@ -1779,4 +1854,60 @@ describe("appendOutcomeToAppliedRecords", () => {
1779
1854
  });
1780
1855
  expect(count).toBe(0);
1781
1856
  });
1857
+
1858
+ test("uses supplied outcomeStatus when provided", async () => {
1859
+ const agentDir = join(tempDir, ".overstory", "agents", "test-agent");
1860
+ await mkdir(agentDir, { recursive: true });
1861
+ await Bun.write(
1862
+ join(agentDir, "applied-records.json"),
1863
+ JSON.stringify({
1864
+ taskId: "bead-outcome",
1865
+ agentName: "test-agent",
1866
+ capability: "builder",
1867
+ records: [{ id: "mx-aaa111", domain: "agents" }],
1868
+ }),
1869
+ );
1870
+
1871
+ const { client, appendOutcomeCalls } = makeOutcomeClient();
1872
+ await appendOutcomeToAppliedRecords({
1873
+ mulchClient: client,
1874
+ agentName: "test-agent",
1875
+ capability: "builder",
1876
+ taskId: "bead-outcome",
1877
+ projectRoot: tempDir,
1878
+ outcomeStatus: "failure",
1879
+ });
1880
+
1881
+ expect(appendOutcomeCalls).toHaveLength(1);
1882
+ expect(appendOutcomeCalls[0]?.outcome).toMatchObject({ status: "failure" });
1883
+ expect(appendOutcomeCalls[0]?.outcome.notes).toContain("Quality gates: failure");
1884
+ });
1885
+
1886
+ test("falls back to 'success' when outcomeStatus is undefined (backward compat)", async () => {
1887
+ const agentDir = join(tempDir, ".overstory", "agents", "test-agent");
1888
+ await mkdir(agentDir, { recursive: true });
1889
+ await Bun.write(
1890
+ join(agentDir, "applied-records.json"),
1891
+ JSON.stringify({
1892
+ taskId: "bead-default",
1893
+ agentName: "test-agent",
1894
+ capability: "builder",
1895
+ records: [{ id: "mx-bbb222", domain: "agents" }],
1896
+ }),
1897
+ );
1898
+
1899
+ const { client, appendOutcomeCalls } = makeOutcomeClient();
1900
+ await appendOutcomeToAppliedRecords({
1901
+ mulchClient: client,
1902
+ agentName: "test-agent",
1903
+ capability: "builder",
1904
+ taskId: "bead-default",
1905
+ projectRoot: tempDir,
1906
+ });
1907
+
1908
+ expect(appendOutcomeCalls).toHaveLength(1);
1909
+ expect(appendOutcomeCalls[0]?.outcome.status).toBe("success");
1910
+ // No "Quality gates:" annotation when caller didn't provide outcomeStatus
1911
+ expect(appendOutcomeCalls[0]?.outcome.notes).not.toContain("Quality gates:");
1912
+ });
1782
1913
  });
@@ -19,6 +19,7 @@ import { ValidationError } from "../errors.ts";
19
19
  import { createEventStore } from "../events/store.ts";
20
20
  import { filterToolArgs } from "../events/tool-filter.ts";
21
21
  import { analyzeSessionInsights } from "../insights/analyzer.ts";
22
+ import { hasWorkToVerify, runQualityGates } from "../insights/quality-gates.ts";
22
23
  import { createLogger } from "../logging/logger.ts";
23
24
  import { createMailClient } from "../mail/client.ts";
24
25
  import { createMailStore } from "../mail/store.ts";
@@ -379,6 +380,7 @@ export async function autoRecordExpertise(params: {
379
380
  parentAgent: string | null;
380
381
  projectRoot: string;
381
382
  sessionStartedAt: string;
383
+ outcomeStatus?: "success" | "partial" | "failure";
382
384
  }): Promise<string[]> {
383
385
  const learnResult = await params.mulchClient.learn({ since: "HEAD~1" });
384
386
  if (learnResult.suggestedDomains.length === 0) {
@@ -395,6 +397,8 @@ export async function autoRecordExpertise(params: {
395
397
  description: `${params.capability} agent ${params.agentName} completed work in this domain. Files: ${filesList}`,
396
398
  tags: ["auto-session-end", params.capability],
397
399
  evidenceBead: params.taskId ?? undefined,
400
+ outcomeStatus: params.outcomeStatus,
401
+ outcomeAgent: params.agentName,
398
402
  });
399
403
  recordedDomains.push(domain);
400
404
  } catch {
@@ -434,6 +438,8 @@ export async function autoRecordExpertise(params: {
434
438
  description: insight.description,
435
439
  tags: insight.tags,
436
440
  evidenceBead: params.taskId ?? undefined,
441
+ outcomeStatus: params.outcomeStatus,
442
+ outcomeAgent: params.agentName,
437
443
  });
438
444
  if (!recordedDomains.includes(insight.domain)) {
439
445
  recordedDomains.push(insight.domain);
@@ -500,6 +506,7 @@ export async function appendOutcomeToAppliedRecords(params: {
500
506
  capability: string;
501
507
  taskId: string | null;
502
508
  projectRoot: string;
509
+ outcomeStatus?: "success" | "partial" | "failure";
503
510
  }): Promise<number> {
504
511
  const appliedRecordsPath = join(
505
512
  params.projectRoot,
@@ -522,10 +529,12 @@ export async function appendOutcomeToAppliedRecords(params: {
522
529
  if (!records || records.length === 0) return 0;
523
530
 
524
531
  const taskSuffix = params.taskId ? ` for task ${params.taskId}` : "";
532
+ const status: "success" | "partial" | "failure" = params.outcomeStatus ?? "success";
533
+ const gateNote = params.outcomeStatus ? ` Quality gates: ${params.outcomeStatus}.` : "";
525
534
  const outcome = {
526
- status: "success" as const,
535
+ status,
527
536
  agent: params.agentName,
528
- notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.`,
537
+ notes: `Applied by ${params.capability} agent ${params.agentName}${taskSuffix}. Session completed.${gateNote}`,
529
538
  };
530
539
 
531
540
  let appended = 0;
@@ -793,6 +802,30 @@ async function runLog(opts: {
793
802
  // Non-fatal: metrics recording should not break session-end handling
794
803
  }
795
804
 
805
+ // Resolve outcome status from quality-gate results, threaded into
806
+ // every session-end mulch record write so confirmation scoring
807
+ // reflects whether tests/lint/typecheck actually passed.
808
+ let outcomeStatus: "success" | "partial" | "failure" | undefined;
809
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
810
+ try {
811
+ let baseRef = "main";
812
+ const baseBranchPath = join(config.project.root, ".overstory", "session-branch.txt");
813
+ const baseFile = Bun.file(baseBranchPath);
814
+ if (await baseFile.exists()) {
815
+ const txt = (await baseFile.text()).trim();
816
+ if (txt.length > 0) baseRef = txt;
817
+ }
818
+ const hasWork = await hasWorkToVerify(agentSession.worktreePath, baseRef);
819
+ if (hasWork) {
820
+ const gates = config.project.qualityGates ?? [];
821
+ const outcome = await runQualityGates(gates, agentSession.worktreePath);
822
+ if (outcome) outcomeStatus = outcome.status;
823
+ }
824
+ } catch {
825
+ // Non-fatal: outcome status is optional
826
+ }
827
+ }
828
+
796
829
  // Auto-record expertise via mulch learn + record (post-session).
797
830
  // Skip persistent agents whose Stop hook fires every turn.
798
831
  if (!isStopHookPersistentCapability(agentSession.capability)) {
@@ -808,6 +841,7 @@ async function runLog(opts: {
808
841
  parentAgent: agentSession.parentAgent,
809
842
  projectRoot: config.project.root,
810
843
  sessionStartedAt: agentSession.startedAt,
844
+ outcomeStatus,
811
845
  });
812
846
  } catch {
813
847
  // Non-fatal: mulch learn/record should not break session-end handling
@@ -825,6 +859,7 @@ async function runLog(opts: {
825
859
  capability: agentSession.capability,
826
860
  taskId,
827
861
  projectRoot: config.project.root,
862
+ outcomeStatus,
828
863
  });
829
864
  } catch {
830
865
  // Non-fatal
@@ -708,6 +708,124 @@ merge:
708
708
  });
709
709
  });
710
710
 
711
+ describe("--dry-run conflict prediction", () => {
712
+ test("--dry-run --json includes prediction.wouldRequireAgent for contentful-canonical conflict", async () => {
713
+ await setupProject(repoDir, defaultBranch);
714
+
715
+ // Build a real contentful-canonical conflict on src/shared.ts.
716
+ await commitFile(repoDir, "src/shared.ts", "shared base\n");
717
+ const branchName = "overstory/builder/bead-predict-1";
718
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
719
+ await commitFile(repoDir, "src/shared.ts", "feature side\n");
720
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
721
+ await commitFile(repoDir, "src/shared.ts", "main side\n");
722
+
723
+ let output = "";
724
+ const originalWrite = process.stdout.write.bind(process.stdout);
725
+ process.stdout.write = (chunk: unknown): boolean => {
726
+ output += String(chunk);
727
+ return true;
728
+ };
729
+
730
+ try {
731
+ await mergeCommand({ branch: branchName, dryRun: true, json: true });
732
+ } finally {
733
+ process.stdout.write = originalWrite;
734
+ }
735
+
736
+ const parsed = JSON.parse(output);
737
+ expect(parsed.prediction).toBeDefined();
738
+ expect(parsed.prediction.wouldRequireAgent).toBe(true);
739
+ expect(parsed.prediction.predictedTier).toBe("ai-resolve");
740
+ expect(parsed.prediction.conflictFiles).toContain("src/shared.ts");
741
+ expect(typeof parsed.prediction.reason).toBe("string");
742
+
743
+ // Existing dry-run fields must still be present.
744
+ expect(parsed.branchName).toBe(branchName);
745
+ expect(parsed.status).toBe("pending");
746
+ });
747
+
748
+ test("--dry-run --json reports clean-merge prediction for non-conflicting branch", async () => {
749
+ await setupProject(repoDir, defaultBranch);
750
+ const branchName = "overstory/builder/bead-predict-2";
751
+ await createCleanFeatureBranch(repoDir, branchName);
752
+
753
+ let output = "";
754
+ const originalWrite = process.stdout.write.bind(process.stdout);
755
+ process.stdout.write = (chunk: unknown): boolean => {
756
+ output += String(chunk);
757
+ return true;
758
+ };
759
+
760
+ try {
761
+ await mergeCommand({ branch: branchName, dryRun: true, json: true });
762
+ } finally {
763
+ process.stdout.write = originalWrite;
764
+ }
765
+
766
+ const parsed = JSON.parse(output);
767
+ expect(parsed.prediction.predictedTier).toBe("clean-merge");
768
+ expect(parsed.prediction.wouldRequireAgent).toBe(false);
769
+ expect(parsed.prediction.conflictFiles).toEqual([]);
770
+ });
771
+
772
+ test("--all --dry-run --json attaches prediction to each entry", async () => {
773
+ await setupProject(repoDir, defaultBranch);
774
+ const cleanBranch = "overstory/builder/bead-predict-all-clean";
775
+ await createCleanFeatureBranch(repoDir, cleanBranch);
776
+
777
+ // Add a contentful-canonical conflict branch.
778
+ await commitFile(repoDir, "src/shared.ts", "shared base\n");
779
+ const conflictBranch = "overstory/builder/bead-predict-all-conflict";
780
+ await runGitInDir(repoDir, ["checkout", "-b", conflictBranch]);
781
+ await commitFile(repoDir, "src/shared.ts", "feature side\n");
782
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
783
+ await commitFile(repoDir, "src/shared.ts", "main side\n");
784
+
785
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
786
+ const queue = createMergeQueue(queuePath);
787
+ queue.enqueue({
788
+ branchName: cleanBranch,
789
+ taskId: "bead-predict-all-clean",
790
+ agentName: "builder",
791
+ filesModified: [`src/${cleanBranch}.ts`],
792
+ });
793
+ queue.enqueue({
794
+ branchName: conflictBranch,
795
+ taskId: "bead-predict-all-conflict",
796
+ agentName: "builder",
797
+ filesModified: ["src/shared.ts"],
798
+ });
799
+ queue.close();
800
+
801
+ let output = "";
802
+ const originalWrite = process.stdout.write.bind(process.stdout);
803
+ process.stdout.write = (chunk: unknown): boolean => {
804
+ output += String(chunk);
805
+ return true;
806
+ };
807
+
808
+ try {
809
+ await mergeCommand({ all: true, dryRun: true, json: true });
810
+ } finally {
811
+ process.stdout.write = originalWrite;
812
+ }
813
+
814
+ const parsed = JSON.parse(output);
815
+ expect(parsed.entries).toHaveLength(2);
816
+ const cleanEntry = parsed.entries.find(
817
+ (e: { branchName: string }) => e.branchName === cleanBranch,
818
+ );
819
+ const conflictEntry = parsed.entries.find(
820
+ (e: { branchName: string }) => e.branchName === conflictBranch,
821
+ );
822
+ expect(cleanEntry.prediction.predictedTier).toBe("clean-merge");
823
+ expect(cleanEntry.prediction.wouldRequireAgent).toBe(false);
824
+ expect(conflictEntry.prediction.predictedTier).toBe("ai-resolve");
825
+ expect(conflictEntry.prediction.wouldRequireAgent).toBe(true);
826
+ });
827
+ });
828
+
711
829
  describe("conflict handling", () => {
712
830
  test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
713
831
  await setupProject(repoDir, defaultBranch);
@@ -17,10 +17,11 @@ import { MergeError, ValidationError } from "../errors.ts";
17
17
  import { jsonOutput } from "../json.ts";
18
18
  import { accent, printHint } from "../logging/color.ts";
19
19
  import { acquireMergeLock } from "../merge/lock.ts";
20
+ import { predictConflicts } from "../merge/predict.ts";
20
21
  import { createMergeQueue } from "../merge/queue.ts";
21
22
  import { createMergeResolver } from "../merge/resolver.ts";
22
23
  import { createMulchClient } from "../mulch/client.ts";
23
- import type { MergeEntry, MergeResult } from "../types.ts";
24
+ import type { ConflictPrediction, MergeEntry, MergeResult } from "../types.ts";
24
25
 
25
26
  export interface MergeOptions {
26
27
  branch?: string;
@@ -109,7 +110,7 @@ function formatResult(result: MergeResult): string {
109
110
  }
110
111
 
111
112
  /** Format a dry-run report for a merge entry. */
112
- function formatDryRun(entry: MergeEntry): string {
113
+ function formatDryRun(entry: MergeEntry, prediction?: ConflictPrediction): string {
113
114
  const lines: string[] = [
114
115
  `[dry-run] Branch: ${accent(entry.branchName)}`,
115
116
  ` Agent: ${accent(entry.agentName)} | Task: ${accent(entry.taskId)}`,
@@ -123,9 +124,41 @@ function formatDryRun(entry: MergeEntry): string {
123
124
  }
124
125
  }
125
126
 
127
+ if (prediction) {
128
+ const agentSuffix = prediction.wouldRequireAgent ? " (would require merger agent)" : "";
129
+ lines.push(` Prediction: ${prediction.predictedTier}${agentSuffix} — ${prediction.reason}`);
130
+ if (prediction.conflictFiles.length > 0) {
131
+ lines.push(` Conflict files: ${prediction.conflictFiles.join(", ")}`);
132
+ }
133
+ }
134
+
126
135
  return lines.join("\n");
127
136
  }
128
137
 
138
+ /**
139
+ * Predict the merge tier for a single entry, swallowing errors into a
140
+ * deterministic `ai-resolve` envelope so that `--all --dry-run` can keep
141
+ * going if one branch's prediction blows up.
142
+ */
143
+ async function safePredictForEntry(
144
+ entry: MergeEntry,
145
+ canonicalBranch: string,
146
+ repoRoot: string,
147
+ mulchClient: ReturnType<typeof createMulchClient>,
148
+ ): Promise<ConflictPrediction> {
149
+ try {
150
+ return await predictConflicts(entry, canonicalBranch, repoRoot, mulchClient);
151
+ } catch (err) {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ return {
154
+ predictedTier: "ai-resolve",
155
+ conflictFiles: [],
156
+ wouldRequireAgent: true,
157
+ reason: `prediction-failed: ${msg}`,
158
+ };
159
+ }
160
+ }
161
+
129
162
  /**
130
163
  * Entry point for `ov merge [flags]`.
131
164
  *
@@ -238,10 +271,13 @@ async function handleBranch(
238
271
  }
239
272
 
240
273
  if (dryRun) {
274
+ const mulchClient = createMulchClient(config.project.root);
275
+ const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
276
+
241
277
  if (json) {
242
- jsonOutput("merge", { ...entry });
278
+ jsonOutput("merge", { ...entry, prediction });
243
279
  } else {
244
- process.stdout.write(`${formatDryRun(entry)}\n`);
280
+ process.stdout.write(`${formatDryRun(entry, prediction)}\n`);
245
281
  }
246
282
  return;
247
283
  }
@@ -293,14 +329,21 @@ async function handleAll(
293
329
  }
294
330
 
295
331
  if (dryRun) {
332
+ const mulchClient = createMulchClient(config.project.root);
333
+ const enrichedEntries: Array<MergeEntry & { prediction: ConflictPrediction }> = [];
334
+ for (const entry of pendingEntries) {
335
+ const prediction = await safePredictForEntry(entry, canonicalBranch, repoRoot, mulchClient);
336
+ enrichedEntries.push({ ...entry, prediction });
337
+ }
338
+
296
339
  if (json) {
297
- jsonOutput("merge", { entries: pendingEntries });
340
+ jsonOutput("merge", { entries: enrichedEntries });
298
341
  } else {
299
342
  process.stdout.write(
300
- `${pendingEntries.length} pending branch${pendingEntries.length === 1 ? "" : "es"}:\n\n`,
343
+ `${enrichedEntries.length} pending branch${enrichedEntries.length === 1 ? "" : "es"}:\n\n`,
301
344
  );
302
- for (const entry of pendingEntries) {
303
- process.stdout.write(`${formatDryRun(entry)}\n\n`);
345
+ for (const entry of enrichedEntries) {
346
+ process.stdout.write(`${formatDryRun(entry, entry.prediction)}\n\n`);
304
347
  }
305
348
  }
306
349
  return;
@@ -27,6 +27,8 @@ import {
27
27
  isRunningAsRoot,
28
28
  isTaskWorkable,
29
29
  parentHasScouts,
30
+ parseSiblings,
31
+ resolveParentAgent,
30
32
  resolveUseHeadless,
31
33
  shouldShowScoutWarning,
32
34
  validateHierarchy,
@@ -787,6 +789,48 @@ describe("isTaskWorkable", () => {
787
789
  });
788
790
  });
789
791
 
792
+ // --- resolveParentAgent (overstory-de3c) ---
793
+ //
794
+ // Witnessed bug: a coordinator/lead recovered a zombie spawn-per-turn worker
795
+ // via `ov sling --recover --name <existing>` without threading `--parent`.
796
+ // The pre-fix `parentAgent = opts.parent ?? null` overwrote the prior
797
+ // `parent_agent` row to null on upsert, so the runner could not emit
798
+ // `worker_died` on a resumed-turn parser stall — the lead waited forever.
799
+ // The fix: when --parent is not explicitly passed, fall back to the prior
800
+ // session row's parentAgent. Explicit caller intent (any string, including
801
+ // empty) always wins.
802
+ describe("resolveParentAgent", () => {
803
+ test("case A: explicit --parent wins over prior session linkage", () => {
804
+ const existing = { parentAgent: "old-lead" };
805
+ expect(resolveParentAgent("new-lead", existing)).toBe("new-lead");
806
+ });
807
+
808
+ test("case B: --parent omitted preserves prior session's parentAgent on re-spawn", () => {
809
+ // THE REGRESSION CHECK. Pre-fix this returned null, severing the link
810
+ // the runner needs to emit worker_died (overstory-de3c).
811
+ const existing = { parentAgent: "lead-r" };
812
+ expect(resolveParentAgent(undefined, existing)).toBe("lead-r");
813
+ });
814
+
815
+ test("--parent omitted with no prior session yields null (fresh agent)", () => {
816
+ expect(resolveParentAgent(undefined, null)).toBeNull();
817
+ });
818
+
819
+ test("--parent omitted with prior session whose parent is null yields null", () => {
820
+ // A coordinator-spawned root agent has parentAgent=null. Re-spawn must
821
+ // not synthesize a parent.
822
+ const existing = { parentAgent: null };
823
+ expect(resolveParentAgent(undefined, existing)).toBeNull();
824
+ });
825
+
826
+ test("explicit --parent='' (empty string) is honored — caller intent wins", () => {
827
+ // Empty string is `defined` but `null`-y; we honor it as caller intent
828
+ // rather than silently falling back to the prior linkage.
829
+ const existing = { parentAgent: "lead-r" };
830
+ expect(resolveParentAgent("", existing)).toBe("");
831
+ });
832
+ });
833
+
790
834
  describe("checkDuplicateLead", () => {
791
835
  test("returns lead agent name when an active lead exists for the task", () => {
792
836
  const sessions = [
@@ -1167,6 +1211,7 @@ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDis
1167
1211
  capability: "builder",
1168
1212
  specPath: "/path/to/spec.md",
1169
1213
  parentAgent: "lead-alpha",
1214
+ slingerName: null,
1170
1215
  instructionPath: ".claude/CLAUDE.md",
1171
1216
  ...overrides,
1172
1217
  };
@@ -1180,6 +1225,7 @@ describe("buildAutoDispatch", () => {
1180
1225
  capability: "builder",
1181
1226
  specPath: "/path/to/spec.md",
1182
1227
  parentAgent: "lead-alpha",
1228
+ slingerName: null,
1183
1229
  instructionPath: ".claude/CLAUDE.md",
1184
1230
  });
1185
1231
  expect(dispatch.from).toBe("lead-alpha");
@@ -1195,6 +1241,7 @@ describe("buildAutoDispatch", () => {
1195
1241
  capability: "lead",
1196
1242
  specPath: null,
1197
1243
  parentAgent: null,
1244
+ slingerName: null,
1198
1245
  instructionPath: ".claude/CLAUDE.md",
1199
1246
  });
1200
1247
  expect(dispatch.from).toBe("orchestrator");
@@ -1208,6 +1255,7 @@ describe("buildAutoDispatch", () => {
1208
1255
  capability: "scout",
1209
1256
  specPath: null,
1210
1257
  parentAgent: "lead-alpha",
1258
+ slingerName: null,
1211
1259
  instructionPath: ".claude/CLAUDE.md",
1212
1260
  });
1213
1261
  expect(dispatch.body).toContain("scout");
@@ -1220,11 +1268,33 @@ describe("buildAutoDispatch", () => {
1220
1268
  capability: "builder",
1221
1269
  specPath: "/abs/path/to/spec.md",
1222
1270
  parentAgent: "lead-alpha",
1271
+ slingerName: null,
1223
1272
  instructionPath: ".claude/CLAUDE.md",
1224
1273
  });
1225
1274
  expect(dispatch.body).toContain("/abs/path/to/spec.md");
1226
1275
  });
1227
1276
 
1277
+ test("slinger takes precedence over parent agent for from field", () => {
1278
+ const dispatch = buildAutoDispatch(
1279
+ makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: "lead-alpha" }),
1280
+ );
1281
+ expect(dispatch.from).toBe("coordinator");
1282
+ });
1283
+
1284
+ test("slinger fills in when parent agent is null", () => {
1285
+ const dispatch = buildAutoDispatch(
1286
+ makeAutoDispatchOpts({ slingerName: "coordinator", parentAgent: null }),
1287
+ );
1288
+ expect(dispatch.from).toBe("coordinator");
1289
+ });
1290
+
1291
+ test("falls back to orchestrator when both slinger and parent are null", () => {
1292
+ const dispatch = buildAutoDispatch(
1293
+ makeAutoDispatchOpts({ slingerName: null, parentAgent: null }),
1294
+ );
1295
+ expect(dispatch.from).toBe("orchestrator");
1296
+ });
1297
+
1228
1298
  test("subject contains task ID", () => {
1229
1299
  const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "overstory-zz99" }));
1230
1300
  expect(dispatch.subject).toContain("overstory-zz99");
@@ -1477,3 +1547,37 @@ describe("resolveUseHeadless", () => {
1477
1547
  expect(resolveUseHeadless(saplingLike, false, baseConfig)).toBe(true);
1478
1548
  });
1479
1549
  });
1550
+
1551
+ describe("parseSiblings (overstory-f76a)", () => {
1552
+ test("undefined input returns empty array", () => {
1553
+ expect(parseSiblings(undefined)).toEqual([]);
1554
+ });
1555
+
1556
+ test("empty string returns empty array", () => {
1557
+ expect(parseSiblings("")).toEqual([]);
1558
+ });
1559
+
1560
+ test("single name returns one-element array", () => {
1561
+ expect(parseSiblings("sibling-a")).toEqual(["sibling-a"]);
1562
+ });
1563
+
1564
+ test("comma-separated names are split and trimmed", () => {
1565
+ expect(parseSiblings("sibling-a, sibling-b ,sibling-c")).toEqual([
1566
+ "sibling-a",
1567
+ "sibling-b",
1568
+ "sibling-c",
1569
+ ]);
1570
+ });
1571
+
1572
+ test("blank entries between commas are dropped", () => {
1573
+ expect(parseSiblings("sibling-a,,sibling-b, ,sibling-c")).toEqual([
1574
+ "sibling-a",
1575
+ "sibling-b",
1576
+ "sibling-c",
1577
+ ]);
1578
+ });
1579
+
1580
+ test("whitespace-only input returns empty array", () => {
1581
+ expect(parseSiblings(" ")).toEqual([]);
1582
+ });
1583
+ });