immune-brain 4.6.0 → 5.1.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 (75) hide show
  1. package/README.md +374 -46
  2. package/README.zh-CN.md +384 -47
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -44
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +143 -474
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +78 -87
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8601 -6975
  16. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  17. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  18. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  19. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  20. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  21. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  22. package/plugins/immune-brain/dist/imm-planner.md +23 -8
  23. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  24. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  25. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +231 -10
  26. package/plugins/immune-brain/dist/registry.yaml +9 -9
  27. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  28. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  29. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  30. package/plugins/immune-brain/runtime/assurance/coordinator.ts +83 -67
  31. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  32. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  33. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +78 -373
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +26 -2
  37. package/plugins/immune-brain/runtime/claude/review_host.ts +48 -16
  38. package/plugins/immune-brain/runtime/claude/role_agents.ts +84 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  40. package/plugins/immune-brain/runtime/kernel/application.ts +6 -3
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
  43. package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
  44. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  45. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  46. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  47. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  48. package/plugins/immune-brain/runtime/loop_contract.ts +77 -24
  49. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  50. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  51. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  52. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  53. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +42 -2
  54. package/plugins/immune-brain/runtime/unattended/batch_git.ts +57 -27
  55. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  56. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  57. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  59. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +53 -36
  60. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
  61. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +46 -91
  62. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  63. package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
  64. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  65. package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
  66. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  67. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  68. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  69. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  70. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  72. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  73. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +11 -7
  74. package/plugins/immune-brain/skills/registry.yaml +9 -9
  75. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -581
@@ -24,18 +24,18 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
24
24
  import type { BatchPlanChild } from "./types";
25
25
  import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
26
26
  import {
27
+ createDefaultBatchGitPort,
27
28
  type BatchRunnerGitPort,
28
- type BatchGitPreflightResult,
29
- runBatchGitPreflight,
30
- commitBatchChild,
31
- lookupBatchCommit,
32
29
  } from "./batch_git";
33
30
  import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
