@os-eco/overstory-cli 0.9.4 → 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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -4,7 +4,7 @@ import { mkdtemp } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
7
- import { HierarchyError } from "../errors.ts";
7
+ import { HierarchyError, ValidationError } from "../errors.ts";
8
8
  import { ClaudeRuntime } from "../runtimes/claude.ts";
9
9
  import { getRuntime } from "../runtimes/registry.ts";
10
10
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
@@ -25,7 +25,11 @@ import {
25
25
  getSharedWritableDirs,
26
26
  inferDomainsFromFiles,
27
27
  isRunningAsRoot,
28
+ isTaskWorkable,
28
29
  parentHasScouts,
30
+ parseSiblings,
31
+ resolveParentAgent,
32
+ resolveUseHeadless,
29
33
  shouldShowScoutWarning,
30
34
  validateHierarchy,
31
35
  } from "./sling.ts";
@@ -766,6 +770,67 @@ function makeLeadSession(
766
770
  return { agentName, taskId, capability };
767
771
  }
768
772
 
773
+ describe("isTaskWorkable", () => {
774
+ test("accepts open and in_progress without recover", () => {
775
+ expect(isTaskWorkable("open", false)).toBe(true);
776
+ expect(isTaskWorkable("in_progress", false)).toBe(true);
777
+ });
778
+
779
+ test("rejects closed and other terminal statuses without recover", () => {
780
+ expect(isTaskWorkable("closed", false)).toBe(false);
781
+ expect(isTaskWorkable("cancelled", false)).toBe(false);
782
+ expect(isTaskWorkable("done", false)).toBe(false);
783
+ });
784
+
785
+ test("accepts any status when recover is true", () => {
786
+ expect(isTaskWorkable("closed", true)).toBe(true);
787
+ expect(isTaskWorkable("cancelled", true)).toBe(true);
788
+ expect(isTaskWorkable("open", true)).toBe(true);
789
+ });
790
+ });
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
+
769
834
  describe("checkDuplicateLead", () => {
770
835
  test("returns lead agent name when an active lead exists for the task", () => {
771
836
  const sessions = [
@@ -1146,6 +1211,7 @@ function makeAutoDispatchOpts(overrides?: Partial<AutoDispatchOptions>): AutoDis
1146
1211
  capability: "builder",
1147
1212
  specPath: "/path/to/spec.md",
1148
1213
  parentAgent: "lead-alpha",
1214
+ slingerName: null,
1149
1215
  instructionPath: ".claude/CLAUDE.md",
1150
1216
  ...overrides,
1151
1217
  };
@@ -1159,6 +1225,7 @@ describe("buildAutoDispatch", () => {
1159
1225
  capability: "builder",
1160
1226
  specPath: "/path/to/spec.md",
1161
1227
  parentAgent: "lead-alpha",
1228
+ slingerName: null,
1162
1229
  instructionPath: ".claude/CLAUDE.md",
1163
1230
  });
1164
1231
  expect(dispatch.from).toBe("lead-alpha");
@@ -1174,6 +1241,7 @@ describe("buildAutoDispatch", () => {
1174
1241
  capability: "lead",
1175
1242
  specPath: null,
1176
1243
  parentAgent: null,
1244
+ slingerName: null,
1177
1245
  instructionPath: ".claude/CLAUDE.md",
1178
1246
  });
1179
1247
  expect(dispatch.from).toBe("orchestrator");
@@ -1187,6 +1255,7 @@ describe("buildAutoDispatch", () => {
1187
1255
  capability: "scout",
1188
1256
  specPath: null,
1189
1257
  parentAgent: "lead-alpha",
1258
+ slingerName: null,
1190
1259
  instructionPath: ".claude/CLAUDE.md",
1191
1260
  });
1192
1261
  expect(dispatch.body).toContain("scout");
@@ -1199,11 +1268,33 @@ describe("buildAutoDispatch", () => {
1199
1268
  capability: "builder",
1200
1269
  specPath: "/abs/path/to/spec.md",
1201
1270
  parentAgent: "lead-alpha",
1271
+ slingerName: null,
1202
1272
  instructionPath: ".claude/CLAUDE.md",
1203
1273
  });
1204
1274
  expect(dispatch.body).toContain("/abs/path/to/spec.md");
1205
1275
  });
1206
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
+
1207
1298
  test("subject contains task ID", () => {
1208
1299
  const dispatch = buildAutoDispatch(makeAutoDispatchOpts({ taskId: "overstory-zz99" }));
1209
1300
  expect(dispatch.subject).toContain("overstory-zz99");
@@ -1405,3 +1496,88 @@ describe("getCurrentBranch", () => {
1405
1496
  }
1406
1497
  });
1407
1498
  });
1499
+
1500
+ describe("resolveUseHeadless", () => {
1501
+ const claudeLike = { id: "claude", buildDirectSpawn: () => [] as string[] };
1502
+ const claudeNoSpawn = { id: "claude" };
1503
+ const saplingLike = {
1504
+ id: "sapling",
1505
+ headless: true as const,
1506
+ buildDirectSpawn: () => [] as string[],
1507
+ };
1508
+ const codexLike = { id: "codex" };
1509
+ const baseConfig = {} as OverstoryConfig;
1510
+ const headlessByDefaultConfig = {
1511
+ runtime: { default: "claude", claudeHeadlessByDefault: true },
1512
+ } as unknown as OverstoryConfig;
1513
+
1514
+ test("statically headless runtime returns true regardless of flag", () => {
1515
+ expect(resolveUseHeadless(saplingLike, undefined, baseConfig)).toBe(true);
1516
+ });
1517
+
1518
+ test("claude + no flag + base config returns false (default tmux)", () => {
1519
+ expect(resolveUseHeadless(claudeLike, undefined, baseConfig)).toBe(false);
1520
+ });
1521
+
1522
+ test("claude + no flag + claudeHeadlessByDefault:true returns true", () => {
1523
+ expect(resolveUseHeadless(claudeLike, undefined, headlessByDefaultConfig)).toBe(true);
1524
+ });
1525
+
1526
+ test("claude + flag:true + base config returns true", () => {
1527
+ expect(resolveUseHeadless(claudeLike, true, baseConfig)).toBe(true);
1528
+ });
1529
+
1530
+ test("claude + flag:false + claudeHeadlessByDefault:true returns false (flag wins)", () => {
1531
+ expect(resolveUseHeadless(claudeLike, false, headlessByDefaultConfig)).toBe(false);
1532
+ });
1533
+
1534
+ test("claude without buildDirectSpawn + flag:true throws ValidationError", () => {
1535
+ expect(() => resolveUseHeadless(claudeNoSpawn, true, baseConfig)).toThrow(ValidationError);
1536
+ });
1537
+
1538
+ test("codex + claudeHeadlessByDefault:true returns false (config knob is Claude-only)", () => {
1539
+ expect(resolveUseHeadless(codexLike, undefined, headlessByDefaultConfig)).toBe(false);
1540
+ });
1541
+
1542
+ test("codex + flag:true throws ValidationError (no buildDirectSpawn)", () => {
1543
+ expect(() => resolveUseHeadless(codexLike, true, baseConfig)).toThrow(ValidationError);
1544
+ });
1545
+
1546
+ test("sapling + flag:false returns true (statically headless wins over flag)", () => {
1547
+ expect(resolveUseHeadless(saplingLike, false, baseConfig)).toBe(true);
1548
+ });
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
+ });