@bastani/atomic 0.5.0-3 → 0.5.0-5

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 (42) 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 +138 -59
  12. package/package.json +1 -1
  13. package/src/cli.ts +0 -2
  14. package/src/commands/cli/chat/index.ts +28 -8
  15. package/src/commands/cli/init/index.ts +7 -10
  16. package/src/commands/cli/init/scm.ts +27 -10
  17. package/src/sdk/components/connectors.test.ts +45 -0
  18. package/src/sdk/components/layout.test.ts +321 -0
  19. package/src/sdk/components/layout.ts +51 -15
  20. package/src/sdk/components/orchestrator-panel-contexts.ts +13 -4
  21. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  22. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  23. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  24. package/src/sdk/components/session-graph-panel.tsx +8 -15
  25. package/src/sdk/components/statusline.tsx +4 -6
  26. package/src/sdk/define-workflow.test.ts +71 -0
  27. package/src/sdk/define-workflow.ts +42 -39
  28. package/src/sdk/errors.ts +1 -1
  29. package/src/sdk/index.ts +4 -1
  30. package/src/sdk/providers/claude.ts +1 -1
  31. package/src/sdk/providers/copilot.ts +5 -3
  32. package/src/sdk/providers/opencode.ts +5 -3
  33. package/src/sdk/runtime/executor.ts +512 -301
  34. package/src/sdk/runtime/loader.ts +2 -2
  35. package/src/sdk/runtime/tmux.ts +31 -2
  36. package/src/sdk/types.ts +93 -20
  37. package/src/sdk/workflows.ts +7 -4
  38. package/src/services/config/definitions.ts +39 -2
  39. package/src/services/config/settings.ts +0 -6
  40. package/src/services/system/skills.ts +3 -7
  41. package/.atomic/workflows/package-lock.json +0 -31
  42. package/.atomic/workflows/package.json +0 -8
