@bastani/atomic 0.5.0-3 → 0.5.0-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 (40) hide show
  1. package/.atomic/workflows/hello/claude/index.ts +22 -25
  2. package/.atomic/workflows/hello/copilot/index.ts +41 -31
  3. package/.atomic/workflows/hello/opencode/index.ts +40 -40
  4. package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
  5. package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
  6. package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
  7. package/.atomic/workflows/ralph/claude/index.ts +128 -93
  8. package/.atomic/workflows/ralph/copilot/index.ts +212 -112
  9. package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
  10. package/.atomic/workflows/ralph/opencode/index.ts +174 -111
  11. package/README.md +62 -53
  12. package/package.json +1 -1
  13. package/src/commands/cli/chat/index.ts +28 -8
  14. package/src/commands/cli/init/index.ts +6 -4
  15. package/src/commands/cli/init/scm.ts +27 -10
  16. package/src/sdk/components/connectors.test.ts +45 -0
  17. package/src/sdk/components/layout.test.ts +321 -0
  18. package/src/sdk/components/layout.ts +51 -15
  19. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  20. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  21. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  22. package/src/sdk/components/session-graph-panel.tsx +3 -9
  23. package/src/sdk/components/statusline.tsx +4 -6
  24. package/src/sdk/define-workflow.test.ts +71 -0
  25. package/src/sdk/define-workflow.ts +42 -39
  26. package/src/sdk/errors.ts +1 -1
  27. package/src/sdk/index.ts +4 -1
  28. package/src/sdk/providers/claude.ts +1 -1
  29. package/src/sdk/providers/copilot.ts +5 -3
  30. package/src/sdk/providers/opencode.ts +5 -3
  31. package/src/sdk/runtime/executor.ts +512 -301
  32. package/src/sdk/runtime/loader.ts +2 -2
  33. package/src/sdk/runtime/tmux.ts +31 -2
  34. package/src/sdk/types.ts +93 -20
  35. package/src/sdk/workflows.ts +7 -4
  36. package/src/services/config/definitions.ts +39 -2
  37. package/src/services/config/settings.ts +0 -6
  38. package/src/services/system/skills.ts +3 -7
  39. package/.atomic/workflows/package-lock.json +0 -31
  40. package/.atomic/workflows/package.json +0 -8
@@ -2,7 +2,11 @@ import { join } from "path";
2
2
  import { readdir } from "fs/promises";
3
3
  import { copyFile, pathExists, ensureDir } from "@/services/system/copy.ts";
4
4
  import { getOppositeScriptExtension } from "@/services/system/detect.ts";
5
- import type { AgentKey, SourceControlType } from "@/services/config/index.ts";
5
+ import {
6
+ SCM_SKILLS_BY_TYPE,
7
+ type AgentKey,
8
+ type SourceControlType,
9
+ } from "@/services/config/index.ts";
6
10
 