31
+ import { laneRejectionReport, runLaneBatch, type LaneOffer } from "./batch_lanes";
34
32
  import {
35
33
  type BatchRunStateRecord,
36
34
  type BatchChildRun,
37
35
  type BatchRunReport,
36
+ isLaneBatchRecord,
38
37
  isTerminalBatchState,
38
+ readAnyBatchRunState,
39
39
  prepareBatchRunState,
40
40
  readBatchRunState,
41
41
  writeBatchRunState,
@@ -43,7 +43,9 @@ import {
43
43
  writeBatchRunReport,
44
44
  } from "./batch_state";
45
45
 
46
- /** Kernel-facing ports the driver needs. Host adapters supply these. */
46
+ /** Kernel-facing ports the driver needs. Host adapters supply these. Git
47
+ * operations are not Kernel-port members: the runner drives them through
48
+ * BatchRunnerGitPort alone (D5 of docs/specs/deepen-authority-seams.spec.md). */
47
49
  export interface BatchRunnerKernelPort {
48
50
  /** Enroll one child through the batch-derived capability. */
49
51
  enrollTask(input: {
@@ -57,31 +59,6 @@ export interface BatchRunnerKernelPort {
57
59
  }): Promise<{ record_revision: string }>;
58
60
  /** Drive one enrolled child toward its own Kernel terminal settlement. */
59
61
  advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
60
- /** Scope-bound commit after Kernel reports a child done. */
61
- commitChild?(
62
- root: string,
63
- taskId: string,
64
- batchId: string,
65
- head: string,
66
- branch?: string,
67
- intentPath?: string,
68
- ): Promise<{ commit: string }>;
69
- /** review-3(5th round): read the already-created batch commit for a child,
70
- * or null when none exists. Lets crash recovery adopt an existing commit
71
- * instead of replaying the commitChild mutation. */
72
- lookupBatchCommit?(
73
- root: string,
74
- taskId: string,
75
- batchId: string,
76
- expectedHead?: string,
77
- branch?: string,
78
- ): Promise<{ commit: string } | null>;
79
- /** Optional Git preflight check. */
80
- gitPreflight?(input: {
81
- root: string;
82
- initiative_slug: string;
83
- base_head: string;
84
- }): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
85
62
  /** review-2(5th round): read-only claim projection for enrollment
86
63
  * reconciliation after an interruption between enrollTask and its state
87
64
  * persistence. Uses the real AssuranceProjectionResult contract; batch
@@ -127,6 +104,16 @@ export interface StartBatchInput {
127
104
  now: string;
128
105
  kernel: BatchRunnerKernelPort;
129
106
  git?: BatchRunnerGitPort;
107
+ /** Present only in lane mode: how many children may run at once. */
108
+ max_parallel?: number;
109
+ /** Present only in lane mode: Lanes someone else created, offered to the batch. */
110
+ lane_offers?: LaneOffer[];
111
+ }
112
+
113
+ /** One git seam: the injected BatchRunnerGitPort, or the default adapter over
114
+ * the real repository operations when the Host supplies none. */
115
+ function batchGitPortOf(input: StartBatchInput): BatchRunnerGitPort {
116
+ return input.git ?? createDefaultBatchGitPort();
130
117
  }
131
118
 
132
119
  export type { BatchRunReport } from "./batch_state";
@@ -340,16 +327,10 @@ async function validatePersistedRun(input: StartBatchInput, record: BatchRunStat
340
327
  if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
341
328
  continue;
342
329
  }
343
- const evidence = input.git?.lookupBatchCommit
344
- ? await input.git.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
345
- : input.kernel.lookupBatchCommit
346
- ? await input.kernel.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
347
- : await lookupBatchCommit({
348
- root: input.root,
349
- taskId: child.task_id,
350
- batchId: record.batch_id,
351
- branch: record.branch,
352
- });
330
+ const git = batchGitPortOf(input);
331
+ const evidence = await git.lookupBatchCommit(
332
+ input.root, child.task_id, record.batch_id, undefined, record.branch,
333
+ );
353
334
  if (!evidence || evidence.commit !== child.commit) {
354
335
  // Distinguish an unreachable recorded commit (external HEAD regression)
355
336
  // from a fabricated record: reachability is only checkable in a real repo.
@@ -461,11 +442,17 @@ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport
461
442
  function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
462
443
  const persisted = (() => {
463
444
  try {
464
- return readBatchRunState(input.root, input.batch_id);
445
+ return readAnyBatchRunState(input.root, input.batch_id);
465
446
  } catch {
466
447
  return null;
467
448
  }
468
449
  })();
450
+ if (persisted !== null && isLaneBatchRecord(persisted))
451
+ return laneRejectionReport(
452
+ persisted,
453
+ reason,
454
+ "settle the reported kernel store condition and retry in the current Host",
455
+ ) as unknown as BatchRunReport;
469
456
  return reportFor(
470
457
  {
471
458
  ...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
@@ -477,6 +464,12 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
477
464
  }
478
465
 
479
466
  async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
467
+ // Lane mode is selected by `max_parallel` or by a recorded v2 batch; a batch
468
+ // given neither keeps the serial path below unchanged.
469
+ const recorded = readAnyBatchRunState(input.root, input.batch_id);
470
+ if (input.max_parallel !== undefined || (recorded !== null && isLaneBatchRecord(recorded)))
471
+ return (await runLaneBatch(input, recorded)) as unknown as BatchRunReport;
472
+
480
473
  // Validation failure before the first enrollment: zero writes, rejected.
481
474
  // reportFor only builds the report object; finalize would persist it and
482
475
  // the spec forbids any write on a pre-enrollment rejection.
@@ -620,23 +613,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
620
613
  }
621
614
 
622
615
  // Mandatory batch branch preflight: run before any child is enrolled.
623
- const preflightResult = input.git?.preflight
624
- ? await input.git.preflight({
625
- root: input.root,
626
- initiative_slug: input.initiative_slug,
627
- base_head: input.base_head,
628
- })
629
- : input.kernel.gitPreflight
630
- ? await input.kernel.gitPreflight({
631
- root: input.root,
632
- initiative_slug: input.initiative_slug,
633
- base_head: input.base_head,
634
- })
635
- : runBatchGitPreflight({
636
- root: input.root,
637
- initiative_slug: input.initiative_slug,
638
- base_head: input.base_head,
639
- });
616
+ const preflightResult = await batchGitPortOf(input).preflight({
617
+ root: input.root,
618
+ initiative_slug: input.initiative_slug,
619
+ base_head: input.base_head,
620
+ });
640
621
 
641
622
  if (!preflightResult.ok) {
642
623
  const rejected = prepareBatchRunState({ ...input, now: input.now });
@@ -1027,23 +1008,9 @@ async function driveInterruptedChild(
1027
1008
  // review-1(6th round): a lookup failure must fail closed, not fall
1028
1009
  // through to commitChild, which could replay an existing commit.
1029
1010
  try {
1030
- existing = input.git?.lookupBatchCommit
1031
- ? await input.git.lookupBatchCommit(input.root, child.task_id, input.batch_id, head, record.branch)
1032
- : input.kernel.lookupBatchCommit
1033
- ? await input.kernel.lookupBatchCommit(
1034
- input.root,
1035
- child.task_id,
1036
- input.batch_id,
1037
- head,
1038
- record.branch,
1039
- )
1040
- : await lookupBatchCommit({
1041
- root: input.root,
1042
- taskId: child.task_id,
1043
- batchId: input.batch_id,
1044
- expectedHead: head,
1045
- branch: record.branch,
1046
- });
1011
+ existing = await batchGitPortOf(input).lookupBatchCommit(
1012
+ input.root, child.task_id, input.batch_id, head, record.branch,
1013
+ );
1047
1014
  } catch (error: unknown) {
1048
1015
  const message =
1049
1016
  error instanceof Error ? error.message : String(error);
@@ -1075,22 +1042,10 @@ async function driveInterruptedChild(
1075
1042
  }
1076
1043
  const planChild = input.children.find((c) => c.task_id === child.task_id);
1077
1044
  const intentPath = planChild?.intent_path ?? undefined;
1078
- const doCommit = async () => {
1079
- if (input.git?.commitChild) {
1080
- return input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1081
- }
1082
- if (input.kernel.commitChild) {
1083
- return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1084
- }
1085
- return commitBatchChild({
1086
- root: input.root,
1087
- taskId: child.task_id,
1088
- batchId: input.batch_id,
1089
- expectedHead: head,
1090
- branch: record.branch,
1091
- intentPath,
1092
- });
1093
- };
1045
+ const doCommit = async () =>
1046
+ batchGitPortOf(input).commitChild(
1047
+ input.root, child.task_id, input.batch_id, head, record.branch, intentPath,
1048
+ );
1094
1049
  if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
1095
1050
  throw new Error("first unpersisted batch commit provenance is invalid");
1096
1051
  const adopted =
@@ -0,0 +1,162 @@
1
+ // Pure scheduling for unattended batches. No I/O, no persisted state, no Host
2
+ // concept: given child states, dependency edges, scope hints and a parallelism
3
+ // limit it answers which children may start now, and which children of a plan
4
+ // serialize because their scopes cannot be proved disjoint.
5
+ import type { BatchPlanParallelGroups, BatchPlanScopeConflict } from "./types";
6
+
7
+ /**
8
+ * Child states the scheduler understands. The serial record (v1) uses
9
+ * `committed`; the lane record names the same milestone `integrated`/`released`
10
+ * and adds the in-flight lane states. Listing both keeps this function usable
11
+ * for either record without a persisted-state change.
12
+ */
13
+ export type ScheduleChildState =
14
+ | "pending"
15
+ | "lane_admitted"
16
+ | "enrolled"
17
+ | "settled"
18
+ | "lane_committed"
19
+ | "committed"
20
+ | "integrated"
21
+ | "released"
22
+ | "needs_human"
23
+ | "skipped_blocked";
24
+
25
+ const IN_FLIGHT_STATES: ReadonlySet<ScheduleChildState> = new Set([
26
+ "lane_admitted",
27
+ "enrolled",
28
+ "settled",
29
+ "lane_committed",
30
+ ]);
31
+
32
+ const DONE_STATES: ReadonlySet<ScheduleChildState> = new Set(["committed", "integrated", "released"]);
33
+
34
+ export interface ScheduleChild {
35
+ task_id: string;
36
+ state: ScheduleChildState;
37
+ blocked_by: string[];
38
+ scope_hint: string[];
39
+ }
40
+
41
+ const WILDCARD = /[*?[\]{}!]/;
42
+
43
+ /**
44
+ * Reduce one scope entry to the directory prefix it is certain to live under.
45
+ * A literal path keeps all its segments. A pattern keeps only the whole
46
+ * segments before its first wildcard: `src/foo*` could match `src/foobar/x`, so
47
+ * the partial segment `foo` is not a safe prefix. An empty result means the
48
+ * entry can match anywhere. Anything that is not a plain repository-relative
49
+ * path is also treated as matching anywhere, because disjointness is only ever
50
+ * claimed for entries that can be proved.
51
+ */
52
+ function scopePrefix(entry: string): string[] {
53
+ if (typeof entry !== "string") return [];
54
+ let value = entry.trim().toLowerCase();
55
+ while (value.startsWith("./")) value = value.slice(2);
56
+ if (!value || value.startsWith("/") || value.includes("\\")) return [];
57
+ const wildcard = value.search(WILDCARD);
58
+ const literal = wildcard === -1 ? value : value.slice(0, value.lastIndexOf("/", wildcard) + 1);
59
+ const segments = literal.split("/").filter((segment) => segment.length > 0);
60
+ if (segments.some((segment) => segment === "." || segment === "..")) return [];
61
+ return segments;
62
+ }
63
+
64
+ function prefixOverlaps(left: string[], right: string[]): boolean {
65
+ if (!left.length || !right.length) return true;
66
+ const shared = Math.min(left.length, right.length);
67
+ for (let index = 0; index < shared; index += 1) if (left[index] !== right[index]) return false;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Two scope lists overlap unless every pair of entries is provably disjoint:
73
+ * neither entry's directory prefix equals or contains the other. Identical,
74
+ * nested, and wildcard scopes therefore overlap; an empty list proves nothing
75
+ * about the child and overlaps everything.
76
+ */
77
+ export function scopesOverlap(left: readonly string[], right: readonly string[]): boolean {
78
+ if (!left.length || !right.length) return true;
79
+ const rightPrefixes = right.map(scopePrefix);
80
+ return left.some((entry) => {
81
+ const prefix = scopePrefix(entry);
82
+ return rightPrefixes.some((other) => prefixOverlaps(prefix, other));
83
+ });
84
+ }
85
+
86
+ function positiveLimit(maxParallel: number): number {
87
+ if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
88
+ if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
89
+ throw new Error("max_parallel must be a positive safe integer");
90
+ return maxParallel;
91
+ }
92
+
93
+ /**
94
+ * The children that may start now, in record order. A pending child starts when
95
+ * every dependency is done, its scope is disjoint from every in-flight child and
96
+ * from every child already chosen in this call, and the in-flight count stays
97
+ * below `maxParallel`. With `maxParallel` 1 and nothing in flight this is
98
+ * exactly the serial runner's choice: the first pending child whose
99
+ * dependencies are done.
100
+ */
101
+ export function startableChildren(children: readonly ScheduleChild[], maxParallel: number): string[] {
102
+ const limit = positiveLimit(maxParallel);
103
+ const done = new Set(children.filter((child) => DONE_STATES.has(child.state)).map((child) => child.task_id));
104
+ const occupied = children.filter((child) => IN_FLIGHT_STATES.has(child.state));
105
+ const startable: string[] = [];
106
+ for (const child of children) {
107
+ if (occupied.length >= limit) break;
108
+ if (child.state !== "pending") continue;
109
+ if (!child.blocked_by.every((id) => done.has(id))) continue;
110
+ if (occupied.some((other) => scopesOverlap(child.scope_hint, other.scope_hint))) continue;
111
+ startable.push(child.task_id);
112
+ occupied.push(child);
113
+ }
114
+ return startable;
115
+ }
116
+
117
+ export interface PlanScheduleChild {
118
+ task_id: string;
119
+ blocked_by: string[];
120
+ scope_hint: string[];
121
+ }
122
+
123
+ /**
124
+ * Project a plan's enrollable children into start waves. Each wave is the
125
+ * largest set that is ready (every dependency in an earlier wave) and pairwise
126
+ * scope-disjoint, chosen in plan order. A child that was ready but left out of
127
+ * a wave because its scope overlaps a member of that wave is reported once with
128
+ * every child it overlapped, so a confirmation can say why it serializes.
129
+ */
130
+ export function projectParallelGroups(children: readonly PlanScheduleChild[]): {
131
+ parallel_groups: BatchPlanParallelGroups;
132
+ scope_conflicts: BatchPlanScopeConflict[];
133
+ } {
134
+ const remaining = [...children];
135
+ const placed = new Set<string>();
136
+ const groups: BatchPlanParallelGroups = [];
137
+ const conflicts = new Map<string, Set<string>>();
138
+ while (remaining.length) {
139
+ const ready = remaining.filter((child) => child.blocked_by.every((id) => placed.has(id)));
140
+ if (!ready.length) throw new Error("Initiative Task dependencies must form an acyclic graph");
141
+ const wave: PlanScheduleChild[] = [];
142
+ for (const child of ready) {
143
+ const overlapped = wave.filter((member) => scopesOverlap(child.scope_hint, member.scope_hint));
144
+ if (overlapped.length) {
145
+ const known = conflicts.get(child.task_id) ?? new Set<string>();
146
+ for (const member of overlapped) known.add(member.task_id);
147
+ conflicts.set(child.task_id, known);
148
+ continue;
149
+ }
150
+ wave.push(child);
151
+ }
152
+ groups.push(wave.map((child) => child.task_id));
153
+ for (const child of wave) {
154
+ placed.add(child.task_id);
155
+ remaining.splice(remaining.indexOf(child), 1);
156
+ }
157
+ }
158
+ const scopeConflicts = children
159
+ .filter((child) => conflicts.has(child.task_id))
160
+ .map((child) => ({ task_id: child.task_id, overlaps_with: [...conflicts.get(child.task_id)!].sort() }));
161
+ return { parallel_groups: groups, scope_conflicts: scopeConflicts };
162
+ }