@@ -92,14 +92,20 @@ function buildLauncherScript(
92
92
  cmd: string,
93
93
  args: string[],
94
94
  projectRoot: string,
95
+ envVars: Record<string, string> = {},
95
96
  ): { script: string; ext: string } {
96
97
  const isWin = process.platform === "win32";
98
+ const envEntries = Object.entries(envVars);
97
99
 
98
100
  if (isWin) {
99
101
  // PowerShell: use array splatting for safe arg passing
100
102
  const argList = args.map((a) => `"${escPwsh(a)}"`).join(", ");
103
+ const envLines = envEntries.map(
104
+ ([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
105
+ );
101
106
  const script = [
102
107
  `Set-Location "${escPwsh(projectRoot)}"`,
108
+ ...envLines,
103
109
  argList.length > 0
104
110
  ? `& "${escPwsh(cmd)}" @(${argList})`
105
111
  : `& "${escPwsh(cmd)}"`,
@@ -111,9 +117,13 @@ function buildLauncherScript(
111
117
  const quotedArgs = args
112
118
  .map((a) => `"${escBash(a)}"`)
113
119
  .join(" ");
120
+ const envLines = envEntries.map(
121
+ ([key, value]) => `export ${key}="${escBash(value)}"`,
122
+ );
114
123
  const script = [
115
124
  "#!/bin/bash",
116
125
  `cd "${escBash(projectRoot)}"`,
126
+ ...envLines,
117
127
  `exec "${escBash(cmd)}" ${quotedArgs}`,
118
128
  ].join("\n");
119
129
  return { script, ext: "sh" };
@@ -162,15 +172,16 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
162
172
  // ── Build argv ──
163
173
  const args = buildAgentArgs(agentType, passthroughArgs);
164
174
  const cmd = [config.cmd, ...args];
175
+ const envVars = config.env_vars;
165
176
 
166
177
  // ── Inside tmux: spawn inline in the current pane ──
167
178
  if (isInsideTmux()) {
168
- return spawnDirect(cmd, projectRoot);
179
+ return spawnDirect(cmd, projectRoot, envVars);
169
180
  }
170
181
 
171
182
  // ── No TTY: tmux attach requires a real terminal ──
172
183
  if (!process.stdin.isTTY) {
173
- return spawnDirect(cmd, projectRoot);
184
+ return spawnDirect(cmd, projectRoot, envVars);
174
185
  }
175
186
 
176
187
  // ── Ensure tmux is available ──
@@ -184,7 +195,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
184
195
  }
185
196
  if (!isTmuxInstalled()) {
186
197
  // No tmux available — fall back to direct spawn
187
- return spawnDirect(cmd, projectRoot);
198
+ return spawnDirect(cmd, projectRoot, envVars);
188
199
  }
189
200
  }
190
201
 
@@ -194,7 +205,12 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
194
205
 
195
206
  const sessionsDir = join(homedir(), ".atomic", "sessions", "chat");
196
207
  await mkdir(sessionsDir, { recursive: true });
197
- const { script, ext } = buildLauncherScript(config.cmd, args, projectRoot);
208
+ const { script, ext } = buildLauncherScript(
209
+ config.cmd,
210
+ args,
211
+ projectRoot,
212
+ envVars,
213
+ );
198
214
  const launcherPath = join(sessionsDir, `${windowName}.${ext}`);
199
215
  await writeFile(launcherPath, script, { mode: 0o755 });
200
216
 
@@ -218,7 +234,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
218
234
  // If tmux attach itself failed (e.g. lost TTY), clean up and fall back
219
235
  if (exitCode !== 0) {
220
236
  try { killSession(windowName); } catch {}
221
- return spawnDirect(cmd, projectRoot);
237
+ return spawnDirect(cmd, projectRoot, envVars);
222
238
  }
223
239
 
224
240
  return exitCode;
@@ -228,7 +244,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
228
244
  console.error(
229
245
  `${COLORS.yellow}Warning: Failed to create tmux session (${message}). Falling back to direct spawn.${COLORS.reset}`
230
246
  );
231
- return spawnDirect(cmd, projectRoot);
247
+ return spawnDirect(cmd, projectRoot, envVars);
232
248
  }
233
249
  }
234
250
 
@@ -236,11 +252,15 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
236
252
  * Spawn the agent CLI directly with inherited stdio.
237
253
  * Used when not inside tmux.
238
254
  */
239
- async function spawnDirect(cmd: string[], projectRoot: string): Promise<number> {
255
+ async function spawnDirect(
256
+ cmd: string[],
257
+ projectRoot: string,
258
+ envVars: Record<string, string> = {},
259
+ ): Promise<number> {
240
260
  const proc = Bun.spawn(cmd, {
241
261
  stdio: ["inherit", "inherit", "inherit"],
242
262
  cwd: projectRoot,
243
- env: { ...process.env },
263
+ env: { ...process.env, ...envVars },
244
264
  });
245
265
 
246
266
  return await proc.exited;
@@ -21,6 +21,7 @@ import {
21
21
  getAgentKeys,
22
22
  isValidAgent,
23
23
  SCM_CONFIG,
24
+ SCM_SKILLS_BY_TYPE,
24
25
  type SourceControlType,
25
26
  getScmKeys,
26
27
  isValidScm,
@@ -35,7 +36,6 @@ import {
35
36
  getTemplateAgentFolder,
36
37
  } from "@/services/config/atomic-global-config.ts";
37
38
  import {
38
- getScmPrefix,
39
39
  installLocalScmSkills,
40
40
  reconcileScmVariants,
41
41
  syncProjectScmSkills,
@@ -167,8 +167,6 @@ interface InitOptions {
167
167
  /** Pre-selected source control type (skip SCM selection prompt) */
168
168
  preSelectedScm?: SourceControlType;
169
169
  configNotFoundMessage?: string;
170
- /** Force overwrite of preserved files (bypass preservation/merge logic) */
171
- force?: boolean;
172
170
  /** Auto-confirm all prompts (non-interactive mode for CI/testing) */
173
171
  yes?: boolean;
174
172
  /**
@@ -330,10 +328,7 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
330
328
  const targetFolder = join(targetDir, agent.folder);
331
329
  const folderExists = await pathExists(targetFolder);
332
330
 
333
- // --force bypasses update confirmation prompts.
334
- const shouldForce = options.force ?? false;
335
-
336
- if (folderExists && !shouldForce && !autoConfirm) {
331
+ if (folderExists && !autoConfirm) {
337
332
  const update = await confirm({
338
333
  message: `${agent.folder} already exists. Update source control skills?`,
339
334
  initialValue: true,
@@ -404,9 +399,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
404
399
  // skip the network-backed skills CLI in that case to keep dev iteration
405
400
  // fast and offline-friendly.
406
401
  if (import.meta.dir.includes("node_modules")) {
402
+ const skillsToInstall = SCM_SKILLS_BY_TYPE[scmType];
403
+ const skillsLabel = skillsToInstall.join(", ");
407
404
  const skillsSpinner = spinner();
408
405
  skillsSpinner.start(
409
- `Installing ${getScmPrefix(scmType)}* skills locally for ${agent.name}...`,
406
+ `Installing ${skillsLabel} locally for ${agent.name}...`,
410
407
  );
411
408
  const skillsResult = await installLocalScmSkills({
412
409
  scmType,
@@ -415,11 +412,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
415
412
  });
416
413
  if (skillsResult.success) {
417
414
  skillsSpinner.stop(
418
- `Installed ${getScmPrefix(scmType)}* skills locally for ${agent.name}`,
415
+ `Installed ${skillsLabel} locally for ${agent.name}`,
419
416
  );
420
417
  } else {
421
418
  skillsSpinner.stop(
422
- `Skipped local ${getScmPrefix(scmType)}* skills install (${skillsResult.details})`,
419
+ `Skipped local ${skillsLabel} install (${skillsResult.details})`,
423
420
  );
424
421
  }
425
422
  }
@@ -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
  });