@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -1,6 +1,10 @@
1
1
  import { roleNotFound, usageError } from "../errors/cliError.js";
2
2
  import { createRoleSessionSet, recordRoleAgentSession } from "../executor/agentExecutor.js";
3
3
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
4
+ import { managedGlobalRoleWorkspace } from "../storage/homeLayout.js";
5
+ import { createGlobalRoleMessage, claimGlobalRoleMessageInterruptThen } from "../message/message.js";
6
+ import { resolveGlobalInputControl } from "../message/inputControlResolution.js";
7
+ import { readCommandText } from "./textInput.js";
4
8
  import { defaultTableWidth, renderTable } from "../output/table.js";
5
9
  import { activeRoleSummary, renderRoleDetails } from "../output/rolePresentation.js";
6
10
  import { activeRoleAgentBinding, createGlobalRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateGlobalRole } from "../role/role.js";
@@ -10,6 +14,28 @@ import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./rol
10
14
  import { assertLiveRoleSessionAcknowledged, assertRoleRuntimeMutationAllowed, LIVE_SESSION_ACKNOWLEDGEMENT_OPTION } from "./roleRuntimeGuard.js";
11
15
  export function runGlobalRoleCommand(args, store, options = {}) {
12
16
  const [command, ...rest] = args;
17
+ if (command === "message" || command === "interrupt") {
18
+ const env = options.env ?? process.env;
19
+ const target = command === "message" ? rest[1] : rest[0];
20
+ if (env.YUI_SESSION_SCOPE !== undefined && env.YUI_SESSION_SCOPE !== "global"
21
+ || env.YUI_SESSION_SCOPE === "global" && env.YUI_ROLE !== "operator"
22
+ && env.YUI_ROLE !== target) {
23
+ throw usageError("Global input mutation is outside the exact Session authority.");
24
+ }
25
+ if (env.YUI_SESSION_SCOPE === "global") {
26
+ const caller = store.getGlobalRoleSessionSet(env.YUI_ROLE ?? "");
27
+ const session = caller?.sessions[caller.activeAgentId];
28
+ if (session === undefined || session.status !== "active" || env.YUI_AGENT_ID !== session.agentId
29
+ || env.YUI_ADAPTER_ID !== session.adapterId
30
+ || (env.CODEX_THREAD_ID ?? env.YUI_NATIVE_SESSION_ID) !== session.nativeSessionId) {
31
+ throw usageError("Global input mutation requires the current native caller Session.");
32
+ }
33
+ }
34
+ else if (env.YUI_ROLE !== undefined || env.YUI_AGENT_ID !== undefined
35
+ || env.YUI_NATIVE_SESSION_ID !== undefined) {
36
+ throw usageError("Incomplete managed identity cannot acquire user authority.");
37
+ }
38
+ }
13
39
  switch (command) {
14
40
  case "add": return addRole(rest, store, options);
15
41
  case "list": return listRoles(rest, store);
@@ -21,6 +47,8 @@ export function runGlobalRoleCommand(args, store, options = {}) {
21
47
  case "unbind": return unbindRole(rest, store);
22
48
  case "enter": return enterRole(rest, store);
23
49
  case "session": return roleSession(rest, store, options);
50
+ case "message": return globalRoleMessage(rest, store, options);
51
+ case "interrupt": return globalRoleInterrupt(rest, store, options);
24
52
  default:
25
53
  throw usageError(command === undefined
26
54
  ? "Role command is required."
@@ -32,7 +60,9 @@ function roleContext(args, store, options) {
32
60
  const name = roleName(rawName);
33
61
  assertNoArguments(rest, "Session context usage: yui session context <role>");
34
62
  const environment = options.env ?? process.env;
35
- if (environment.YUI_SESSION_SCOPE !== undefined) {
63
+ // Context reads expose durable input and runtime facts without delivering it.
64
+ const selfRead = environment.YUI_SESSION_SCOPE !== undefined;
65
+ if (selfRead) {
36
66
  if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ROLE !== name) {
37
67
  throw usageError("Managed GlobalRole context is outside the exact Session authority.");
38
68
  }
@@ -45,6 +75,19 @@ function roleContext(args, store, options) {
45
75
  const effective = resolveEffectiveLaunch({ role, purpose: "execution" });
46
76
  const sessions = store.getGlobalRoleSessionSet(name);
47
77
  const session = sessions?.sessions[role.activeAgentId];
78
+ // Only the controlled Endpoint's accepted receipt consumes a queued Message.
79
+ const queued = store.listGlobalRoleMessages(name).filter((message) => message.delivery === undefined
80
+ && message.notDelivered === undefined
81
+ && (message.interruptThen !== undefined || message.inputControl?.action === "queue"));
82
+ const pendingQueue = queued.map((message) => ({
83
+ id: message.id,
84
+ requestId: message.interruptThen?.requestId ?? message.inputControl?.requestId,
85
+ body: message.body,
86
+ ...(message.control === undefined ? {} : { control: message.control }),
87
+ ...(message.deliveryTarget === undefined ? {} : { deliveryTarget: message.deliveryTarget }),
88
+ ...(message.interruptThen === undefined ? {} : { interruptThen: true }),
89
+ ...(message.delivery === undefined ? {} : { deliveredAt: message.delivery.deliveredAt })
90
+ }));
48
91
  const context = Object.freeze({
49
92
  schemaVersion: 1,
50
93
  protocol: "yui-managed-context/v1",
@@ -79,6 +122,11 @@ function roleContext(args, store, options) {
79
122
  ? ["route-request", "inspect-catalog", "answer-user-boundary"]
80
123
  : ["perform-global-role-request"]
81
124
  },
125
+ // Reading pending input is neither delivery nor implementation.
126
+ pendingMessages: pendingQueue,
127
+ messages: store.listGlobalRoleMessages(name),
128
+ nativeTurn: sessions?.providerBinding?.run ?? null,
129
+ interrupts: sessions?.interrupts ?? {},
82
130
  sessionManifestPath: environment.YUI_SESSION_MANIFEST,
83
131
  cliCommand: "yui"
84
132
  });
@@ -92,6 +140,8 @@ function roleContext(args, store, options) {
92
140
  `Effective revision: ${role.launchRevision}`,
93
141
  `Skills: ${context.profile.skillIds.join(", ") || "none"}`,
94
142
  `Authority: ${context.authority.view}; Task implementation: no`,
143
+ `Pending messages: ${pendingQueue.length === 0 ? "none"
144
+ : pendingQueue.map((message) => message.id).join(", ")}`,
95
145
  ""
96
146
  ].join("\n");
97
147
  }
@@ -109,9 +159,16 @@ function addRole(args, store, options) {
109
159
  const created = store.transaction((tx) => {
110
160
  assertRoleRuntimeMutationAllowed(tx, { scope: "global", roleName: name }, "creation");
111
161
  const agent = requireAgent(agentId, tx);
112
- const workspace = trimmed(parsed.one("--workspace"))
113
- ?? tx.getConfig().defaultWorkspace
114
- ?? process.cwd();
162
+ // An explicit --workspace is a user-chosen cwd and keeps its own semantics
163
+ // (external roots stay external). When none is given, default to a
164
+ // Home-internal Global Role working directory rather than the external
165
+ // `defaultWorkspace` or the ambient process.cwd(), so Yui-auto-created
166
+ // scratch never lands outside the canonical Home.
167
+ const explicitWorkspace = trimmed(parsed.one("--workspace"));
168
+ const workspace = explicitWorkspace
169
+ ?? (options.yuiHome !== undefined
170
+ ? managedGlobalRoleWorkspace(options.yuiHome)
171
+ : tx.getConfig().defaultWorkspace ?? process.cwd());
115
172
  const binding = patchRoleAgentBinding(createRoleAgentBinding(definition(agent)), parsed);
116
173
  const profile = roleProfileFrom(parsed);
117
174
  validateConfiguredRoleSkills(options.yuiHome, profile.skills ?? []);
@@ -404,6 +461,259 @@ function assertSessionProvenance(command, role, nativeSessionId, environment) {
404
461
  throw usageError("Native session id does not match CODEX_THREAD_ID.");
405
462
  }
406
463
  }
464
+ /**
465
+ * The shared application-layer primitive for a durable Global Role input
466
+ * (decision-3 §7: CLI and Web/API share one entry, not per-surface fallbacks).
467
+ * It persists the Global-owned Message with an explicit owner (the Role name)
468
+ * and a stable requestId, idempotent exactly like a Task input: an exact repeat
469
+ * of the same requestId returns the original Message, and any different body,
470
+ * action, or expectedTarget under the same id is a conflicting reuse, never a
471
+ * silent second input (decision-3 §6). It never fabricates a Task or a native
472
+ * Turn; it only records intent.
473
+ */
474
+ export function sendGlobalRoleMessageCommand(store, roleName, body, author, inputControl, now) {
475
+ if (!body.trim())
476
+ throw usageError("Message body is required.");
477
+ const existing = store.listGlobalRoleMessages(roleName).find((entry) => (entry.inputControl ?? entry.interruptThen?.reusedInput)?.requestId === inputControl.requestId);
478
+ if (existing !== undefined) {
479
+ const original = existing.inputControl ?? existing.interruptThen?.reusedInput;
480
+ if (original?.action !== inputControl.action
481
+ || existing.body !== body
482
+ || original.expectedTarget !== inputControl.expectedTarget) {
483
+ throw usageError(`Input requestId ${inputControl.requestId} was already used with different content or target; use a new requestId for a new input.`);
484
+ }
485
+ return { message: existing, idempotentReplay: true };
486
+ }
487
+ const kind = author.type;
488
+ let message = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), roleName, body, kind, author, now, { inputControl });
489
+ const sessions = store.getGlobalRoleSessionSet(roleName);
490
+ const session = sessions?.sessions[sessions.activeAgentId];
491
+ if (session !== undefined)
492
+ message = { ...message,
493
+ deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId } };
494
+ store.saveGlobalRoleMessage(message);
495
+ return { message, idempotentReplay: false };
496
+ }
497
+ /**
498
+ * `yui role message queue|steer <role> ...` — the durable Global input actions.
499
+ * `queue` persists and returns queued (delivered at the Role's next legal
500
+ * execution opportunity by the existing global-role-runtime mailbox, never a new
501
+ * private queue). `steer` persists then resolves the exact current native Turn
502
+ * from durable state; on a ready resolution it returns a structured live intent
503
+ * the CLI performs against the Agent Host with scope "global", and on any
504
+ * explicit failure it returns the saved Message plus the exact code and never
505
+ * falls back to interrupt or queue (decision-3 §1/§5).
506
+ */
507
+ function globalRoleMessage(args, store, options) {
508
+ const [action, rawName, ...tail] = args;
509
+ if (action !== "queue" && action !== "steer") {
510
+ throw usageError("Role message usage: yui role message queue|steer <role> (<body>|--body-file <path|->) --request-id <id> [--expected-target <turn>].");
511
+ }
512
+ const usage = action === "queue"
513
+ ? "Role message queue usage: yui role message queue <role> (<body>|--body-file <path|->) --request-id <id>."
514
+ : "Role message steer usage: yui role message steer <role> (<body>|--body-file <path|->) --request-id <id> --expected-target <turn>.";
515
+ const name = roleName(rawName);
516
+ // A leading positional before the flags is the inline body; the rest are flags.
517
+ const positionalBody = tail.length > 0 && !tail[0].startsWith("--") ? tail[0] : undefined;
518
+ const flags = positionalBody === undefined ? tail : tail.slice(1);
519
+ const parsed = parseOptions(flags, new Map([
520
+ ["--body-file", false],
521
+ ["--request-id", false],
522
+ ...(action === "steer" ? [["--expected-target", false]] : [])
523
+ ]));
524
+ const body = readCommandText(positionalBody, parsed.one("--body-file"), "--body", usage);
525
+ const requestId = required(parsed.one("--request-id"), "--request-id");
526
+ const expectedTarget = action === "steer"
527
+ ? required(parsed.one("--expected-target"), "--expected-target")
528
+ : undefined;
529
+ const now = new Date();
530
+ // The durable Global input is authored as an operator input: a Global Role has
531
+ // no Task Leader, and the operator is the human authority over Global Roles.
532
+ const environment = options.env ?? process.env;
533
+ const author = { type: environment.YUI_SESSION_SCOPE === "global"
534
+ ? environment.YUI_ROLE === "operator" ? "operator" : "agent" : "user" };
535
+ const persisted = store.transaction((tx) => {
536
+ requireRole(name, tx);
537
+ return sendGlobalRoleMessageCommand(tx, name, body, author, { action, requestId, ...(expectedTarget === undefined ? {} : { expectedTarget }) }, now);
538
+ });
539
+ if (action === "queue") {
540
+ const state = persisted.idempotentReplay ? "idempotent-replay" : "queued";
541
+ return jsonOrText(options, `Queued Global message ${persisted.message.id} to ${name} (${state}).\n`, { roleName: name, message: persisted.message, delivery: { state } });
542
+ }
543
+ if (persisted.idempotentReplay) {
544
+ return jsonOrText(options, `Steer Global message ${persisted.message.id} already recorded (idempotent-replay).\n`, { roleName: name, message: persisted.message, steer: { state: "idempotent-replay" } });
545
+ }
546
+ const resolution = resolveGlobalInputControl(store, name, "steer", expectedTarget);
547
+ if (resolution.outcome !== "ready") {
548
+ return jsonOrText(options, `Steer Global message ${persisted.message.id} saved but not delivered (${resolution.code}: ${resolution.detail}).\n`, { roleName: name, message: persisted.message,
549
+ steer: { state: "not-steered", code: resolution.code, detail: resolution.detail } });
550
+ }
551
+ store.updateGlobalRoleMessage({ ...persisted.message, control: {
552
+ requestId, receiptId: `steer:${name}/${persisted.message.id}`,
553
+ outcome: "pending", observedAt: now.toISOString()
554
+ } });
555
+ return {
556
+ kind: "input-steer",
557
+ roleName: name,
558
+ messageId: persisted.message.id,
559
+ target: resolution.target,
560
+ receiptId: `steer:${name}/${persisted.message.id}`,
561
+ text: body,
562
+ output: `Steering ${name} at Turn ${resolution.target.nativeTurnId ?? resolution.target.attemptId} with message ${persisted.message.id}.\n`
563
+ };
564
+ }
565
+ /**
566
+ * `yui role interrupt <role> --expected-target <turn> [--then-message <ref>]` —
567
+ * a live interrupt of a Global Role's exact current native Turn. A bare
568
+ * interrupt persists no Message (it is pure control); a `--then-message` names
569
+ * an already-persisted durable Global Message to deliver once after a proven
570
+ * terminal — the ordered composition of interrupt and an existing queue, not a
571
+ * fourth action and not an auto-fallback (decision-3 §4).
572
+ */
573
+ function globalRoleInterrupt(args, store, options) {
574
+ const usage = "Role interrupt usage: yui role interrupt <role> --expected-target <turn> [--then-message <global-message-id>] [--request-id <id>].";
575
+ const [rawName, ...tail] = args;
576
+ const name = roleName(rawName);
577
+ const parsed = parseOptions(tail, new Map([
578
+ ["--expected-target", false],
579
+ ["--then-message", false],
580
+ ["--request-id", false]
581
+ ]));
582
+ const expectedTarget = required(parsed.one("--expected-target"), "--expected-target");
583
+ const thenMessageId = trimmed(parsed.one("--then-message"));
584
+ const requestId = trimmed(parsed.one("--request-id")) ?? `cancel:${encodeURIComponent(expectedTarget)}`;
585
+ const fingerprint = JSON.stringify({ expectedTarget, thenMessageId });
586
+ const prior = store.getGlobalRoleSessionSet(name)?.interrupts?.[requestId];
587
+ if (prior !== undefined) {
588
+ if (prior.fingerprint !== fingerprint)
589
+ throw usageError("Interrupt requestId already names another control.");
590
+ return jsonOrText(options, `Interrupt ${requestId} already recorded; no native control repeated.\n`, { roleName: name, interrupt: prior.receipt ?? { state: "interrupt-unknown" }, idempotentReplay: true });
591
+ }
592
+ const resolved = store.transaction((tx) => {
593
+ requireRole(name, tx);
594
+ const resolution = resolveGlobalInputControl(tx, name, "interrupt", expectedTarget);
595
+ if (resolution.outcome !== "ready")
596
+ return resolution;
597
+ const sessions = tx.getGlobalRoleSessionSet(name);
598
+ if (Object.values(sessions.interrupts ?? {}).some(entry => entry.attemptId === resolution.target.attemptId
599
+ && entry.nativeSessionId === resolution.target.nativeSessionId
600
+ && !(entry.receipt?.state === "interrupt-unavailable" && entry.receipt.outcome === "rejected"))) {
601
+ return { outcome: "then-conflict", code: "TARGET_CHANGED",
602
+ detail: "This exact native Turn already has a recorded cancel request; inspect it instead of repeating the control." };
603
+ }
604
+ // decision-3 §4: before the live cancel, register the continuation claim on
605
+ // the already-persisted durable Global Message in one short transaction, so
606
+ // the handoff relationship, its target Turn, and its stable requestId are
607
+ // durable independent of the cancel outcome. An explicit then-Message must be
608
+ // an already-persisted durable Global Message owned by this exact Role; it is
609
+ // never created here and never a Task record (decision-3 §4/§9).
610
+ if (thenMessageId !== undefined) {
611
+ const claim = registerGlobalInterruptThen(tx, name, thenMessageId, resolution.target.attemptId, resolution.target.nativeTurnId, requestId, resolution.target);
612
+ if (claim !== "claimed")
613
+ return { outcome: "then-conflict", ...claim };
614
+ }
615
+ tx.saveGlobalRoleSessionSet({ ...sessions, interrupts: { ...sessions.interrupts,
616
+ [requestId]: { fingerprint, attemptId: resolution.target.attemptId,
617
+ nativeSessionId: resolution.target.nativeSessionId,
618
+ receiptId: `interrupt:${name}/${requestId}` }
619
+ } });
620
+ return resolution;
621
+ });
622
+ if (resolved.outcome === "then-conflict") {
623
+ return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
624
+ }
625
+ if (resolved.outcome !== "ready") {
626
+ return jsonOrText(options, `Interrupt not delivered (${resolved.code}: ${resolved.detail}).\n`, { roleName: name, interrupt: { state: "not-interrupted", code: resolved.code, detail: resolved.detail } });
627
+ }
628
+ return {
629
+ kind: "input-interrupt",
630
+ roleName: name,
631
+ target: resolved.target,
632
+ receiptId: `interrupt:${name}/${requestId}`,
633
+ ...(thenMessageId === undefined ? {} : { thenMessageId }),
634
+ output: `Interrupting ${name} at Turn ${resolved.target.nativeTurnId ?? resolved.target.attemptId}`
635
+ + `${thenMessageId === undefined ? "" : `, then delivering ${thenMessageId} once after a proven terminal`}.\n`
636
+ };
637
+ }
638
+ /**
639
+ * Bind a saved durable Global Message as the single interrupt-then continuation
640
+ * of an exact interrupted native Turn (decision-3 §4). The Global twin of the
641
+ * Task `registerInterruptThen`: a Global Role has no AgentRun, so the
642
+ * target is proven by the exact native Turn's attemptId under the Role's own
643
+ * writer fence, never by a fabricated Task Run. Idempotent per interrupt
644
+ * requestId; only one continuation may claim a given target Turn; a Message that
645
+ * was already delivered or already claims another target is refused, never
646
+ * silently dropped or duplicated.
647
+ */
648
+ function registerGlobalInterruptThen(store, roleName, thenMessageId, targetAttemptId, targetNativeTurnId, requestId, target) {
649
+ const message = store.listGlobalRoleMessages(roleName).find((entry) => entry.id === thenMessageId);
650
+ if (message === undefined) {
651
+ throw usageError(`Then-Message ${thenMessageId} is not a durable Global Message owned by ${roleName}.`);
652
+ }
653
+ if (message.notDelivered !== undefined) {
654
+ return { code: "TARGET_CHANGED", detail: `The saved input is already settled not-delivered (${message.notDelivered.reason}).` };
655
+ }
656
+ if (message.control !== undefined && message.control.outcome !== "rejected") {
657
+ return { code: "TARGET_CHANGED", detail: "The saved input is already accepted or its delivery remains unconfirmed." };
658
+ }
659
+ if (message.deliveryTarget !== undefined
660
+ && (message.deliveryTarget.nativeSessionId !== target.nativeSessionId
661
+ || message.deliveryTarget.agentId !== target.agentId)) {
662
+ return { code: "TARGET_CHANGED", detail: "The saved input belongs to another native Session." };
663
+ }
664
+ // A synthesized fallback id is an opaque idempotency key (compared only for
665
+ // equality, never parsed), so it must satisfy the same safe-identity rule as a
666
+ // caller-supplied requestId: no path separators. Use `:` as the field joiner —
667
+ // the receiptId carries the `/`-shaped human receipt, this key stays slash-free.
668
+ const claimRequestId = requestId ?? `interrupt:${roleName}:${encodeURIComponent(targetAttemptId)}`;
669
+ // Idempotent per interrupt requestId: an exact repeat of the same claim is a
670
+ // no-op that still authorizes the live cancel.
671
+ if (message.interruptThen !== undefined) {
672
+ const claim = message.interruptThen;
673
+ const prior = store.getGlobalRoleSessionSet(roleName)?.interrupts?.[claim.requestId];
674
+ const canReferenceClaim = claim.requestId === claimRequestId
675
+ || (prior?.receipt?.state === "interrupt-unavailable" && prior.receipt.outcome === "rejected");
676
+ if (canReferenceClaim && message.delivery === undefined && message.notDelivered === undefined
677
+ && claim.targetAttemptId === targetAttemptId
678
+ && claim.targetAgentId === target.agentId && claim.targetNativeSessionId === target.nativeSessionId
679
+ && claim.targetAuthorityEpoch === target.authority.epoch
680
+ && claim.targetAuthorityHolderId === target.authority.holderId)
681
+ return "claimed";
682
+ return { code: "TARGET_CHANGED",
683
+ detail: `Message ${thenMessageId} already claims a continuation of Turn ${message.interruptThen.targetAttemptId}.` };
684
+ }
685
+ // A Message that already delivered is a settled fact, not a fresh input to
686
+ // reuse; reusing it would replay a consumed queue entry.
687
+ if (message.delivery !== undefined) {
688
+ return { code: "TARGET_CHANGED",
689
+ detail: `Message ${thenMessageId} was already delivered at ${message.delivery.deliveredAt}.` };
690
+ }
691
+ // Only one continuation may claim a given target Turn.
692
+ const existing = store.listGlobalRoleMessages(roleName).find((entry) => entry.interruptThen?.targetAttemptId === targetAttemptId && entry.id !== message.id);
693
+ if (existing !== undefined) {
694
+ return { code: "TARGET_CHANGED",
695
+ detail: `Turn ${targetAttemptId} is already the terminal target of Message ${existing.id}.` };
696
+ }
697
+ store.updateGlobalRoleMessage(claimGlobalRoleMessageInterruptThen(message, {
698
+ requestId: claimRequestId, targetAttemptId,
699
+ targetNativeSessionId: target.nativeSessionId, targetAgentId: target.agentId,
700
+ targetAuthorityEpoch: target.authority.epoch,
701
+ targetAuthorityHolderId: target.authority.holderId,
702
+ ...(targetNativeTurnId === undefined ? {} : { targetNativeTurnId })
703
+ }));
704
+ return "claimed";
705
+ }
706
+ /** Emit a plain string or, under --json, a JSON envelope, matching the Global
707
+ * Role command surface's existing string/JSON split. */
708
+ function jsonOrText(options, text, data) {
709
+ const record = data;
710
+ const failure = record.steer?.code === undefined ? record.interrupt : record.steer;
711
+ if (failure?.code !== undefined)
712
+ options.onInputFailure?.({
713
+ code: failure.code, detail: failure.detail ?? failure.code, data
714
+ });
715
+ return options.jsonOutput === true ? JSON.stringify(data) : text;
716
+ }
407
717
  function parseOptions(args, specs) {
408
718
  const values = new Map();
409
719
  const seen = new Set();
@@ -7,6 +7,7 @@ import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../r
7
7
  import { 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;
@@ -235,7 +236,7 @@ async function cloneProject(args, store, options) {
235
236
  if (dirname(destination) !== workspaceRoot) {
236
237
  throw usageError("Project clone destination must be directly inside the configured workspace.");
237
238
  }
238
- assertOutsideManagedWorktrees(destination, workspace);
239
+ assertOutsideManagedWorktrees(destination, store.rootDirectory());
239
240
  ownership = "external";
240
241
  }
241
242
  else {
@@ -461,10 +462,10 @@ async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, bran
461
462
  async function addProject(args, store, options) {
462
463
  const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
463
464
  const parsed = parseAddArguments(args, usage);
464
- assertOutsideManagedWorktrees(parsed.path, store.getConfig().defaultWorkspace);
465
+ assertOutsideManagedWorktrees(parsed.path, store.rootDirectory());
465
466
  const git = options.git ?? new NodeGitWorkspace();
466
467
  const head = await git.inspect(parsed.path, "HEAD");
467
- assertOutsideManagedWorktrees(head.root, store.getConfig().defaultWorkspace);
468
+ assertOutsideManagedWorktrees(head.root, store.rootDirectory());
468
469
  if (!await git.isClean(head.root)) {
469
470
  throw usageError("Project checkout must be clean before it can be registered.");
470
471
  }
@@ -1097,10 +1098,8 @@ function assertProjectAvailable(store, project, exceptId) {
1097
1098
  }
1098
1099
  }
1099
1100
  }
1100
- function assertOutsideManagedWorktrees(path, workspace) {
1101
- if (workspace === undefined)
1102
- return;
1103
- const managedRoot = join(resolve(workspace), "worktree");
1101
+ function assertOutsideManagedWorktrees(path, home) {
1102
+ const managedRoot = managedWorkspacesRoot(home);
1104
1103
  const candidate = resolve(path);
1105
1104
  const fromManagedRoot = relative(managedRoot, candidate);
1106
1105
  const toManagedRoot = relative(candidate, managedRoot);
@@ -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)
@@ -52,6 +52,31 @@ export function assertTaskDeliveryAuthority(store, environment, taskId) {
52
52
  }
53
53
  return actor;
54
54
  }
55
+ /**
56
+ * Authority for a live steer/interrupt of an exact current native Turn
57
+ * (decision-3 §1/§9, message-5 gap B). The legal evidence is the native
58
+ * Turn/Session, not planning→delivery elevation and not an AgentRun's existence:
59
+ * the resolution proves a present Turn under the caller's own current Session, so
60
+ * a planning (Draft) Leader may legally control its own management/planning Turn.
61
+ *
62
+ * Delivery authority is required only when a managed Leader redirects INTO a
63
+ * Worker/Reviewer execution Assignment (targetRole other than the Leader itself),
64
+ * which is the same boundary the addressed-Message send path enforces — a
65
+ * planning Session must not start or redirect execution work. A user or operator
66
+ * caller, and a Leader controlling its own Turn, need only be the current
67
+ * Session; an incomplete managed identity is refused by {@link taskLocalActor}.
68
+ */
69
+ export function assertTaskInputControlAuthority(store, environment, taskId, targetRole) {
70
+ const actor = taskLocalActor(store, environment, taskId);
71
+ if (actor !== "leader" || targetRole === LEADER_ROLE)
72
+ return actor;
73
+ const caller = currentManagedRuntime(store, environment, taskId, LEADER_ROLE);
74
+ if (caller?.executionAuthority !== "delivery") {
75
+ throw usageError("This native Session has planning authority, not delivery authority. "
76
+ + "Redirecting a Worker or Reviewer Assignment requires a delivery Session.");
77
+ }
78
+ return actor;
79
+ }
55
80
  export function projectActor(environment) {
56
81
  const env = environment ?? {};
57
82
  if (env.YUI_SESSION_SCOPE === "task")