@zq-silk/yui 0.15.9 → 0.15.12

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 (160) hide show
  1. package/ARCHITECTURE.md +8 -4
  2. package/ARCHITECTURE.zh-CN.md +5 -2
  3. package/README.md +13 -5
  4. package/dist/agent/launchEnvironment.js +7 -0
  5. package/dist/agentRun/agentRun.js +3 -0
  6. package/dist/cli/commandCatalog.js +64 -16
  7. package/dist/cli/interactionPolicy.js +7 -3
  8. package/dist/cli/managedDiagnostics.js +1 -1
  9. package/dist/cli/updateOrchestrator.js +24 -1
  10. package/dist/cli/updatePorts.js +7 -3
  11. package/dist/cli/upgradeCommand.js +42 -2
  12. package/dist/cli.js +381 -107
  13. package/dist/commands/executionAuditCommands.js +10 -0
  14. package/dist/commands/globalRoleCommands.js +339 -4
  15. package/dist/commands/projectCommands.js +50 -22
  16. package/dist/commands/releaseCommands.js +18 -0
  17. package/dist/commands/taskActor.js +25 -0
  18. package/dist/commands/taskCommands.js +586 -96
  19. package/dist/commands/taskIntegrationCommands.js +19 -39
  20. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  21. package/dist/commands/taskOverviewCommand.js +4 -3
  22. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  23. package/dist/commands/taskPublicationCommands.js +11 -2
  24. package/dist/commands/taskPublicationVerifyCommand.js +23 -39
  25. package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
  26. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  27. package/dist/context/runContextPack.js +3 -0
  28. package/dist/context/taskCatalog.js +187 -0
  29. package/dist/context/taskContext.js +55 -6
  30. package/dist/controller/agentHostObservation.js +155 -0
  31. package/dist/controller/clientRuntime.js +17 -2
  32. package/dist/controller/controller.js +14 -2
  33. package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
  34. package/dist/controller/globalInputDelivery.js +132 -0
  35. package/dist/controller/jobControl.js +6 -2
  36. package/dist/controller/providerRetryAdmission.js +100 -0
  37. package/dist/controller/providerRetryDelivery.js +218 -0
  38. package/dist/controller/resourceInventory.js +14 -4
  39. package/dist/controller/resourceInventoryLinux.js +2 -6
  40. package/dist/controller/runtime.js +117 -7
  41. package/dist/controller/runtimeEventInbox.js +32 -3
  42. package/dist/controller/runtimeEventProcessor.js +26 -6
  43. package/dist/controller/runtimeHookRunFence.js +75 -19
  44. package/dist/controller/structuredProviderObservation.js +133 -70
  45. package/dist/coordination/workMailboxQueue.js +5 -0
  46. package/dist/execution/workItemExecutionProjection.js +1 -1
  47. package/dist/executor/agentExecutor.js +64 -4
  48. package/dist/executor/executorRegistry.js +3 -0
  49. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  50. package/dist/integration/deliveryObligation.js +2 -1
  51. package/dist/integration/gitIntegrationService.js +329 -386
  52. package/dist/integration/integrationAttempt.js +30 -4
  53. package/dist/integration/integrationQueueService.js +7 -7
  54. package/dist/integration/integrationSourceApplication.js +323 -0
  55. package/dist/lifecycle/exactRunTerminalization.js +4 -1
  56. package/dist/message/globalInterrupt.js +33 -0
  57. package/dist/message/globalProviderRetry.js +15 -0
  58. package/dist/message/inputControlResolution.js +106 -0
  59. package/dist/message/message.js +367 -0
  60. package/dist/message/messageContinuation.js +126 -3
  61. package/dist/message/taskInterrupt.js +34 -0
  62. package/dist/observability/executionAudit.js +19 -0
  63. package/dist/observability/orchestrationMetrics.js +1 -1
  64. package/dist/release/releaseHandover.js +22 -0
  65. package/dist/release/releaseWorkflowPorts.js +15 -7
  66. package/dist/repository/gitWorkspace.js +430 -107
  67. package/dist/repository/projectMaintenanceLock.js +75 -18
  68. package/dist/repository/taskWorkspaceCoordinator.js +182 -101
  69. package/dist/repository/taskWorkspacePreparer.js +205 -72
  70. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  71. package/dist/repository/workspaceCleanupInspection.js +187 -0
  72. package/dist/resources/resourceDiscovery.js +3 -2
  73. package/dist/runtime/agentError.js +5 -3
  74. package/dist/runtime/agentHost.js +179 -82
  75. package/dist/runtime/agentHostCompatibility.js +127 -0
  76. package/dist/runtime/agentHostProtocol.js +53 -0
  77. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  78. package/dist/runtime/codexAppServerRuntime.js +34 -3
  79. package/dist/runtime/executionEnvironment.js +0 -19
  80. package/dist/runtime/launchBroker.js +6 -0
  81. package/dist/runtime/providerControl.js +5 -1
  82. package/dist/runtime/providerRetry.js +198 -0
  83. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  84. package/dist/runtime/sessionReconciliation.js +4 -4
  85. package/dist/runtime/sessionTokenMetrics.js +15 -5
  86. package/dist/runtime/structuredProviderHost.js +6 -2
  87. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  88. package/dist/runtime/taskUsageMetrics.js +275 -0
  89. package/dist/runtime/tmuxAdapters.js +5 -3
  90. package/dist/scheduler/activeRoleRunDelivery.js +12 -0
  91. package/dist/scheduler/leaderWakeupProcessor.js +5 -0
  92. package/dist/scheduler/operatorEvent.js +4 -0
  93. package/dist/scheduler/taskExecutionProjection.js +38 -6
  94. package/dist/scheduler/taskObservabilityProjection.js +6 -44
  95. package/dist/scheduler/wakeReason.js +7 -1
  96. package/dist/scheduler/wakeupQueue.js +2 -0
  97. package/dist/setup/setupCommand.js +26 -8
  98. package/dist/storage/homeLayout.js +130 -0
  99. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  100. package/dist/storage/migrations/integrationContinuation.js +104 -0
  101. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  102. package/dist/storage/sqliteSchema.js +167 -4
  103. package/dist/storage/sqliteStore.js +57 -1
  104. package/dist/storage/storageVersions.js +1 -1
  105. package/dist/storage/storeRpc.js +2 -0
  106. package/dist/storage/taskCatalog.js +123 -0
  107. package/dist/storage/taskStore.js +2 -0
  108. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  109. package/dist/task/archiveDiagnostics.js +129 -0
  110. package/dist/task/archivePreflight.js +124 -0
  111. package/dist/task/nextAction.js +44 -11
  112. package/dist/task/publicationAdoption.js +56 -0
  113. package/dist/task/publicationReference.js +10 -0
  114. package/dist/task/remoteDelivery.js +31 -16
  115. package/dist/web/assets/client/app.js +147 -17
  116. package/dist/web/assets/client/components.js +56 -13
  117. package/dist/web/assets/client/i18n.js +78 -4
  118. package/dist/web/assets/client/taskSurface.js +108 -1
  119. package/dist/web/assets/client/view.js +39 -8
  120. package/dist/web/assets/shell.js +29 -0
  121. package/dist/web/assets/styles/layout.js +8 -1
  122. package/dist/web/assets/styles/widgets.js +12 -0
  123. package/dist/web/webServer.js +131 -4
  124. package/dist/web/webSnapshot.js +16 -6
  125. package/dist/web/webTaskSurface.js +222 -5
  126. package/dist/workspace/cleanupInspection.js +63 -0
  127. package/dist/workspace/workItemChangeSetManager.js +111 -35
  128. package/docs/agent-result-consumption.md +4 -0
  129. package/docs/agent-result-consumption.zh-CN.md +3 -0
  130. package/docs/agent-runtime-drivers.md +7 -0
  131. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  132. package/docs/architecture/README.md +2 -0
  133. package/docs/architecture/README.zh-CN.md +3 -1
  134. package/docs/architecture/capabilities-and-resources.md +30 -5
  135. package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
  136. package/docs/managed-turn-and-session-runtime.md +47 -0
  137. package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
  138. package/docs/observability/README.md +62 -0
  139. package/docs/observability/README.zh-CN.md +47 -0
  140. package/docs/project-refresh.md +77 -0
  141. package/docs/project-refresh.zh-CN.md +59 -0
  142. package/docs/provider-retry.md +70 -0
  143. package/docs/release-workflow.md +39 -0
  144. package/docs/release-workflow.zh-CN.md +29 -0
  145. package/docs/sqlite-control-plane-design.md +223 -1
  146. package/docs/task-delivery.md +133 -13
  147. package/docs/task-delivery.zh-CN.md +99 -10
  148. package/docs/task-discovery.md +102 -0
  149. package/docs/task-discovery.zh-CN.md +86 -0
  150. package/docs/testing/verification-levels.md +40 -0
  151. package/docs/testing/verification-levels.zh-CN.md +23 -0
  152. package/i18n/README.zh-CN.md +13 -7
  153. package/package.json +1 -1
  154. package/skills/yui-leader/references/execution.md +154 -51
  155. package/skills/yui-leader/references/integration.md +52 -2
  156. package/skills/yui-operator/SKILL.md +19 -3
  157. package/skills/yui-reviewer/SKILL.md +4 -0
  158. package/skills/yui-runtime/SKILL.md +42 -0
  159. package/skills/yui-runtime/references/publication.md +42 -0
  160. package/skills/yui-runtime/references/recovery.md +24 -0
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { usageError } from "../errors/cliError.js";
9
9
  import { defaultTableWidth, renderTable } from "../output/table.js";
