@zq-silk/yui 0.2.0 → 0.4.2

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 (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -1,23 +1,88 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { resolve } from "node:path";
2
+ import { realpathSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
5
+ import { isDeepStrictEqual } from "node:util";
4
6
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
7
+ import { NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment } from "../agent/launchEnvironment.js";
5
8
  import { activeRoleAgentBinding } from "../role/role.js";
9
+ import { writeTextFileAtomically } from "../storage/durableFile.js";
10
+ import { compileRoleSessionContext } from "../context/roleSessionContext.js";
6
11
  import { resolveAgentAdapter } from "./agentAdapter.js";
12
+ import { inspectCodexLaunchConfig } from "./codexConfigConflict.js";
13
+ import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
14
+ import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
15
+ import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
16
+ import { activeLiveRoleAgentSession } from "./agentExecutor.js";
17
+ import { effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
18
+ import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeState, createExactControlPlaneDescriptor, createExactTaskRuntimeDescriptor, exactControlPlaneCommandPrefix, exactControlPlaneDigest, exactTaskRuntimeDescriptorPath, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
19
+ import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
7
20
  /** Builds managed native Agent launches from the authoritative Task records. */
8
21
  export class FileRoleLaunchPlanner {
9
22
  home;
10
23
  store;
11
- #environment;
24
+ #operationalEnvironment;
25
+ #agentEnvironment;
26
+ #nativeAgentEnvironment;
12
27
  #createNativeSessionId;
13
28
  #cliPath;
29
+ #controlPlane;
14
30
  constructor(home, store, options = {}) {
15
31
  this.home = home;
16
32
  this.store = store;
17
- this.#environment = options.environment ?? process.env;
33
+ // Operational launch context is stable for the Controller lifetime. Agent
34
+ // binding sources are a separate replaceable snapshot so an unset/removed
35
+ // secret cannot survive a later configuration refresh.
36
+ const sourceEnvironment = { ...(options.environment ?? process.env) };
37
+ this.#operationalEnvironment = { ...sourceEnvironment };
38
+ for (const name of NATIVE_AGENT_ENVIRONMENT_NAMES) {
39
+ delete this.#operationalEnvironment[name];
40
+ }
41
+ this.#agentEnvironment = this.#selectConfiguredAgentEnvironment(sourceEnvironment);
42
+ this.#nativeAgentEnvironment = this.#selectConfiguredNativeEnvironment(sourceEnvironment);
18
43
  this.#createNativeSessionId = options.createNativeSessionId ?? randomUUID;
19
- this.#cliPath = options.cliPath
20
- ?? fileURLToPath(new URL("../cli.js", import.meta.url));
44
+ this.#cliPath = canonicalPath(options.cliPath
45
+ ?? fileURLToPath(new URL("../cli.js", import.meta.url)));
46
+ // Freeze the complete control identity before any native process exists.
47
+ // This value is immutable for the planner/Controller lifetime and is never
48
+ // rewritten through a shared PATH launcher.
49
+ this.#controlPlane = createExactControlPlaneDescriptor({
50
+ executable: process.execPath,
51
+ cliEntry: this.#cliPath,
52
+ yuiHome: this.home
53
+ });
54
+ }
55
+ refreshAgentEnvironment(refresh) {
56
+ this.#agentEnvironment = patchEnvironment(this.#agentEnvironment, refresh.sourceNames, refresh.sources);
57
+ this.#nativeAgentEnvironment = patchEnvironment(this.#nativeAgentEnvironment, refresh.nativeNames, refresh.nativeSources);
58
+ }
59
+ /** Re-publishes provider-discovered native identity after its durable fold. */
60
+ refreshTaskRuntimeDescriptor(input) {
61
+ const task = this.store.getTask(input.taskId);
62
+ const role = this.store.getRole(input.taskId, input.roleName);
63
+ if (task === null || task.status !== "active" || role === null) {
64
+ throw new Error(`Task runtime is not current: ${input.taskId}/${input.roleName}.`);
65
+ }
66
+ const run = this.store.getActiveAgentRun(input.taskId, input.roleName);
67
+ const sessions = this.store.getTaskRoleSessionSet(input.taskId, input.roleName);
68
+ const session = sessions?.sessions[role.activeAgentId];
69
+ if (session === undefined || session.launchId === undefined) {
70
+ throw new Error(`Task runtime native Session is not ready: ${input.taskId}/${input.roleName}.`);
71
+ }
72
+ const effective = run?.effective ?? session.effective;
73
+ const descriptor = createExactTaskRuntimeDescriptor({
74
+ controlPlaneDigest: exactControlPlaneDigest(this.#controlPlane),
75
+ taskId: input.taskId,
76
+ roleName: input.roleName,
77
+ agentId: session.agentId,
78
+ adapterId: session.adapterId,
79
+ workspace: effective.workspace.root,
80
+ ...(run === null ? {} : { runId: run.id }),
81
+ launchId: session.launchId,
82
+ nativeSessionId: session.nativeSessionId
83
+ });
84
+ assertExactTaskRuntimeState(descriptor, this.store);
85
+ writeTextFileAtomically(exactTaskRuntimeDescriptorPath(this.home, descriptor), `${serializeExactDescriptor(descriptor)}\n`);
21
86
  }
22
87
  plan(input) {
23
88
  const task = this.store.getTask(input.taskId);
@@ -28,25 +93,119 @@ export class FileRoleLaunchPlanner {
28
93
  const role = this.store.getRole(input.taskId, input.roleName);
29
94
  if (role === null)
30
95
  throw new Error(`Role not found: ${input.taskId}/${input.roleName}.`);
31
- if (task.repositoryId !== undefined) {
32
- const workspace = this.store.getRoleWorkspace(task.id, role.name);
33
- if (task.cwd === undefined
34
- || workspace === null
35
- || workspace.repositoryId !== task.repositoryId
36
- || workspace.path !== role.workspace) {
96
+ const activeRun = this.store.getActiveAgentRun(task.id, role.name);
97
+ if (input.runId !== undefined && activeRun?.id !== input.runId) {
98
+ throw new Error(`Role Run is no longer current: ${input.runId}.`);
99
+ }
100
+ const runWorkspace = activeRun?.workspace;
101
+ const main = this.store.getTaskWorkspace(task.id);
102
+ if (!isTaskOwnedWorkspace(main, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
103
+ throw new Error(`Role workspace is not ready: ${input.taskId}/${input.roleName}.`);
104
+ }
105
+ if (runWorkspace !== undefined) {
106
+ const durableRunWorkspace = this.store.getManagedWorkspace(runWorkspace.owner);
107
+ if (durableRunWorkspace === null || !isDeepStrictEqual(durableRunWorkspace, runWorkspace)) {
108
+ throw new Error(`Role Run workspace is not the durable owner: ${input.taskId}/${input.roleName}.`);
109
+ }
110
+ }
111
+ const assignedWorkItem = this.store.listWorkItems(task.id).find((item) => item.assignee === role.name
112
+ && !["completed", "failed", "retired"].includes(item.status));
113
+ // A Run snapshot is authoritative for the live launch. In particular,
114
+ // a reviewer Run must launch from its ReviewRound-owned workspace rather
115
+ // than falling back to the WorkItem Develop workspace. Without an
116
+ // active snapshot, resolve the normal Role/WorkItem assignment.
117
+ const workspace = runWorkspace !== undefined
118
+ ? runWorkspace
119
+ : task.projectBindings.length === 0
120
+ ? main
121
+ : assignedWorkItem === undefined
122
+ ? main
123
+ : this.store.getWorkItemWorkspace(task.id, assignedWorkItem.id);
124
+ if (task.projectBindings.length === 0) {
125
+ if (workspace === null || !isDeepStrictEqual(workspace, main)) {
126
+ throw new Error(`Role workspace is not ready: ${input.taskId}/${input.roleName}.`);
127
+ }
128
+ }
129
+ else {
130
+ const sharedMain = main !== null
131
+ && (workspace === null || workspace.owner.type === "task")
132
+ && sameWorkspaceProjects(main, task.projectBindings.map(({ projectId }) => projectId))
133
+ && (role.name === "leader" || role.workspace === main.root);
134
+ const isolatedWorkItem = workspace?.owner.type === "work-item"
135
+ ? this.store.getWorkItem(task.id, workspace.owner.workItemId)
136
+ : null;
137
+ const isolated = workspace !== null
138
+ && workspace.owner.type === "work-item"
139
+ && isolatedWorkItem !== null
140
+ && (isolatedWorkItem.assignee === undefined || isolatedWorkItem.assignee === role.name)
141
+ && !["completed", "failed", "retired"].includes(isolatedWorkItem.status)
142
+ && (activeRun === null
143
+ || activeRun.workItemId === workspace.owner.workItemId)
144
+ && sameWorkspaceProjects(workspace, task.projectBindings.map(({ projectId }) => projectId))
145
+ && sameWritableProjects(workspace, isolatedWorkItem.writeProjectIds);
146
+ const runScoped = runWorkspace !== undefined
147
+ && runWorkspace.owner.taskId === task.id
148
+ && sameWorkspaceProjects(runWorkspace, task.projectBindings.map(({ projectId }) => projectId))
149
+ && (runWorkspace.owner.type === "task"
150
+ || (runWorkspace.owner.type === "work-item"
151
+ && runWorkspace.owner.workItemId === activeRun?.workItemId)
152
+ || (runWorkspace.owner.type === "review-round"
153
+ && activeRun?.purpose === "review"
154
+ && runWorkspace.owner.reviewRoundId === activeRun.reviewRoundId)
155
+ || (runWorkspace.owner.type === "execution-lane"
156
+ && runWorkspace.owner.executionGroupId === activeRun?.executionGroupId
157
+ && runWorkspace.owner.executionLaneId === activeRun?.executionLaneId
158
+ && ((runWorkspace.owner.purpose === "review" && activeRun?.purpose === "review")
159
+ || (runWorkspace.owner.purpose === "execution" && activeRun?.purpose === "execution"))));
160
+ if (!runScoped && !sharedMain && !isolated) {
37
161
  throw new Error(`Role workspace is not ready: ${input.taskId}/${input.roleName}.`);
38
162
  }
39
163
  }
40
- return this.#compile(role, input, { scope: "task", taskId: task.id }, this.store.getRoleSession(task.id, role.name)?.nativeSessionId);
164
+ const sessionSet = this.store.getTaskRoleSessionSet(task.id, role.name);
165
+ const resolvedEffective = activeRun?.effective
166
+ ?? activeLiveRoleAgentSession(sessionSet)?.effective
167
+ ?? resolveTaskRoleEffectiveLaunch(this.store, role);
168
+ if (input.effective !== undefined
169
+ && !isDeepStrictEqual(resolvedEffective, input.effective)) {
170
+ throw new Error(`Role launch effective Run snapshot changed: ${input.taskId}/${input.roleName}.`);
171
+ }
172
+ if (activeRun !== null && input.effective === undefined) {
173
+ throw new Error(`Role launch is missing the effective Run snapshot: ${input.taskId}/${input.roleName}.`);
174
+ }
175
+ const effective = input.effective ?? resolvedEffective;
176
+ const existing = sessionSet?.sessions[effective.agentId];
177
+ const compatibleExisting = existing !== undefined
178
+ && effectiveLaunchSnapshotsCompatible(existing.effective, effective);
179
+ if (input.mode === "resume" && !compatibleExisting) {
180
+ throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
181
+ }
182
+ return this.#compile(role, input, { scope: "task", taskId: task.id }, taskRoleSessionTitle(task, role.name), compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
183
+ purpose: activeRun?.purpose ?? "execution"
184
+ });
41
185
  }
42
186
  planGlobalRole(input) {
43
187
  const role = this.store.getGlobalRole(input.roleName);
44
188
  if (role === null)
45
189
  throw new Error(`Global Role not found: ${input.roleName}.`);
46
- return this.#compile(role, input, { scope: "global" }, this.store.getGlobalRoleSessionSet(role.name)?.sessions[role.activeAgentId]?.nativeSessionId);
190
+ const sessionSet = this.store.getGlobalRoleSessionSet(role.name);
191
+ const resolvedEffective = activeLiveRoleAgentSession(sessionSet)?.effective
192
+ ?? resolveEffectiveLaunch({ role, purpose: "execution" });
193
+ if (input.effective !== undefined
194
+ && !isDeepStrictEqual(resolvedEffective, input.effective)) {
195
+ throw new Error(`Global Role launch effective snapshot changed: ${role.name}.`);
196
+ }
197
+ const effective = input.effective ?? resolvedEffective;
198
+ const existing = sessionSet?.sessions[effective.agentId];
199
+ const compatibleExisting = existing !== undefined
200
+ && effectiveLaunchSnapshotsCompatible(existing.effective, effective);
201
+ if (input.mode === "resume" && !compatibleExisting) {
202
+ throw new Error(`Global Role resume effective snapshot drifted: ${role.name}.`);
203
+ }
204
+ return this.#compile(role, input, { scope: "global" }, undefined, compatibleExisting ? existing.nativeSessionId : undefined, undefined, effective, { purpose: "execution" });
47
205
  }
48
- #compile(role, input, owner, knownNativeSessionId) {
49
- const binding = activeRoleAgentBinding(role);
206
+ #compile(role, input, owner, sessionTitle, knownNativeSessionId, workspaceOverride, effective, sessionPolicy) {
207
+ const launchRole = effectiveRoleForLaunch(role, effective);
208
+ const binding = activeRoleAgentBinding(launchRole);
50
209
  if (binding.agentId !== input.agentId || binding.adapterId !== input.adapterId) {
51
210
  throw new Error(`Role launch identity changed: ${role.name}.`);
52
211
  }
@@ -57,12 +216,78 @@ export class FileRoleLaunchPlanner {
57
216
  throw new Error(`Configured Agent adapter changed: ${input.agentId}.`);
58
217
  }
59
218
  const agent = configuredAgentToDefinition(configured);
219
+ const agentSourceEnvironment = input.environment ?? this.#agentEnvironment;
220
+ const operationalSourceEnvironment = input.environment ?? {
221
+ ...this.#operationalEnvironment,
222
+ ...this.#nativeAgentEnvironment
223
+ };
224
+ const resolvedAgentEnvironment = resolveAgentEnvironment(agent, agentSourceEnvironment);
225
+ const inheritedLaunchEnvironment = {
226
+ ...operationalAgentEnvironment(configured.adapterId, operationalSourceEnvironment),
227
+ ...resolvedAgentEnvironment
228
+ };
229
+ const launchEnvironment = { ...inheritedLaunchEnvironment };
60
230
  const adapter = resolveAgentAdapter(binding.adapterId);
231
+ const effectiveWorkspace = effective.workspace.root;
232
+ const agentWorkspace = nativeAgentWorkspace(effective.workspace);
233
+ const runtimeIsolation = input.runtimeIsolation === undefined
234
+ ? undefined
235
+ : parseTaskRuntimeIsolationDescriptor(JSON.stringify(input.runtimeIsolation));
236
+ if (runtimeIsolation !== undefined && (owner.scope !== "task"
237
+ || runtimeIsolation.taskId !== owner.taskId
238
+ || runtimeIsolation.workspace.root !== effectiveWorkspace
239
+ || runtimeIsolation.generation.runId !== input.runId
240
+ || runtimeIsolation.generation.launchId !== input.launchId)) {
241
+ throw new Error("Role launch does not match its Task runtime isolation descriptor.");
242
+ }
243
+ Object.assign(launchEnvironment, runtimeIsolation === undefined
244
+ ? {}
245
+ : taskRuntimeIsolationEnvironment(runtimeIsolation));
246
+ const baseSessionContext = compileRoleSessionContext(this.home, launchRole, owner, sessionPolicy);
247
+ const sessionContext = owner.scope === "task"
248
+ ? {
249
+ ...baseSessionContext,
250
+ developerInstructions: [
251
+ baseSessionContext.developerInstructions,
252
+ renderExactControlPlaneInstructions(this.#controlPlane)
253
+ ].join("\n")
254
+ }
255
+ : baseSessionContext;
256
+ const codexConfig = binding.config.adapterId === "codex"
257
+ ? inspectCodexLaunchConfig({
258
+ environment: {
259
+ ...operationalSourceEnvironment,
260
+ ...agentSourceEnvironment,
261
+ ...launchEnvironment
262
+ },
263
+ workspace: agentWorkspace,
264
+ profile: binding.config.profile,
265
+ trustWorkspace: true
266
+ })
267
+ : undefined;
268
+ if (codexConfig?.notify.status === "configured") {
269
+ throw new Error("Codex notify is already configured by "
270
+ + `${codexConfig.notify.source}; Yui requires exclusive ownership of the structured `
271
+ + "notify callback and refuses to replace or be replaced by native configuration.");
272
+ }
273
+ const managedRun = owner.scope === "task" && input.runId !== undefined
274
+ ? this.store.getAgentRun(owner.taskId, input.runId)
275
+ : null;
276
+ const roleConfig = binding.config.adapterId === "claude"
277
+ && owner.scope === "task"
278
+ && input.runId !== undefined
279
+ ? managedClaudeControlPlaneConfig(binding.config, owner.taskId, managedRun?.workItemId, input.runId, this.#controlPlane)
280
+ : binding.config;
281
+ const effectiveConfig = withNativeProjectDirectories(roleConfig, nativeAdditionalDirectories(effective.workspace, agentWorkspace));
61
282
  const compileInput = {
62
283
  agent,
63
- config: binding.config,
64
- workspace: role.workspace,
65
- systemPrompt: role.systemPrompt
284
+ config: effectiveConfig,
285
+ workspace: agentWorkspace,
286
+ ...(sessionTitle === undefined ? {} : { sessionTitle }),
287
+ ...sessionContext,
288
+ ...(codexConfig === undefined
289
+ ? {}
290
+ : { codexDeveloperInstructions: codexConfig.developerInstructions })
66
291
  };
67
292
  if (input.mode === "resume"
68
293
  && knownNativeSessionId !== undefined
@@ -88,41 +313,280 @@ export class FileRoleLaunchPlanner {
88
313
  let session;
89
314
  if (binding.adapterId === "codex") {
90
315
  args = addCodexSessionNotify(args, launchMode, this.#cliPath);
316
+ // A fresh Codex TUI has no provider event before its first prompt. Carry
317
+ // the exact Run input as the provider's positional launch prompt so it is
318
+ // submitted only after Codex completes startup; never race terminal bytes
319
+ // and Enter against TUI initialization.
320
+ if (owner.scope === "task" && input.runId !== undefined) {
321
+ if (managedRun === null || managedRun.status !== "active") {
322
+ throw new Error(`Managed Codex Run is no longer active: ${input.runId}.`);
323
+ }
324
+ args = addCodexLifecycleHooks(args, launchMode, this.#cliPath);
325
+ // End option parsing before the opaque prompt so a wakeup beginning
326
+ // with '-' can never be reinterpreted as a Codex CLI flag.
327
+ args.push("--", managedRun.input);
328
+ }
91
329
  session = launchMode === "resume"
92
- ? readySession(input.agentId, binding.adapterId, resumeNativeSessionId)
330
+ ? readySession(input.agentId, binding.adapterId, resumeNativeSessionId, effective)
93
331
  : null;
94
332
  }
95
333
  else if (launchMode === "new") {
96
- const nativeSessionId = requireText(this.#createNativeSessionId(), "Native session id");
334
+ if (owner.scope === "task" && input.runId !== undefined) {
335
+ args.push("--plugin-dir", ensureManagedClaudeLifecyclePlugin(this.home, this.#cliPath));
336
+ }
337
+ const nativeSessionId = requireText(input.launchId === undefined
338
+ ? this.#createNativeSessionId()
339
+ : nativeSessionIdForLaunch(this.home, input.launchId, input.agentId, input.adapterId), "Native session id");
97
340
  args.push("--session-id", nativeSessionId);
98
- session = readySession(input.agentId, binding.adapterId, nativeSessionId);
341
+ session = readySession(input.agentId, binding.adapterId, nativeSessionId, effective);
99
342
  }
100
343
  else {
101
- session = readySession(input.agentId, binding.adapterId, resumeNativeSessionId);
344
+ if (owner.scope === "task" && input.runId !== undefined) {
345
+ args.push("--plugin-dir", ensureManagedClaudeLifecyclePlugin(this.home, this.#cliPath));
346
+ }
347
+ session = readySession(input.agentId, binding.adapterId, resumeNativeSessionId, effective);
348
+ }
349
+ const runtimeDescriptor = owner.scope === "task"
350
+ ? createExactTaskRuntimeDescriptor({
351
+ controlPlaneDigest: exactControlPlaneDigest(this.#controlPlane),
352
+ taskId: owner.taskId,
353
+ roleName: role.name,
354
+ agentId: configured.id,
355
+ adapterId: configured.adapterId,
356
+ workspace: effectiveWorkspace,
357
+ ...(input.runId === undefined ? {} : { runId: input.runId }),
358
+ ...(input.launchId === undefined ? {} : { launchId: input.launchId }),
359
+ ...(session === null
360
+ ? {}
361
+ : { nativeSessionId: session.nativeSessionId })
362
+ })
363
+ : undefined;
364
+ const runtimeDescriptorSource = runtimeDescriptor === undefined
365
+ ? undefined
366
+ : exactTaskRuntimeDescriptorPath(this.home, runtimeDescriptor);
367
+ if (runtimeDescriptor !== undefined && runtimeDescriptorSource !== undefined) {
368
+ writeTextFileAtomically(runtimeDescriptorSource, `${serializeExactDescriptor(runtimeDescriptor)}\n`);
102
369
  }
370
+ const launch = {
371
+ command: configured.command,
372
+ args,
373
+ env: {
374
+ ...launchEnvironment,
375
+ YUI_HOME: resolve(this.home),
376
+ YUI_SESSION_SCOPE: owner.scope,
377
+ ...(owner.scope === "task" ? { YUI_TASK_ID: owner.taskId } : {}),
378
+ YUI_ROLE: role.name,
379
+ YUI_AGENT_ID: configured.id,
380
+ YUI_ADAPTER_ID: configured.adapterId,
381
+ YUI_WORKSPACE: effectiveWorkspace,
382
+ ...(runtimeDescriptor === undefined
383
+ ? {}
384
+ : {
385
+ [YUI_CONTROL_PLANE_DESCRIPTOR]: serializeExactDescriptor(this.#controlPlane),
386
+ [YUI_TASK_RUNTIME_DESCRIPTOR]: runtimeDescriptorSource
387
+ }),
388
+ ...(sessionTitle === undefined
389
+ ? {}
390
+ : {
391
+ YUI_SESSION_TITLE: sessionTitle,
392
+ ...(configured.adapterId === "codex"
393
+ ? {
394
+ YUI_AGENT_COMMAND: configured.command,
395
+ YUI_AGENT_BASE_ARGS: JSON.stringify(configured.baseArgs)
396
+ }
397
+ : {})
398
+ }),
399
+ ...(input.launchId === undefined
400
+ ? {}
401
+ : { YUI_LAUNCH_ID: input.launchId }),
402
+ ...(input.runId === undefined ? {} : { YUI_RUN_ID: input.runId }),
403
+ ...(session === null
404
+ ? {}
405
+ : { YUI_NATIVE_SESSION_ID: session.nativeSessionId })
406
+ }
407
+ };
408
+ const scopedLaunch = owner.scope === "task"
409
+ ? this.#applyWorkspaceScope(owner.taskId, role, launch, workspaceOverride)
410
+ : launch;
103
411
  return {
104
412
  role: {
105
413
  name: role.name,
106
- workspace: role.workspace,
414
+ workspace: effectiveWorkspace,
415
+ ...(agentWorkspace === effectiveWorkspace ? {} : { cwd: agentWorkspace }),
107
416
  ...(owner.scope === "task" ? { status: role.status } : {})
108
417
  },
109
- launch: {
110
- command: configured.command,
111
- args,
112
- env: {
113
- ...resolveAgentEnvironment(agent, this.#environment),
114
- YUI_HOME: resolve(this.home),
115
- YUI_SESSION_SCOPE: owner.scope,
116
- ...(owner.scope === "task" ? { YUI_TASK_ID: owner.taskId } : {}),
117
- YUI_ROLE: role.name,
118
- YUI_AGENT_ID: configured.id,
119
- YUI_ADAPTER_ID: configured.adapterId,
120
- YUI_WORKSPACE: role.workspace
121
- }
122
- },
123
- session
418
+ launch: scopedLaunch,
419
+ session,
420
+ ...(binding.adapterId === "codex" && input.runId !== undefined
421
+ ? { initialPromptRunId: input.runId }
422
+ : {})
423
+ };
424
+ }
425
+ #applyWorkspaceScope(taskId, role, launch, workspaceOverride) {
426
+ const workspace = workspaceOverride
427
+ ?? (role.name === "leader"
428
+ ? this.store.getTaskWorkspace(taskId)
429
+ : this.store.listWorkItems(taskId)
430
+ .find((item) => item.assignee === role.name && item.status === "running") === undefined
431
+ ? this.store.getTaskWorkspace(taskId)
432
+ : this.store.getWorkItemWorkspace(taskId, this.store.listWorkItems(taskId).find((item) => item.assignee === role.name && item.status === "running").id));
433
+ if (workspace === null || workspace === undefined)
434
+ return launch;
435
+ return {
436
+ ...launch,
437
+ env: {
438
+ ...workspaceScopeEnvironment(launch.env, workspace)
439
+ }
124
440
  };
125
441
  }
442
+ #selectConfiguredAgentEnvironment(source) {
443
+ const selected = {};
444
+ for (const agent of this.store.listConfiguredAgents()) {
445
+ for (const binding of agent.environment) {
446
+ const value = source[binding.sourceName];
447
+ if (value !== undefined)
448
+ selected[binding.sourceName] = value;
449
+ }
450
+ }
451
+ return selected;
452
+ }
453
+ #selectConfiguredNativeEnvironment(source) {
454
+ const names = new Set(this.store.listConfiguredAgents().flatMap((agent) => (nativeAgentEnvironmentNames(agent.adapterId))));
455
+ return selectEnvironment(source, names);
456
+ }
457
+ }
458
+ function nativeAgentWorkspace(workspace) {
459
+ return workspace.entries.length === 1
460
+ ? workspace.entries[0].path
461
+ : workspace.root;
462
+ }
463
+ function nativeAdditionalDirectories(workspace, agentWorkspace) {
464
+ return [workspace.root, ...workspace.entries.map(({ path }) => path)]
465
+ .filter((path) => path !== agentWorkspace);
466
+ }
467
+ function withNativeProjectDirectories(config, projectDirectories) {
468
+ if (projectDirectories.length === 0)
469
+ return config;
470
+ return {
471
+ ...config,
472
+ additionalDirectories: [...new Set([
473
+ ...(config.additionalDirectories ?? []),
474
+ ...projectDirectories
475
+ ])]
476
+ };
477
+ }
478
+ function resolveTaskRoleEffectiveLaunch(store, role) {
479
+ const item = store.listWorkItems(role.taskId).find((candidate) => (candidate.assignee === role.name
480
+ && !["completed", "failed", "retired"].includes(candidate.status))) ?? null;
481
+ const workspace = (item === null
482
+ ? store.getTaskWorkspace(role.taskId)
483
+ : store.getWorkItemWorkspace(role.taskId, item.id))
484
+ ?? store.getTaskWorkspace(role.taskId)
485
+ ?? undefined;
486
+ return resolveEffectiveLaunch({
487
+ role,
488
+ purpose: "execution",
489
+ ...(workspace === undefined ? {} : { workspace }),
490
+ ...(item === null ? {} : { workItemWriteProjectIds: item.writeProjectIds })
491
+ });
492
+ }
493
+ function workspaceScopeEnvironment(environment, workspace) {
494
+ return {
495
+ ...environment,
496
+ YUI_WRITABLE_PROJECT_IDS: JSON.stringify(workspace.entries
497
+ .filter(({ access }) => access === "write")
498
+ .map(({ projectId }) => projectId)),
499
+ YUI_CONTEXT_PROJECT_IDS: JSON.stringify(workspace.entries
500
+ .filter(({ access }) => access === "read")
501
+ .map(({ projectId }) => projectId)),
502
+ YUI_WORKSPACE_PROJECTS: JSON.stringify(Object.fromEntries(workspace.entries.map(({ projectId, directory, access, path }) => [
503
+ projectId,
504
+ { directory, access, path }
505
+ ])))
506
+ };
507
+ }
508
+ function ensureManagedClaudeLifecyclePlugin(home, cliPath) {
509
+ const root = join(resolve(home), "runtime", "claude-lifecycle-plugin");
510
+ writeTextFileAtomically(join(root, ".claude-plugin", "plugin.json"), `${JSON.stringify({
511
+ name: "yui-runtime-lifecycle",
512
+ description: "Yui-owned exact Run lifecycle transport",
513
+ version: "1.0.0"
514
+ }, null, 2)}\n`);
515
+ const command = {
516
+ type: "command",
517
+ command: canonicalPath(process.execPath),
518
+ args: [canonicalPath(cliPath), "internal", "claude-hook"]
519
+ };
520
+ writeTextFileAtomically(join(root, "hooks", "hooks.json"), `${JSON.stringify({
521
+ hooks: {
522
+ // SessionStart proves the session exists (and, for source=startup,
523
+ // pre-input readiness); UserPromptSubmit is the exact provider-accepted
524
+ // fence; PostToolUse is a provider-native in-turn progress fact and
525
+ // StopFailure is the terminal failure fact. All route to the same
526
+ // Yui-owned entrypoint, which parses by hook_event_name.
527
+ SessionStart: [{ hooks: [command] }],
528
+ UserPromptSubmit: [{ hooks: [command] }],
529
+ PostToolUse: [{ hooks: [command] }],
530
+ StopFailure: [{ hooks: [command] }]
531
+ }
532
+ }, null, 2)}\n`);
533
+ return root;
534
+ }
535
+ function managedClaudeControlPlaneConfig(config, taskId, workItemId, runId, controlPlane) {
536
+ if (config.permission.strategy !== "configured")
537
+ return config;
538
+ const exact = exactControlPlaneCommandPrefix(controlPlane);
539
+ const managed = [
540
+ `Bash(${exact} --json task context ${taskId})`,
541
+ `Bash(${exact} --json task work list ${taskId})`,
542
+ ...(workItemId === undefined
543
+ ? []
544
+ : [`Bash(${exact} --json task work show ${workItemId})`]),
545
+ `Bash(${exact} task run yield ${runId} --summary-file -:*)`
546
+ ];
547
+ const existing = (config.permission.allowedTools ?? [])
548
+ .filter((rule) => !isManagedYuiBashRule(rule));
549
+ return {
550
+ ...config,
551
+ permission: {
552
+ ...config.permission,
553
+ allowedTools: [...new Set([...existing, ...managed])]
554
+ }
555
+ };
556
+ }
557
+ function isManagedYuiBashRule(rule) {
558
+ const normalized = rule.trim();
559
+ return /^Bash\(yui(?:\s|:\*|\*|\))/u.test(normalized)
560
+ || /^Bash\(.*\s--yui-control\s/u.test(normalized);
561
+ }
562
+ function renderExactControlPlaneInstructions(descriptor) {
563
+ const command = exactControlPlaneCommandPrefix(descriptor);
564
+ return [
565
+ "Exact Yui control-plane command prefix for this managed Task session:",
566
+ `\`${command}\``,
567
+ "Replace the portable bare `yui` token in Yui Role Skills and dispatch instructions with this exact prefix.",
568
+ "Bare `yui`, a PATH launcher, another checkout CLI, another YUI_HOME, or a changed schema/Controller identity is invalid and fails before Task state is read or written."
569
+ ].join("\n");
570
+ }
571
+ function canonicalPath(path) {
572
+ const absolute = resolve(path);
573
+ try {
574
+ return realpathSync(absolute);
575
+ }
576
+ catch {
577
+ return absolute;
578
+ }
579
+ }
580
+ function shellQuote(value) {
581
+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
582
+ }
583
+ function patchEnvironment(current, names, values) {
584
+ const next = { ...current };
585
+ for (const name of names)
586
+ delete next[name];
587
+ for (const [name, value] of Object.entries(values))
588
+ next[name] = value;
589
+ return next;
126
590
  }
127
591
  /**
128
592
  * Codex invokes this argv after each completed turn and appends one JSON
@@ -140,12 +604,43 @@ function addCodexSessionNotify(args, mode, cliPath) {
140
604
  }
141
605
  return [...args.slice(0, -2), ...managed, ...args.slice(-2)];
142
606
  }
143
- function readySession(agentId, adapterId, nativeSessionId) {
607
+ /**
608
+ * Codex 0.145 discovers lifecycle hooks from its effective config. Keep Yui's
609
+ * two handlers invocation-local: this avoids mutating CODEX_HOME or the Task
610
+ * workspace, while the exact launch environment supplies the durable Run fence.
611
+ */
612
+ function codexLifecycleHooksConfig(cliPath) {
613
+ const command = [
614
+ shellQuote(canonicalPath(process.execPath)),
615
+ shellQuote(canonicalPath(cliPath)),
616
+ "internal",
617
+ "codex-hook"
618
+ ].join(" ");
619
+ const handler = `{hooks=[{type="command",command=${JSON.stringify(command)}}]}`;
620
+ return `hooks={SessionStart=[${handler}],UserPromptSubmit=[${handler}]}`;
621
+ }
622
+ function addCodexLifecycleHooks(args, mode, cliPath) {
623
+ // Session flags are Yui-owned and exact to this launch. Hook trust bypass is
624
+ // still explicit because these handlers execute a local command.
625
+ const managed = [
626
+ "--enable", "hooks",
627
+ "--config", codexLifecycleHooksConfig(cliPath),
628
+ "--dangerously-bypass-hook-trust"
629
+ ];
630
+ if (mode === "new")
631
+ return [...args, ...managed];
632
+ if (args.length < 2 || args.at(-2) !== "resume") {
633
+ throw new Error("Codex resume launch shape is invalid.");
634
+ }
635
+ return [...args.slice(0, -2), ...managed, ...args.slice(-2)];
636
+ }
637
+ function readySession(agentId, adapterId, nativeSessionId, effective) {
144
638
  return {
145
639
  agentId,
146
640
  adapterId,
147
641
  nativeSessionId: requireText(nativeSessionId, "Native session id"),
148
- status: "ready"
642
+ status: "ready",
643
+ effective
149
644
  };
150
645
  }
151
646
  function requireText(value, label) {
@@ -154,3 +649,18 @@ function requireText(value, label) {
154
649
  }
155
650
  return value.trim();
156
651
  }
652
+ function sameWorkspaceProjects(workspace, projectIds) {
653
+ const actual = workspace.entries.map(({ projectId }) => projectId).sort();
654
+ const expected = [...projectIds].sort();
655
+ return actual.length === expected.length
656
+ && actual.every((projectId, index) => projectId === expected[index]);
657
+ }
658
+ function sameWritableProjects(workspace, projectIds) {
659
+ const actual = workspace.entries
660
+ .filter(({ access }) => access === "write")
661
+ .map(({ projectId }) => projectId)
662
+ .sort();
663
+ const expected = [...projectIds].sort();
664
+ return actual.length === expected.length
665
+ && actual.every((projectId, index) => projectId === expected[index]);
666
+ }