7
11
  export const SCM_PREFIX_BY_TYPE: Record<SourceControlType, "gh-" | "sl-"> = {
8
12
  github: "gh-",
@@ -127,14 +131,21 @@ export interface InstallLocalScmSkillsOptions {
127
131
 
128
132
  export interface InstallLocalScmSkillsResult {
129
133
  success: boolean;
134
+ /** The explicit skill names that were requested (e.g. `["gh-commit", "gh-create-pr"]`). */
135
+ skills: readonly string[];
130
136
  /** Non-empty when `success` is false. */
131
137
  details: string;
132
138
  }
133
139
 
134
140
  /**
135
- * Install the SCM skill variants (gh-* or sl-*) locally into the current
136
- * project via `npx skills add`. The `-g` flag is intentionally omitted so
137
- * the skills are installed per-project (in the given `cwd`).
141
+ * Install the SCM skill variants (e.g. `gh-commit`, `gh-create-pr` for
142
+ * GitHub) locally into the current project via `npx skills add`. The `-g`
143
+ * flag is intentionally omitted so the skills are installed per-project
144
+ * (in the given `cwd`).
145
+ *
146
+ * Each skill is passed explicitly with `--skill <name>` — the skills CLI
147
+ * does not support glob patterns like `gh-*`, which would either fail or
148
+ * fall back to installing the entire skill set.
138
149
  *
139
150
  * This is best-effort: callers should treat a failed result as a warning,
140
151
  * not as a fatal error.
@@ -144,13 +155,15 @@ export async function installLocalScmSkills(
144
155
  ): Promise<InstallLocalScmSkillsResult> {
145
156
  const { scmType, agentKey, cwd } = options;
146
157
 
158
+ const skills = SCM_SKILLS_BY_TYPE[scmType];
159
+
147
160
  const npxPath = Bun.which("npx");
148
161
  if (!npxPath) {
149
- return { success: false, details: "npx not found on PATH" };
162
+ return { success: false, skills, details: "npx not found on PATH" };
150
163
  }
151
164
 
152
- const pattern = `${getScmPrefix(scmType)}*`;
153
165
  const agentFlag = SKILLS_AGENT_BY_KEY[agentKey];
166
+ const skillFlags = skills.flatMap((skill) => ["--skill", skill]);
154
167
 
155
168
  try {
156
169
  const proc = Bun.spawn({
@@ -160,8 +173,7 @@ export async function installLocalScmSkills(
160
173
  "skills",
161
174
  "add",
162
175
  SKILLS_REPO,
163
- "--skill",
164
- pattern,
176
+ ...skillFlags,
165
177
  "-a",
166
178
  agentFlag,
167
179
  "-y",
@@ -177,13 +189,18 @@ export async function installLocalScmSkills(
177
189
  proc.exited,
178
190
  ]);
179
191
  if (exitCode === 0) {
180
- return { success: true, details: "" };
192
+ return { success: true, skills, details: "" };
181
193
  }
182
194
  const details = stderr.trim().length > 0 ? stderr.trim() : stdout.trim();
183
- return { success: false, details: details || `exit code ${exitCode}` };
195
+ return {
196
+ success: false,
197
+ skills,
198
+ details: details || `exit code ${exitCode}`,
199
+ };
184
200
  } catch (error) {
185
201
  return {
186
202
  success: false,
203
+ skills,
187
204
  details: error instanceof Error ? error.message : String(error),
188
205
  };
189
206
  }
@@ -659,3 +659,48 @@ describe("buildMergeConnector", () => {
659
659
  expect(barLine[84]).toBe("┤");
660
660
  });
661
661
  });
662
+
663
+ // ─── Integration tests: connectors with computeLayout-produced nodes ──────
664
+
665
+ describe("integration with computeLayout", () => {
666
+ test("connector from orchestrator to reclassified orphan child", () => {
667
+ const { computeLayout } = require("./layout.ts");
668
+ const layout = computeLayout([
669
+ { name: "orchestrator", status: "running", parents: [], startedAt: null, endedAt: null },
670
+ { name: "orphan", status: "pending", parents: ["nonexistent"], startedAt: null, endedAt: null },
671
+ ]);
672
+ const orch = layout.map["orchestrator"];
673
+ expect(orch.children).toHaveLength(1);
674
+ const conn = buildConnector(orch, layout.rowH, mockTheme);
675
+ expect(conn).not.toBeNull();
676
+ expect(conn!.height).toBeGreaterThan(0);
677
+ });
678
+
679
+ test("connector from parent to reclassified merge-to-single-parent node", () => {
680
+ const { computeLayout } = require("./layout.ts");
681
+ const layout = computeLayout([
682
+ { name: "orchestrator", status: "running", parents: [], startedAt: null, endedAt: null },
683
+ { name: "A", status: "pending", parents: ["orchestrator"], startedAt: null, endedAt: null },
684
+ { name: "M", status: "pending", parents: ["A", "nonexistent"], startedAt: null, endedAt: null },
685
+ ]);
686
+ const nodeA = layout.map["A"];
687
+ expect(nodeA.children).toHaveLength(1);
688
+ expect(nodeA.children[0].name).toBe("M");
689
+ const conn = buildConnector(nodeA, layout.rowH, mockTheme);
690
+ expect(conn).not.toBeNull();
691
+ });
692
+
693
+ test("merge connector still works for valid merge nodes", () => {
694
+ const { computeLayout } = require("./layout.ts");
695
+ const layout = computeLayout([
696
+ { name: "A", status: "pending", parents: [], startedAt: null, endedAt: null },
697
+ { name: "B", status: "pending", parents: [], startedAt: null, endedAt: null },
698
+ { name: "M", status: "pending", parents: ["A", "B"], startedAt: null, endedAt: null },
699
+ ]);
700
+ const nodeM = layout.map["M"];
701
+ expect(nodeM.parents).toEqual(["A", "B"]);
702
+ const mergeConn = buildMergeConnector(nodeM, layout.rowH, layout.map, mockTheme);
703
+ expect(mergeConn).not.toBeNull();
704
+ expect(mergeConn!.height).toBeGreaterThan(0);
705
+ });
706
+ });
@@ -921,4 +921,325 @@ describe("computeLayout", () => {
921
921
  });
922
922
  }
923
923
  });
924
+
925
+ // ─── Edge cases: parent normalization ─────────
926
+
927
+ describe("missing parent fallback", () => {
928
+ test("session with missing parent falls back to orchestrator child", () => {
929
+ const r = computeLayout([
930
+ session("orchestrator"),
931
+ session("orphan", ["nonexistent"]),
932
+ ]);
933
+ // orphan should be a child of orchestrator, not a root
934
+ expect(r.roots).toHaveLength(1);
935
+ expect(r.roots[0]!.name).toBe("orchestrator");
936
+ expect(r.map["orchestrator"]!.children).toHaveLength(1);
937
+ expect(r.map["orchestrator"]!.children[0]!.name).toBe("orphan");
938
+ expect(r.map["orphan"]!.depth).toBe(1);
939
+ });
940
+
941
+ test("session with missing parent and no orchestrator becomes root", () => {
942
+ const r = computeLayout([
943
+ session("A"),
944
+ session("orphan", ["nonexistent"]),
945
+ ]);
946
+ // Without orchestrator, orphan becomes a root alongside A
947
+ expect(r.roots).toHaveLength(2);
948
+ expect(r.roots.map((n) => n.name)).toContain("orphan");
949
+ });
950
+
951
+ test("preserves raw parents array on LayoutNode even when normalized", () => {
952
+ const r = computeLayout([
953
+ session("orchestrator"),
954
+ session("orphan", ["nonexistent"]),
955
+ ]);
956
+ // raw parents metadata should be unchanged
957
+ expect(r.map["orphan"]!.parents).toEqual(["nonexistent"]);
958
+ });
959
+
960
+ test("multiple orphans all fall back to orchestrator", () => {
961
+ const r = computeLayout([
962
+ session("orchestrator"),
963
+ session("a", ["ghost-1"]),
964
+ session("b", ["ghost-2"]),
965
+ ]);
966
+ expect(r.roots).toHaveLength(1);
967
+ expect(r.map["orchestrator"]!.children).toHaveLength(2);
968
+ expect(r.map["a"]!.depth).toBe(1);
969
+ expect(r.map["b"]!.depth).toBe(1);
970
+ });
971
+
972
+ test("valid parent takes priority over orchestrator fallback", () => {
973
+ const r = computeLayout([
974
+ session("orchestrator"),
975
+ session("step-1", ["orchestrator"]),
976
+ session("child", ["step-1"]),
977
+ ]);
978
+ // child should be under step-1, not orchestrator
979
+ expect(r.map["step-1"]!.children).toHaveLength(1);
980
+ expect(r.map["step-1"]!.children[0]!.name).toBe("child");
981
+ expect(r.map["child"]!.depth).toBe(2);
982
+ });
983
+
984
+ test("orchestrator itself does not get reparented to itself", () => {
985
+ const r = computeLayout([session("orchestrator")]);
986
+ expect(r.roots).toHaveLength(1);
987
+ expect(r.roots[0]!.name).toBe("orchestrator");
988
+ expect(r.map["orchestrator"]!.children).toHaveLength(0);
989
+ });
990
+ });
991
+
992
+ describe("merge node with missing parents", () => {
993
+ test("merge with one missing parent reclassified as single-parent child", () => {
994
+ const r = computeLayout([
995
+ session("orchestrator"),
996
+ session("A", ["orchestrator"]),
997
+ session("M", ["A", "nonexistent"]),
998
+ ]);
999
+ // M should become a single-parent child of A (not a merge node)
1000
+ expect(r.map["A"]!.children).toHaveLength(1);
1001
+ expect(r.map["A"]!.children[0]!.name).toBe("M");
1002
+ expect(r.map["M"]!.depth).toBe(2);
1003
+ });
1004
+
1005
+ test("merge with all missing parents falls back to orchestrator child", () => {
1006
+ const r = computeLayout([
1007
+ session("orchestrator"),
1008
+ session("M", ["ghost-1", "ghost-2"]),
1009
+ ]);
1010
+ // All parents missing → falls back to orchestrator
1011
+ expect(r.map["orchestrator"]!.children).toHaveLength(1);
1012
+ expect(r.map["orchestrator"]!.children[0]!.name).toBe("M");
1013
+ expect(r.map["M"]!.depth).toBe(1);
1014
+ });
1015
+
1016
+ test("merge with duplicate parents after filtering is deduplicated", () => {
1017
+ const r = computeLayout([
1018
+ session("orchestrator"),
1019
+ session("A", ["orchestrator"]),
1020
+ session("M", ["A", "A"]),
1021
+ ]);
1022
+ // Duplicate "A" deduplicated → single parent → tree child of A
1023
+ expect(r.map["A"]!.children).toHaveLength(1);
1024
+ expect(r.map["A"]!.children[0]!.name).toBe("M");
1025
+ });
1026
+ });
1027
+
1028
+ describe("chained merge node ordering", () => {
1029
+ test("merge nodes in reverse order get correct depths", () => {
1030
+ // M2 depends on M1, but M2 appears first in the sessions array
1031
+ const r = computeLayout([
1032
+ session("A"),
1033
+ session("B"),
1034
+ session("C"),
1035
+ session("M2", ["M1", "C"]), // M2 before M1 in array
1036
+ session("M1", ["A", "B"]),
1037
+ ]);
1038
+ expect(r.map["M1"]!.depth).toBe(1);
1039
+ expect(r.map["M2"]!.depth).toBe(2); // must be deeper than M1
1040
+ });
1041
+
1042
+ test("indirect merge dependency: A,B → M1 → X; C,X → M2", () => {
1043
+ const r = computeLayout([
1044
+ session("A"),
1045
+ session("B"),
1046
+ session("C"),
1047
+ session("M1", ["A", "B"]),
1048
+ session("X", ["M1"]),
1049
+ session("M2", ["C", "X"]),
1050
+ ]);
1051
+ expect(r.map["M1"]!.depth).toBe(1);
1052
+ expect(r.map["X"]!.depth).toBe(2);
1053
+ expect(r.map["M2"]!.depth).toBe(3);
1054
+ });
1055
+
1056
+ test("indirect merge dependency in forward session order", () => {
1057
+ const r = computeLayout([
1058
+ session("A"),
1059
+ session("B"),
1060
+ session("C"),
1061
+ session("M1", ["A", "B"]),
1062
+ session("X", ["M1"]),
1063
+ session("M2", ["C", "X"]),
1064
+ ]);
1065
+ expect(r.map["M1"]!.depth).toBe(1);
1066
+ expect(r.map["X"]!.depth).toBe(2);
1067
+ expect(r.map["M2"]!.depth).toBe(3);
1068
+ });
1069
+ });
1070
+
1071
+ describe("deeply nested tree", () => {
1072
+ test("four levels of nesting with orchestrator", () => {
1073
+ const r = computeLayout([
1074
+ session("orchestrator"),
1075
+ session("step-1", ["orchestrator"]),
1076
+ session("step-2", ["step-1"]),
1077
+ session("step-3", ["step-2"]),
1078
+ session("step-4", ["step-3"]),
1079
+ ]);
1080
+ expect(r.map["orchestrator"]!.depth).toBe(0);
1081
+ expect(r.map["step-1"]!.depth).toBe(1);
1082
+ expect(r.map["step-2"]!.depth).toBe(2);
1083
+ expect(r.map["step-3"]!.depth).toBe(3);
1084
+ expect(r.map["step-4"]!.depth).toBe(4);
1085
+ // All share x (single chain)
1086
+ const x0 = r.map["orchestrator"]!.x;
1087
+ for (const name of ["step-1", "step-2", "step-3", "step-4"]) {
1088
+ expect(r.map[name]!.x).toBe(x0);
1089
+ }
1090
+ });
1091
+ });
1092
+
1093
+ describe("merge node as single parent", () => {
1094
+ test("child of merge node is placed correctly", () => {
1095
+ const r = computeLayout([
1096
+ session("A"),
1097
+ session("B"),
1098
+ session("M", ["A", "B"]),
1099
+ session("child", ["M"]),
1100
+ ]);
1101
+ expect(r.map["M"]!.depth).toBe(1);
1102
+ expect(r.map["child"]!.depth).toBe(2);
1103
+ // child should be directly below M
1104
+ expect(r.map["child"]!.x).toBe(r.map["M"]!.x);
1105
+ });
1106
+ });
1107
+
1108
+ describe("wide fan-out from non-root node", () => {
1109
+ test("multiple children of a mid-tree node", () => {
1110
+ const r = computeLayout([
1111
+ session("orchestrator"),
1112
+ session("parent", ["orchestrator"]),
1113
+ session("c1", ["parent"]),
1114
+ session("c2", ["parent"]),
1115
+ session("c3", ["parent"]),
1116
+ ]);
1117
+ expect(r.map["parent"]!.depth).toBe(1);
1118
+ expect(r.map["c1"]!.depth).toBe(2);
1119
+ expect(r.map["c2"]!.depth).toBe(2);
1120
+ expect(r.map["c3"]!.depth).toBe(2);
1121
+ // parent centered over children
1122
+ const parentX = r.map["parent"]!.x;
1123
+ const c1X = r.map["c1"]!.x;
1124
+ const c3X = r.map["c3"]!.x;
1125
+ expect(parentX).toBe(Math.round((c1X + c3X) / 2));
1126
+ });
1127
+ });
1128
+
1129
+ // ─── Extended invariants for new topologies ───
1130
+
1131
+ describe("layout invariants (extended edge cases)", () => {
1132
+ const EDGE_TOPOLOGIES = [
1133
+ {
1134
+ name: "missing parent → orchestrator fallback",
1135
+ sessions: () => [
1136
+ session("orchestrator"),
1137
+ session("orphan", ["nonexistent"]),
1138
+ ],
1139
+ },
1140
+ {
1141
+ name: "merge with missing parent reclassified",
1142
+ sessions: () => [
1143
+ session("orchestrator"),
1144
+ session("A", ["orchestrator"]),
1145
+ session("M", ["A", "nonexistent"]),
1146
+ ],
1147
+ },
1148
+ {
1149
+ name: "chained merges in reverse order",
1150
+ sessions: () => [
1151
+ session("A"),
1152
+ session("B"),
1153
+ session("C"),
1154
+ session("M2", ["M1", "C"]),
1155
+ session("M1", ["A", "B"]),
1156
+ ],
1157
+ },
1158
+ {
1159
+ name: "indirect merge dependency",
1160
+ sessions: () => [
1161
+ session("A"),
1162
+ session("B"),
1163
+ session("C"),
1164
+ session("M1", ["A", "B"]),
1165
+ session("X", ["M1"]),
1166
+ session("M2", ["C", "X"]),
1167
+ ],
1168
+ },
1169
+ {
1170
+ name: "deeply nested (5 levels)",
1171
+ sessions: () => [
1172
+ session("orchestrator"),
1173
+ session("s1", ["orchestrator"]),
1174
+ session("s2", ["s1"]),
1175
+ session("s3", ["s2"]),
1176
+ session("s4", ["s3"]),
1177
+ ],
1178
+ },
1179
+ {
1180
+ name: "wide fan-out from non-root",
1181
+ sessions: () => [
1182
+ session("orchestrator"),
1183
+ session("parent", ["orchestrator"]),
1184
+ session("c1", ["parent"]),
1185
+ session("c2", ["parent"]),
1186
+ session("c3", ["parent"]),
1187
+ ],
1188
+ },
1189
+ ];
1190
+
1191
+ for (const topo of EDGE_TOPOLOGIES) {
1192
+ test(`[${topo.name}] all x values are >= PAD`, () => {
1193
+ const r = computeLayout(topo.sessions());
1194
+ for (const node of Object.values(r.map)) {
1195
+ expect(node.x).toBeGreaterThanOrEqual(PAD);
1196
+ }
1197
+ });
1198
+
1199
+ test(`[${topo.name}] all y values are >= PAD`, () => {
1200
+ const r = computeLayout(topo.sessions());
1201
+ for (const node of Object.values(r.map)) {
1202
+ expect(node.y).toBeGreaterThanOrEqual(PAD);
1203
+ }
1204
+ });
1205
+
1206
+ test(`[${topo.name}] width = max(node.x + NODE_W) + PAD`, () => {
1207
+ const r = computeLayout(topo.sessions());
1208
+ const rightmost = Math.max(...Object.values(r.map).map((n) => n.x + NODE_W));
1209
+ expect(r.width).toBe(rightmost + PAD);
1210
+ });
1211
+
1212
+ test(`[${topo.name}] height = max(node.y + NODE_H) + PAD`, () => {
1213
+ const r = computeLayout(topo.sessions());
1214
+ const bottommost = Math.max(...Object.values(r.map).map((n) => n.y + NODE_H));
1215
+ expect(r.height).toBe(bottommost + PAD);
1216
+ });
1217
+
1218
+ test(`[${topo.name}] nodes at same depth share the same y`, () => {
1219
+ const r = computeLayout(topo.sessions());
1220
+ const byDepth: Record<number, number[]> = {};
1221
+ for (const node of Object.values(r.map)) {
1222
+ (byDepth[node.depth] ??= []).push(node.y);
1223
+ }
1224
+ for (const ys of Object.values(byDepth)) {
1225
+ const first = ys[0]!;
1226
+ for (const y of ys) expect(y).toBe(first);
1227
+ }
1228
+ });
1229
+
1230
+ test(`[${topo.name}] no horizontal overlap between nodes at same depth`, () => {
1231
+ const r = computeLayout(topo.sessions());
1232
+ const byDepth: Record<number, number[]> = {};
1233
+ for (const node of Object.values(r.map)) {
1234
+ (byDepth[node.depth] ??= []).push(node.x);
1235
+ }
1236
+ for (const xs of Object.values(byDepth)) {
1237
+ xs.sort((a, b) => a - b);
1238
+ for (let i = 1; i < xs.length; i++) {
1239
+ expect(xs[i]! - xs[i - 1]!).toBeGreaterThanOrEqual(NODE_W + H_GAP);
1240
+ }
1241
+ }
1242
+ });
1243
+ }
1244
+ });
924
1245
  });
@@ -72,6 +72,32 @@ function resolveOverlaps(map: Record<string, LayoutNode>): void {
72
72
 
73
73
  // ─── Layout Computation ───────────────────────────
74
74
 
75
+ /**
76
+ * Compute effective parents for each session by filtering out references
77
+ * to sessions that don't exist in the map and deduplicating. Orphaned
78
+ * sessions (all parents missing) fall back to the "orchestrator" node
79
+ * when one is present, instead of becoming disconnected roots.
80
+ */
81
+ function normalizeParents(
82
+ sessions: SessionData[],
83
+ map: Record<string, LayoutNode>,
84
+ ): Map<string, string[]> {
85
+ const hasOrchestrator = "orchestrator" in map;
86
+ const effective = new Map<string, string[]>();
87
+
88
+ for (const s of sessions) {
89
+ const valid = [...new Set(s.parents)].filter((p) => p in map);
90
+ if (valid.length > 0) {
91
+ effective.set(s.name, valid);
92
+ } else if (hasOrchestrator && s.name !== "orchestrator") {
93
+ effective.set(s.name, ["orchestrator"]);
94
+ } else {
95
+ effective.set(s.name, []);
96
+ }
97
+ }
98
+ return effective;
99
+ }
100
+
75
101
  export function computeLayout(sessions: SessionData[]): LayoutResult {
76
102
  const map: Record<string, LayoutNode> = {};
77
103
  const roots: LayoutNode[] = [];
@@ -92,27 +118,36 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
92
118
  };
93
119
  }
94
120
 
95
- // Classify: single-parent tree child, multi-parent → merge node, no parent → root
121
+ // Normalize parents: filter missing refs, dedupe, orchestrator fallback
122
+ const effective = normalizeParents(sessions, map);
123
+
124
+ // Classify using effective parents (preserves LayoutNode.parents as raw metadata)
96
125
  for (const s of sessions) {
97
- if (s.parents.length > 1) {
126
+ const ep = effective.get(s.name) ?? [];
127
+ if (ep.length > 1) {
98
128
  mergeNodes.push(map[s.name]!);
99
- } else if (s.parents.length === 1 && map[s.parents[0]!]) {
100
- map[s.parents[0]!]!.children.push(map[s.name]!);
129
+ } else if (ep.length === 1 && map[ep[0]!]) {
130
+ map[ep[0]!]!.children.push(map[s.name]!);
101
131
  } else {
102
132
  roots.push(map[s.name]!);
103
133
  }
104
134
  }
105
135
 
106
- function setDepth(n: LayoutNode, d: number) {
107
- n.depth = d;
108
- for (const c of n.children) setDepth(c, d + 1);
136
+ // Memoized depth resolution — handles tree children, merge nodes,
137
+ // indirect dependencies, and arbitrary session ordering.
138
+ const depthCache = new Map<string, number>();
139
+ function resolveDepth(name: string): number {
140
+ if (depthCache.has(name)) return depthCache.get(name)!;
141
+ depthCache.set(name, 0); // guard against cycles
142
+ const ep = effective.get(name) ?? [];
143
+ if (ep.length === 0) return 0;
144
+ const maxParentDepth = Math.max(...ep.map((p) => resolveDepth(p)));
145
+ const depth = maxParentDepth + 1;
146
+ depthCache.set(name, depth);
147
+ return depth;
109
148
  }
110
- for (const r of roots) setDepth(r, 0);
111
-
112
- // Merge nodes: depth = max(parent depths) + 1, then recurse into children
113
- for (const m of mergeNodes) {
114
- const maxParentDepth = Math.max(...m.parents.map((p) => map[p]?.depth ?? 0));
115
- setDepth(m, maxParentDepth + 1);
149
+ for (const s of sessions) {
150
+ map[s.name]!.depth = resolveDepth(s.name);
116
151
  }
117
152
 
118
153
  const rowH: Record<number, number> = {};
@@ -149,9 +184,10 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
149
184
  firstRoot = false;
150
185
  }
151
186
 
152
- // Place merge nodes centered under all parents (and their sub-trees)
187
+ // Place merge nodes centered under all effective parents (and their sub-trees)
153
188
  for (const m of mergeNodes) {
154
- const parentCenters = m.parents.map((p) => (map[p]?.x ?? 0) + Math.floor(NODE_W / 2));
189
+ const ep = effective.get(m.name) ?? [];
190
+ const parentCenters = ep.map((p) => (map[p]?.x ?? 0) + Math.floor(NODE_W / 2));
155
191
  const avgCenter = Math.round(parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length);
156
192
 
157
193
  if (m.children.length > 0) {