10
+ import { formatUsageMetric } from "../runtime/taskUsageMetrics.js";
10
11
  import { runExecutionAudit } from "../observability/executionAudit.js";
11
12
  export function parseExecutionAuditOptions(args) {
12
13
  const options = {};
@@ -246,6 +247,15 @@ export function renderExecutionAudit(report, width = defaultTableWidth()) {
246
247
  else {
247
248
  lines.push("", ...sectionError("orchestration", report));
248
249
  }
250
+ if (report.usage.status === "ok" && report.usage.data !== undefined) {
251
+ lines.push("", "Observed usage — Task lifetime, observed sources only (audit time window not applied):");
252
+ for (const usage of report.usage.data) {
253
+ lines.push(` ${usage.taskId}: tokens=${formatUsageMetric(usage.tokens)}; tools=${formatUsageMetric(usage.toolCalls)}; elapsed=${formatUsageMetric(usage.elapsedSeconds, "s")}; native execution sum=${formatUsageMetric(usage.executionSeconds, "s")}`);
254
+ }
255
+ }
256
+ else {
257
+ lines.push("", ...sectionError("usage", report));
258
+ }
249
259
  if (report.storage.status === "ok" && report.storage.data !== undefined) {
250
260
  const storage = report.storage.data;
251
261
  lines.push("", `Storage: backend ${storage.backend} · state.json ${formatBytes(storage.stateJsonBytes)} · yui.db ${formatBytes(storage.databaseBytes)}`
@@ -1,6 +1,12 @@
1
1
  import { roleNotFound, usageError } from "../errors/cliError.js";
2
+ import { controlProviderRetry, providerRetryProjection, renderProviderRetry } from "../runtime/providerRetry.js";
3
+ import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
2
4
  import { createRoleSessionSet, recordRoleAgentSession } from "../executor/agentExecutor.js";
3
5
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
6
+ import { managedGlobalRoleWorkspace } from "../storage/homeLayout.js";
7
+ import { createGlobalRoleMessage, claimGlobalRoleMessageInterruptThen } from "../message/message.js";
8
+ import { resolveGlobalInputControl } from "../message/inputControlResolution.js";
9
+ import { readCommandText } from "./textInput.js";
4
10
  import { defaultTableWidth, renderTable } from "../output/table.js";
5
11
  import { activeRoleSummary, renderRoleDetails } from "../output/rolePresentation.js";
6
12
  import { activeRoleAgentBinding, createGlobalRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateGlobalRole } from "../role/role.js";
@@ -10,6 +16,28 @@ import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./rol
10
16
  import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
11
17
  export function runGlobalRoleCommand(args, store, options = {}) {
12
18
  const [command, ...rest] = args;
19
+ if (command === "message" || command === "interrupt" || command === "session" && rest[0] === "retry") {
20
+ const env = options.env ?? process.env;
21
+ const target = command === "message" || command === "session" ? rest[1] : rest[0];
22
+ if (env.YUI_SESSION_SCOPE !== undefined && env.YUI_SESSION_SCOPE !== "global"
23
+ || env.YUI_SESSION_SCOPE === "global" && env.YUI_ROLE !== "operator"
24
+ && env.YUI_ROLE !== target) {
25
+ throw usageError("Global input mutation is outside the exact Session authority.");
26
+ }
27
+ if (env.YUI_SESSION_SCOPE === "global") {
28
+ const caller = store.getGlobalRoleSessionSet(env.YUI_ROLE ?? "");
29
+ const session = caller?.sessions[caller.activeAgentId];
30
+ if (session === undefined || session.status !== "active" || env.YUI_AGENT_ID !== session.agentId
31
+ || env.YUI_ADAPTER_ID !== session.adapterId
32
+ || (env.CODEX_THREAD_ID ?? env.YUI_NATIVE_SESSION_ID) !== session.nativeSessionId) {
33
+ throw usageError("Global input mutation requires the current native caller Session.");
34
+ }
35
+ }
36
+ else if (env.YUI_ROLE !== undefined || env.YUI_AGENT_ID !== undefined
37
+ || env.YUI_NATIVE_SESSION_ID !== undefined) {
38
+ throw usageError("Incomplete managed identity cannot acquire user authority.");
39
+ }
40
+ }
13
41
  switch (command) {
14
42
  case "add": return addRole(rest, store, options);
15
43
  case "list": return listRoles(rest, store);
@@ -21,6 +49,8 @@ export function runGlobalRoleCommand(args, store, options = {}) {
21
49
  case "unbind": return unbindRole(rest, store);
22
50
  case "enter": return enterRole(rest, store);
23
51
  case "session": return roleSession(rest, store, options);
52
+ case "message": return globalRoleMessage(rest, store, options);
53
+ case "interrupt": return globalRoleInterrupt(rest, store, options);
24
54
  default:
25
55
  throw usageError(command === undefined
26
56
  ? "Role command is required."
@@ -32,7 +62,9 @@ function roleContext(args, store, options) {
32
62
  const name = roleName(rawName);
33
63
  assertNoArguments(rest, "Session context usage: yui session context <role>");
34
64
  const environment = options.env ?? process.env;
35
- if (environment.YUI_SESSION_SCOPE !== undefined) {
65
+ // Context reads expose durable input and runtime facts without delivering it.
66
+ const selfRead = environment.YUI_SESSION_SCOPE !== undefined;
67
+ if (selfRead) {
36
68
  if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ROLE !== name) {
37
69
  throw usageError("Managed GlobalRole context is outside the exact Session authority.");
38
70
  }
@@ -45,6 +77,19 @@ function roleContext(args, store, options) {
45
77
  const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
46
78
  const sessions = store.getGlobalRoleSessionSet(name);
47
79
  const session = sessions?.sessions[role.activeAgentId];
80
+ // Only the controlled Endpoint's accepted receipt consumes a queued Message.
81
+ const queued = store.listGlobalRoleMessages(name).filter((message) => message.delivery === undefined
82
+ && message.notDelivered === undefined
83
+ && (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
84
+ const pendingQueue = queued.map((message) => ({
85
+ id: message.id,
86
+ requestId: message.interruptThen?.requestId ?? message.inputControl?.requestId,
87
+ body: message.body,
88
+ ...(message.control === undefined ? {} : { control: message.control }),
89
+ ...(message.deliveryTarget === undefined ? {} : { deliveryTarget: message.deliveryTarget }),
90
+ ...(message.interruptThen === undefined ? {} : { interruptThen: true }),
91
+ ...(message.delivery === undefined ? {} : { deliveredAt: message.delivery.deliveredAt })
92
+ }));
48
93
  const context = Object.freeze({
49
94
  schemaVersion: 1,
50
95
  protocol: "yui-managed-context/v1",
@@ -79,6 +124,12 @@ function roleContext(args, store, options) {
79
124
  ? ["route-request", "inspect-catalog", "answer-user-boundary"]
80
125
  : ["perform-global-role-request"]
81
126
  },
127
+ // Reading pending input is neither delivery nor implementation.
128
+ pendingMessages: pendingQueue,
129
+ messages: store.listGlobalRoleMessages(name),
130
+ nativeTurn: sessions?.providerBinding?.run ?? null,
131
+ retry: providerRetryProjection(sessions?.providerBinding),
132
+ interrupts: sessions?.interrupts ?? {},
82
133
  sessionManifestPath: environment.YUI_SESSION_MANIFEST,
83
134
  cliCommand: "yui"
84
135
  });
@@ -92,6 +143,8 @@ function roleContext(args, store, options) {
92
143
  `Effective revision: ${role.launchRevision}`,
93
144
  `Skills: ${context.profile.skillIds.join(", ") || "none"}`,
94
145
  `Authority: ${context.authority.view}; Task implementation: no`,
146
+ `Pending messages: ${pendingQueue.length === 0 ? "none"
147
+ : pendingQueue.map((message) => message.id).join(", ")}`,
95
148
  ""
96
149
  ].join("\n");
97
150
  }
@@ -109,9 +162,16 @@ function addRole(args, store, options) {
109
162
  const created = store.transaction((tx) => {
110
163
  assertRoleRuntimeMutationAllowed(tx, { scope: "global", roleName: name }, "creation");
111
164
  const agent = requireAgent(agentId, tx);
112
- const workspace = trimmed(parsed.one("--workspace"))
113
- ?? tx.getConfig().defaultWorkspace
114
- ?? process.cwd();
165
+ // An explicit --workspace is a user-chosen cwd and keeps its own semantics
166
+ // (external roots stay external). When none is given, default to a
167
+ // Home-internal Global Role working directory rather than the external
168
+ // `defaultWorkspace` or the ambient process.cwd(), so Yui-auto-created
169
+ // scratch never lands outside the canonical Home.
170
+ const explicitWorkspace = trimmed(parsed.one("--workspace"));
171
+ const workspace = explicitWorkspace
172
+ ?? (options.yuiHome !== undefined
173
+ ? managedGlobalRoleWorkspace(options.yuiHome)
174
+ : tx.getConfig().defaultWorkspace ?? process.cwd());
115
175
  const binding = patchRoleAgentBinding(createRoleAgentBinding(definition(agent)), parsed);
116
176
  const profile = roleProfileFrom(parsed);
117
177
  validateConfiguredRoleSkills(options.yuiHome, profile.skills ?? []);
@@ -325,6 +385,28 @@ function enterRole(args, store) {
325
385
  }
326
386
  function roleSession(args, store, options) {
327
387
  const [command, rawName, ...tail] = args;
388
+ if (command === "retry") {
389
+ const name = roleName(rawName);
390
+ const [action = "show", ...extra] = tail;
391
+ if (extra.length > 0 || !["show", "cancel", "disable", "enable"].includes(action)) {
392
+ throw usageError("Usage: yui session retry <role> [show|cancel|disable|enable]");
393
+ }
394
+ return store.transaction(tx => {
395
+ requireRole(name, tx);
396
+ const sessions = tx.getGlobalRoleSessionSet(name);
397
+ let binding = sessions?.providerBinding ?? null;
398
+ if (action !== "show") {
399
+ if (sessions === null || binding === null)
400
+ throw usageError("No current Provider Session.");
401
+ binding = controlProviderRetry(binding, action, Date.now());
402
+ tx.saveGlobalRoleSessionSet({ ...sessions, providerBinding: binding });
403
+ settleGlobalRetryInput(tx, name, binding, new Date());
404
+ }
405
+ return options.jsonOutput ? JSON.stringify({
406
+ roleName: name, retry: providerRetryProjection(binding), disabled: binding?.retryDisabled ?? false
407
+ }) + "\n" : renderProviderRetry(binding) + "\n";
408
+ });
409
+ }
328
410
  if (command !== "record" && command !== "replace") {
329
411
  throw usageError("Session usage: yui session record|replace <role> --native-id <id> [--reason <reason>].");
330
412
  }
@@ -404,6 +486,259 @@ function assertSessionProvenance(command, role, nativeSessionId, environment) {
404
486
  throw usageError("Native session id does not match CODEX_THREAD_ID.");
405
487
  }
406
488
  }
489
+ /**
490
+ * The shared application-layer primitive for a durable Global Role input
491
+ * (decision-3 §7: CLI and Web/API share one entry, not per-surface fallbacks).
492
+ * It persists the Global-owned Message with an explicit owner (the Role name)
493
+ * and a stable requestId, idempotent exactly like a Task input: an exact repeat
494
+ * of the same requestId returns the original Message, and any different body,
495
+ * action, or expectedTarget under the same id is a conflicting reuse, never a
496
+ * silent second input (decision-3 §6). It never fabricates a Task or a native
497
+ * Turn; it only records intent.
498
+ */
499
+ export function sendGlobalRoleMessageCommand(store, roleName, body, author, inputControl, now) {
500
+ if (!body.trim())
501
+ throw usageError("Message body is required.");
502
+ const existing = store.listGlobalRoleMessages(roleName).find((entry) => (entry.inputControl ?? entry.interruptThen?.reusedInput)?.requestId === inputControl.requestId);
503
+ if (existing !== undefined) {
504
+ const original = existing.inputControl ?? existing.interruptThen?.reusedInput;
505
+ if (original?.action !== inputControl.action
506
+ || existing.body !== body
507
+ || original.expectedTarget !== inputControl.expectedTarget) {
508
+ throw usageError(`Input requestId ${inputControl.requestId} was already used with different content or target; use a new requestId for a new input.`);
509
+ }
510
+ return { message: existing, idempotentReplay: true };
511
+ }
512
+ const kind = author.type;
513
+ let message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), roleName, body, kind, author, now, { inputControl });
514
+ const sessions = store.getGlobalRoleSessionSet(roleName);
515
+ const session = sessions?.sessions[sessions.activeAgentId];
516
+ if (session !== undefined)
517
+ message = { ...message,
518
+ deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId } };
519
+ store.saveGlobalRoleMessage(message);
520
+ return { message, idempotentReplay: false };
521
+ }
522
+ /**
523
+ * `yui role message queue|steer <role> ...` — the durable Global input actions.
524
+ * `queue` persists and returns queued (delivered at the Role's next legal
525
+ * execution opportunity by the existing global-role-runtime mailbox, never a new
526
+ * private queue). `steer` persists then resolves the exact current native Turn
527
+ * from durable state; on a ready resolution it returns a structured live intent
528
+ * the CLI performs against the Agent Host with scope "global", and on any
529
+ * explicit failure it returns the saved Message plus the exact code and never
530
+ * falls back to interrupt or queue (decision-3 §1/§5).
531
+ */
532
+ function globalRoleMessage(args, store, options) {
533
+ const [action, rawName, ...tail] = args;
534
+ if (action !== "queue" && action !== "steer") {
535
+ throw usageError("Role message usage: yui role message queue|steer <role> (<body>|--body-file <path|->) --request-id <id> [--expected-target <turn>].");
536
+ }
537
+ const usage = action === "queue"
538
+ ? "Role message queue usage: yui role message queue <role> (<body>|--body-file <path|->) --request-id <id>."
539
+ : "Role message steer usage: yui role message steer <role> (<body>|--body-file <path|->) --request-id <id> --expected-target <turn>.";
540
+ const name = roleName(rawName);
541
+ // A leading positional before the flags is the inline body; the rest are flags.
542
+ const positionalBody = tail.length > 0 && !tail[0].startsWith("--") ? tail[0] : undefined;
543
+ const flags = positionalBody === undefined ? tail : tail.slice(1);
544
+ const parsed = parseOptions(flags, new Map([
545
+ ["--body-file", false],
546
+ ["--request-id", false],
547
+ ...(action === "steer" ? [["--expected-target", false]] : [])
548
+ ]));
549
+ const body = readCommandText(positionalBody, parsed.one("--body-file"), "--body", usage);
550
+ const requestId = required(parsed.one("--request-id"), "--request-id");
551
+ const expectedTarget = action === "steer"
552
+ ? required(parsed.one("--expected-target"), "--expected-target")
553
+ : undefined;
554
+ const now = new Date();
555
+ // The durable Global input is authored as an operator input: a Global Role has
556
+ // no Task Leader, and the operator is the human authority over Global Roles.
557
+ const environment = options.env ?? process.env;
558
+ const author = { type: environment.YUI_SESSION_SCOPE === "global"
559
+ ? environment.YUI_ROLE === "operator" ? "operator" : "agent" : "user" };
560
+ const persisted = store.transaction((tx) => {
561
+ requireRole(name, tx);
562
+ return sendGlobalRoleMessageCommand(tx, name, body, author, { action, requestId, ...(expectedTarget === undefined ? {} : { expectedTarget }) }, now);
563
+ });
564
+ if (action === "queue") {
565
+ const state = persisted.idempotentReplay ? "idempotent-replay" : "queued";
566
+ return jsonOrText(options, `Queued Global message ${persisted.message.id} to ${name} (${state}).\n`, { roleName: name, message: persisted.message, delivery: { state } });
567
+ }
568
+ if (persisted.idempotentReplay) {
569
+ return jsonOrText(options, `Steer Global message ${persisted.message.id} already recorded (idempotent-replay).\n`, { roleName: name, message: persisted.message, steer: { state: "idempotent-replay" } });
570
+ }
571
+ const resolution = resolveGlobalInputControl(store, name, "steer", expectedTarget);
572
+ if (resolution.outcome !== "ready") {
573
+ return jsonOrText(options, `Steer Global message ${persisted.message.id} saved but not delivered (${resolution.code}: ${resolution.detail}).\n`, { roleName: name, message: persisted.message,
574
+ steer: { state: "not-steered", code: resolution.code, detail: resolution.detail } });
575
+ }
576
+ store.updateGlobalRoleMessage({ ...persisted.message, control: {
577
+ requestId, receiptId: `steer:${name}/${persisted.message.id}`,
578
+ outcome: "pending", observedAt: now.toISOString()
579
+ } });
580
+ return {
581
+ kind: "input-steer",
582
+ roleName: name,
583
+ messageId: persisted.message.id,
584
+ target: resolution.target,
585
+ receiptId: `steer:${name}/${persisted.message.id}`,
586
+ text: body,
587
+ output: `Steering ${name} at Turn ${resolution.target.nativeTurnId ?? resolution.target.attemptId} with message ${persisted.message.id}.\n`
588
+ };
589
+ }
590
+ /**
591
+ * `yui role interrupt <role> --expected-target <turn> [--then-message <ref>]` —
592
+ * a live interrupt of a Global Role's exact current native Turn. A bare
593
+ * interrupt persists no Message (it is pure control); a `--then-message` names
594
+ * an already-persisted durable Global Message to deliver once after a proven
595
+ * terminal — the ordered composition of interrupt and an existing queue, not a
596
+ * fourth action and not an auto-fallback (decision-3 §4).
597
+ */
598
+ function globalRoleInterrupt(args, store, options) {
599
+ const usage = "Role interrupt usage: yui role interrupt <role> --expected-target <turn> [--then-message <global-message-id>] [--request-id <id>].";
600
+ const [rawName, ...tail] = args;
601
+ const name = roleName(rawName);
602
+ const parsed = parseOptions(tail, new Map([
603
+ ["--expected-target", false],
604
+ ["--then-message", false],
605
+ ["--request-id", false]
606
+ ]));
607
+ const expectedTarget = required(parsed.one("--expected-target"), "--expected-target");
608
+ const thenMessageId = trimmed(parsed.one("--then-message"));
609
+ const requestId = trimmed(parsed.one("--request-id")) ?? `cancel:${encodeURIComponent(expectedTarget)}`;
610
+ const fingerprint = JSON.stringify({ expectedTarget, thenMessageId });
611
+ const prior = store.getGlobalRoleSessionSet(name)?.interrupts?.[requestId];
612
+ if (prior !== undefined) {
613
+ if (prior.fingerprint !== fingerprint)
614
+ throw usageError("Interrupt requestId already names another control.");
615
+ return jsonOrText(options, `Interrupt ${requestId} already recorded; no native control repeated.\n`, { roleName: name, interrupt: prior.receipt ?? { state: "interrupt-unknown" }, idempotentReplay: true });
616
+ }
617
+ const resolved = store.transaction((tx) => {
618
+ requireRole(name, tx);
619
+ const resolution = resolveGlobalInputControl(tx, name, "interrupt", expectedTarget);
620
+ if (resolution.outcome !== "ready")
621
+ return resolution;
622
+ const sessions = tx.getGlobalRoleSessionSet(name);
623
+ if (Object.values(sessions.interrupts ?? {}).some(entry => entry.attemptId === resolution.target.attemptId
624
+ && entry.nativeSessionId === resolution.target.nativeSessionId
625
+ && !(entry.receipt?.state === "interrupt-unavailable" && entry.receipt.outcome === "rejected"))) {
626
+ return { outcome: "then-conflict", code: "TARGET_CHANGED",
627
+ detail: "This exact native Turn already has a recorded cancel request; inspect it instead of repeating the control." };
628
+ }
629
+ // decision-3 §4: before the live cancel, register the continuation claim on
630
+ // the already-persisted durable Global Message in one short transaction, so
631
+ // the handoff relationship, its target Turn, and its stable requestId are
632
+ // durable independent of the cancel outcome. An explicit then-Message must be
633
+ // an already-persisted durable Global Message owned by this exact Role; it is
634
+ // never created here and never a Task record (decision-3 §4/§9).
635
+ if (thenMessageId !== undefined) {
636
+ const claim = registerGlobalInterruptThen(tx, name, thenMessageId, resolution.target.attemptId, resolution.target.nativeTurnId, requestId, resolution.target);
637
+ if (claim !== "claimed")
638
+ return { outcome: "then-conflict", ...claim };
639
+ }
640
+ tx.saveGlobalRoleSessionSet({ ...sessions, interrupts: { ...sessions.interrupts,
641
+ [requestId]: { fingerprint, attemptId: resolution.target.attemptId,
642
+ nativeSessionId: resolution.target.nativeSessionId,
643
+ receiptId: `interrupt:${name}/${requestId}` }
644
+ } });
645
+ return resolution;
646
+ });
647
+ if (resolved.outcome === "then-conflict") {
648
+ return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
649
+ }
650
+ if (resolved.outcome !== "ready") {
651
+ return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
652
+ }
653
+ return {
654
+ kind: "input-interrupt",
655
+ roleName: name,
656
+ target: resolved.target,
657
+ receiptId: `interrupt:${name}/${requestId}`,
658
+ ...(thenMessageId === undefined ? {} : { thenMessageId }),
659
+ output: `Interrupting ${name} at Turn ${resolved.target.nativeTurnId ?? resolved.target.attemptId}`
660
+ + `${thenMessageId === undefined ? "" : `, then delivering ${thenMessageId} once after a proven terminal`}.\n`
661
+ };
662
+ }
663
+ /**
664
+ * Bind a saved durable Global Message as the single interrupt-then continuation
665
+ * of an exact interrupted native Turn (decision-3 §4). The Global twin of the
666
+ * Task `registerInterruptThen`: a Global Role has no AgentRun, so the
667
+ * target is proven by the exact native Turn's attemptId under the Role's own
668
+ * writer fence, never by a fabricated Task Run. Idempotent per interrupt
669
+ * requestId; only one continuation may claim a given target Turn; a Message that
670
+ * was already delivered or already claims another target is refused, never
671
+ * silently dropped or duplicated.
672
+ */
673
+ function registerGlobalInterruptThen(store, roleName, thenMessageId, targetAttemptId, targetNativeTurnId, requestId, target) {
674
+ const message = store.listGlobalRoleMessages(roleName).find((entry) => entry.id === thenMessageId);
675
+ if (message === undefined) {
676
+ throw usageError(`Then-Message ${thenMessageId} is not a durable Global Message owned by ${roleName}.`);
677
+ }
678
+ if (message.notDelivered !== undefined) {
679
+ return { code: "TARGET_CHANGED", detail: `The saved input is already settled not-delivered (${message.notDelivered.reason}).` };
680
+ }
681
+ if (message.control !== undefined && message.control.outcome !== "rejected") {
682
+ return { code: "TARGET_CHANGED", detail: "The saved input is already accepted or its delivery remains unconfirmed." };
683
+ }
684
+ if (message.deliveryTarget !== undefined
685
+ && (message.deliveryTarget.nativeSessionId !== target.nativeSessionId
686
+ || message.deliveryTarget.agentId !== target.agentId)) {
687
+ return { code: "TARGET_CHANGED", detail: "The saved input belongs to another native Session." };
688
+ }
689
+ // A synthesized fallback id is an opaque idempotency key (compared only for
690
+ // equality, never parsed), so it must satisfy the same safe-identity rule as a
691
+ // caller-supplied requestId: no path separators. Use `:` as the field joiner —
692
+ // the receiptId carries the `/`-shaped human receipt, this key stays slash-free.
693
+ const claimRequestId = requestId ?? `interrupt:${roleName}:${encodeURIComponent(targetAttemptId)}`;
694
+ // Idempotent per interrupt requestId: an exact repeat of the same claim is a
695
+ // no-op that still authorizes the live cancel.
696
+ if (message.interruptThen !== undefined) {
697
+ const claim = message.interruptThen;
698
+ const prior = store.getGlobalRoleSessionSet(roleName)?.interrupts?.[claim.requestId];
699
+ const canReferenceClaim = claim.requestId === claimRequestId
700
+ || (prior?.receipt?.state === "interrupt-unavailable" && prior.receipt.outcome === "rejected");
701
+ if (canReferenceClaim && message.delivery === undefined && message.notDelivered === undefined
702
+ && claim.targetAttemptId === targetAttemptId
703
+ && claim.targetAgentId === target.agentId && claim.targetNativeSessionId === target.nativeSessionId
704
+ && claim.targetAuthorityEpoch === target.authority.epoch
705
+ && claim.targetAuthorityHolderId === target.authority.holderId)
706
+ return "claimed";
707
+ return { code: "TARGET_CHANGED",
708
+ detail: `Message ${thenMessageId} already claims a continuation of Turn ${message.interruptThen.targetAttemptId}.` };
709
+ }
710
+ // A Message that already delivered is a settled fact, not a fresh input to
711
+ // reuse; reusing it would replay a consumed queue entry.
712
+ if (message.delivery !== undefined) {
713
+ return { code: "TARGET_CHANGED",
714
+ detail: `Message ${thenMessageId} was already delivered at ${message.delivery.deliveredAt}.` };
715
+ }
716
+ // Only one continuation may claim a given target Turn.
717
+ const existing = store.listGlobalRoleMessages(roleName).find((entry) => entry.interruptThen?.targetAttemptId === targetAttemptId && entry.id !== message.id);
718
+ if (existing !== undefined) {
719
+ return { code: "TARGET_CHANGED",
720
+ detail: `Turn ${targetAttemptId} is already the terminal target of Message ${existing.id}.` };
721
+ }
722
+ store.updateGlobalRoleMessage(claimGlobalRoleMessageInterruptThen(message, {
723
+ requestId: claimRequestId, targetAttemptId,
724
+ targetNativeSessionId: target.nativeSessionId, targetAgentId: target.agentId,
725
+ targetAuthorityEpoch: target.authority.epoch,
726
+ targetAuthorityHolderId: target.authority.holderId,
727
+ ...(targetNativeTurnId === undefined ? {} : { targetNativeTurnId })
728
+ }));
729
+ return "claimed";
730
+ }
731
+ /** Emit a plain string or, under --json, a JSON envelope, matching the Global
732
+ * Role command surface's existing string/JSON split. */
733
+ function jsonOrText(options, text, data) {
734
+ const record = data;
735
+ const failure = record.steer?.code === undefined ? record.interrupt : record.steer;
736
+ if (failure?.code !== undefined)
737
+ options.onInputFailure?.({
738
+ code: failure.code, detail: failure.detail ?? failure.code, data
739
+ });
740
+ return options.jsonOutput === true ? JSON.stringify(data) : text;
741
+ }
407
742
  function parseOptions(args, specs) {
408
743
  const values = new Map();
409
744
  const seen = new Set();
@@ -1,12 +1,13 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readdir, rename, rm } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { usageError } from "../errors/cliError.js";
4
+ import { CliError, usageError } from "../errors/cliError.js";
5
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
6
6
  import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../repository/checkoutSwap.js";
7
- import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
7
+ import { GitWorkspaceRefreshError, NodeGitWorkspace } from "../repository/gitWorkspace.js";
8
8
  import { acquireProjectMaintenanceLock } from "../repository/projectMaintenanceLock.js";
9
9
  import { addProjectKnowledge, addKnowledgeProposal, assertProjectActive, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
10
+ import { managedWorkspacesRoot } from "../storage/homeLayout.js";
10
11
  import { projectActor } from "./taskActor.js";
11
12
  export async function runProjectCommand(args, store, options = {}) {
12
13
  const [command, ...rest] = args;
@@ -21,10 +22,14 @@ export async function runProjectCommand(args, store, options = {}) {
21
22
  }
22
23
  if (command === "refresh") {
23
24
  const refreshed = await refreshProject(rest, store, options);
25
+ const head = refreshed.changed
26
+ ? `Refreshed project ${refreshed.project.id}: ${refreshed.fromCommit} -> ${refreshed.toCommit}`
27
+ : `Project ${refreshed.project.id} HEAD is already current at ${refreshed.toCommit}`;
28
+ const tracking = refreshed.tracking;
24
29
  return {
25
- output: refreshed.changed
26
- ? `Refreshed project ${refreshed.project.id}: ${refreshed.fromCommit} -> ${refreshed.toCommit}\n`
27
- : `Project ${refreshed.project.id} is already current at ${refreshed.toCommit}\n`,
30
+ output: `${head}\n` + (tracking.status === "unmanaged"
31
+ ? `Tracking unmanaged: ${tracking.reason}\n`
32
+ : `Tracking ${tracking.status}: ${tracking.ref} at ${tracking.toCommit}\n`),
28
33
  data: refreshed
29
34
  };
30
35
  }
@@ -100,14 +105,23 @@ async function refreshProject(args, store, options) {
100
105
  // RFC Phase 1: hold the per-Project maintenance fence for the whole refresh
101
106
  // so Task workspace preparation and other maintenance cannot interleave
102
107
  // with the canonical branch/working-tree move.
103
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
108
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
104
109
  try {
110
+ const current = requireUnchangedMaintenanceProject(store, project, "refresh");
105
111
  const refreshed = await (options.git ?? new NodeGitWorkspace()).refresh({
106
- repositoryPath: project.path,
107
- remoteUrl: project.remoteUrl,
108
- stableRef: project.stableBranch
112
+ repositoryPath: current.path,
113
+ remoteUrl: current.remoteUrl,
114
+ stableRef: current.stableBranch
109
115
  });
110
- return { project, ...refreshed };
116
+ return { project: current, ...refreshed };
117
+ }
118
+ catch (error) {
119
+ if (error instanceof GitWorkspaceRefreshError) {
120
+ throw new CliError("RUNTIME_ERROR", error.message, undefined, {
121
+ projectId: project.id, refresh: error.result
122
+ });
123
+ }
124
+ throw error;
111
125
  }
112
126
  finally {
113
127
  releaseMaintenance();
@@ -130,8 +144,9 @@ async function diagnoseProject(args, store, options) {
130
144
  };
131
145
  }
132
146
  const git = options.git ?? new NodeGitWorkspace();
133
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
147
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
134
148
  try {
149
+ requireUnchangedMaintenanceProject(store, project, "diagnose");
135
150
  const current = await git.inspect(project.path, "HEAD");
136
151
  const remote = await git.resolveRemoteBaseline({
137
152
  repositoryPath: project.path,
@@ -235,7 +250,7 @@ async function cloneProject(args, store, options) {
235
250
  if (dirname(destination) !== workspaceRoot) {
236
251
  throw usageError("Project clone destination must be directly inside the configured workspace.");
237
252
  }
238
- assertOutsideManagedWorktrees(destination, workspace);
253
+ assertOutsideManagedWorktrees(destination, store.rootDirectory());
239
254
  ownership = "external";
240
255
  }
241
256
  else {
@@ -306,13 +321,14 @@ async function migrateProject(args, store, options) {
306
321
  // Migration rewrites the Project's Git repository: hold the per-Project
307
322
  // maintenance fence so no rebuild/archive/cleanup (or a second migrate)
308
323
  // interleaves, and the Controller defers worktree preparation meanwhile.
309
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
324
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
310
325
  try {
311
326
  // Re-read the Project under the fence. A concurrent migration may have
312
327
  // completed and switched the catalog to the Home-managed repo since the
313
328
  // record was resolved above; the stale snapshot must not drive any Git
314
329
  // effect (in particular it must not delete the now-canonical repo).
315
330
  const current = requireProject(store, project.id);
331
+ assertProjectActive(current, "migrate");
316
332
  if (current.ownership === "managed") {
317
333
  throw usageError(`Project is already Home-managed: ${project.id}.`);
318
334
  }
@@ -461,10 +477,10 @@ async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, bran
461
477
  async function addProject(args, store, options) {
462
478
  const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
463
479
  const parsed = parseAddArguments(args, usage);
464
- assertOutsideManagedWorktrees(parsed.path, store.getConfig().defaultWorkspace);
480
+ assertOutsideManagedWorktrees(parsed.path, store.rootDirectory());
465
481
  const git = options.git ?? new NodeGitWorkspace();
466
482
  const head = await git.inspect(parsed.path, "HEAD");
467
- assertOutsideManagedWorktrees(head.root, store.getConfig().defaultWorkspace);
483
+ assertOutsideManagedWorktrees(head.root, store.rootDirectory());
468
484
  if (!await git.isClean(head.root)) {
469
485
  throw usageError("Project checkout must be clean before it can be registered.");
470
486
  }
@@ -587,7 +603,7 @@ async function resetProject(args, store, options) {
587
603
  throw usageError(`Project reset requires matching stable and development branches: ${project.id}.`);
588
604
  }
589
605
  const git = options.git ?? new NodeGitWorkspace();
590
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
606
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
591
607
  try {
592
608
  // Re-read under the fence so a concurrent catalog change can never drive
593
609
  // a destructive Git effect from a stale snapshot.
@@ -712,7 +728,7 @@ async function replaceProject(args, store, options) {
712
728
  + "Re-run with --discard-local to acknowledge that the checkout and any uncommitted state will be discarded.");
713
729
  }
714
730
  const git = options.git ?? new NodeGitWorkspace();
715
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
731
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
716
732
  try {
717
733
  const current = requireProject(store, project.id);
718
734
  assertProjectActive(current, "replace");
@@ -921,8 +937,9 @@ async function deleteProjectCommand(args, store, options) {
921
937
  // every failure restores it, so the catalog never loses its recoverable
922
938
  // entry while a live checkout (or its failure) is still in play.
923
939
  const tombstone = join(store.rootDirectory(), "projects", `.delete-${project.id}`);
924
- const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
940
+ const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
925
941
  try {
942
+ requireUnchangedMaintenanceProject(store, project, "delete");
926
943
  // Heal a crashed earlier attempt before the prechecks turn.
927
944
  await healCheckoutSwap({ currentPath: project.path, backupPath: tombstone });
928
945
  if (existsSync(project.path)) {
@@ -1097,10 +1114,8 @@ function assertProjectAvailable(store, project, exceptId) {
1097
1114
  }
1098
1115
  }
1099
1116
  }
1100
- function assertOutsideManagedWorktrees(path, workspace) {
1101
- if (workspace === undefined)
1102
- return;
1103
- const managedRoot = join(resolve(workspace), "worktree");
1117
+ function assertOutsideManagedWorktrees(path, home) {
1118
+ const managedRoot = managedWorkspacesRoot(home);
1104
1119
  const candidate = resolve(path);
1105
1120
  const fromManagedRoot = relative(managedRoot, candidate);
1106
1121
  const toManagedRoot = relative(candidate, managedRoot);
@@ -1290,6 +1305,19 @@ function projectKnowledge(args, store, options) {
1290
1305
  ? "Project knowledge command is required."
1291
1306
  : `Unknown command: project knowledge ${command}`);
1292
1307
  }
1308
+ /** Waiting yields to catalog writers too; never apply stale Git/lifecycle inputs. */
1309
+ function requireUnchangedMaintenanceProject(store, expected, action) {
1310
+ const current = requireProject(store, expected.id);
1311
+ if (current.path !== expected.path
1312
+ || current.remoteUrl !== expected.remoteUrl
1313
+ || current.stableBranch !== expected.stableBranch
1314
+ || current.developmentBranch !== expected.developmentBranch
1315
+ || current.ownership !== expected.ownership
1316
+ || current.status !== expected.status) {
1317
+ throw new Error(`Project changed while waiting to ${action}: ${expected.id}; read current state before retrying.`);
1318
+ }
1319
+ return current;
1320
+ }
1293
1321
  function requireProject(store, reference) {
1294
1322
  const project = resolveProject(store.listProjects(), reference);
1295
1323
  if (project === null)
@@ -373,6 +373,24 @@ export function createReleaseActivatePorts(overrides = {}) {
373
373
  if (failed.length > 0) {
374
374
  throw new Error(`Release preflight storage compatibility checks failed: ${failed.join(", ")}.`);
375
375
  }
376
+ // Doctor proves the target's storage, not the pinned code in existing
377
+ // Hosts. Ask the target's upgrade boundary for its independent Host
378
+ // protocol proof as well; release activation itself still never migrates.
379
+ const hostCheck = spawnSync(process.execPath, [cli, "--json", "upgrade", "--update-preflight"], {
380
+ env: { ...process.env, YUI_HOME: home, NO_COLOR: "1" },
381
+ encoding: "utf8", timeout: 60_000
382
+ });
383
+ let preflight;
384
+ try {
385
+ preflight = JSON.parse(hostCheck.stdout).data;
386
+ }
387
+ catch {
388
+ throw new Error(`Release Host preflight produced no JSON: ${hostCheck.stderr.trim()}`);
389
+ }
390
+ if (hostCheck.status !== 0 || preflight?.outcome !== "update-preflight"
391
+ || preflight.status !== "already-current") {
392
+ throw new Error(`Release Host compatibility preflight failed: ${preflight?.message ?? preflight?.status ?? "unknown"}. ${preflight?.action ?? ""}`);
393
+ }
376
394
  }),
377
395
  killOwnedProcess: overrides.killOwnedProcess ?? ((owner) => {
378
396
  if (readLinuxProcessStartIdentity(owner.pid) !== owner.processStartIdentity)