@zq-silk/yui 0.11.3 → 0.12.1

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 (108) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +44 -22
  3. package/dist/cli/commandCatalog.js +13 -10
  4. package/dist/cli/interactionPolicy.js +5 -6
  5. package/dist/cli/updateCommand.js +3 -1
  6. package/dist/cli/updateOrchestrator.js +173 -28
  7. package/dist/cli/updatePorts.js +137 -8
  8. package/dist/cli/upgradeCommand.js +19 -9
  9. package/dist/cli.js +156 -57
  10. package/dist/commands/configCommands.js +13 -46
  11. package/dist/commands/executionAuditCommands.js +3 -2
  12. package/dist/commands/jobCommands.js +4 -10
  13. package/dist/commands/taskCommands.js +234 -200
  14. package/dist/commands/taskCompletionGate.js +29 -54
  15. package/dist/commands/taskContextCommand.js +61 -13
  16. package/dist/commands/taskInputCommands.js +8 -5
  17. package/dist/commands/taskNextActionCommand.js +56 -6
  18. package/dist/commands/taskOverviewCommand.js +17 -31
  19. package/dist/commands/taskRoleRuntimeStatus.js +55 -7
  20. package/dist/commands/taskWorkspaceCommands.js +5 -2
  21. package/dist/config/configCatalog.js +3 -3
  22. package/dist/config/yuiConfig.js +3 -7
  23. package/dist/context/wakeNotification.js +20 -5
  24. package/dist/controller/agentRuntimeObserver.js +247 -51
  25. package/dist/controller/clientRuntime.js +39 -3
  26. package/dist/controller/controller.js +31 -27
  27. package/dist/controller/fileSchedulerStoreAdapter.js +390 -332
  28. package/dist/controller/runtime.js +58 -3
  29. package/dist/controller/runtimeEventInbox.js +17 -7
  30. package/dist/controller/runtimeEventProcessor.js +12 -19
  31. package/dist/controller/runtimeHookRunFence.js +19 -4
  32. package/dist/controller/runtimeObservationHook.js +45 -0
  33. package/dist/controller/structuredProviderObservation.js +20 -3
  34. package/dist/core/controllerClient.js +20 -2
  35. package/dist/core/controllerServer.js +1 -0
  36. package/dist/execution/resourceBroker.js +5 -4
  37. package/dist/executor/agentExecutor.js +52 -50
  38. package/dist/executor/effectiveLaunch.js +8 -48
  39. package/dist/executor/fileRoleLaunchPlanner.js +129 -45
  40. package/dist/executor/workspacePreflightClassification.js +23 -2
  41. package/dist/interaction/operatorPresentation.js +33 -89
  42. package/dist/lifecycle/exactRunTerminalization.js +69 -5
  43. package/dist/observability/executionAudit.js +5 -0
  44. package/dist/observability/orchestrationMetrics.js +8 -3
  45. package/dist/profile/agentProfile.js +1 -1
  46. package/dist/release/cliHomeReleaseFence.js +123 -0
  47. package/dist/release/runtimeRelease.js +20 -0
  48. package/dist/repository/taskWorkspacePreparer.js +176 -60
  49. package/dist/resources/sqliteResourceRegistry.js +1 -1
  50. package/dist/review/deltaRecheck.js +12 -51
  51. package/dist/review/reviewAcceptance.js +26 -0
  52. package/dist/review/reviewConfig.js +0 -31
  53. package/dist/review/reviewDecision.js +113 -0
  54. package/dist/review/reviewOutcomeClassifier.js +1 -1
  55. package/dist/review/reviewRound.js +1 -1
  56. package/dist/review/reviewerAvailability.js +69 -0
  57. package/dist/run/recoveryProjection.js +45 -6
  58. package/dist/runtime/agentDriverObservation.js +24 -10
  59. package/dist/runtime/agentHost.js +159 -85
  60. package/dist/runtime/builtinAgentDrivers.js +4 -3
  61. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  62. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  63. package/dist/runtime/conversationSwitch.js +277 -0
  64. package/dist/runtime/index.js +2 -1
  65. package/dist/runtime/launchBroker.js +12 -0
  66. package/dist/runtime/processExitOutbox.js +88 -0
  67. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  68. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  69. package/dist/runtime/runtimeObservation.js +28 -0
  70. package/dist/runtime/runtimeProjection.js +22 -32
  71. package/dist/runtime/sessionTokenMetrics.js +181 -0
  72. package/dist/runtime/structuredProviderHost.js +7 -1
  73. package/dist/runtime/tmuxAdapters.js +4 -1
  74. package/dist/scheduler/activeRoleRunDelivery.js +34 -199
  75. package/dist/scheduler/activeTaskProgress.js +7 -6
  76. package/dist/scheduler/leaderWakeupProcessor.js +42 -138
  77. package/dist/scheduler/operatorEvent.js +34 -0
  78. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  79. package/dist/scheduler/roleRunStall.js +53 -31
  80. package/dist/scheduler/taskExecutionProjection.js +97 -76
  81. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  82. package/dist/storage/migration/productionRegistry.js +379 -0
  83. package/dist/storage/sqliteSchema.js +66 -23
  84. package/dist/storage/sqliteStore.js +46 -23
  85. package/dist/storage/storeRpc.js +0 -1
  86. package/dist/storage/taskStore.js +15 -30
  87. package/dist/storage/upgrade/homeClassification.js +52 -0
  88. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  89. package/dist/storage/upgrade/recordVersions.js +3 -2
  90. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  91. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  92. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  94. package/dist/task/completionReadiness.js +10 -6
  95. package/dist/task/nextAction.js +82 -58
  96. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  97. package/dist/web/assets/client/components.js +13 -3
  98. package/dist/web/assets/client/i18n.js +6 -2
  99. package/dist/web/webSnapshot.js +7 -1
  100. package/i18n/README.zh-CN.md +2 -2
  101. package/package.json +1 -1
  102. package/skills/yui-leader/SKILL.md +60 -20
  103. package/skills/yui-operator/SKILL.md +13 -4
  104. package/skills/yui-reviewer/SKILL.md +35 -11
  105. package/dist/context/sessionContextBudget.js +0 -71
  106. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  107. package/dist/lifecycle/taskRoleSessionReset.js +0 -126
  108. package/dist/scheduler/operatorNotification.js +0 -59
@@ -1,96 +1,40 @@
1
- import { formatTimestamp } from "../output/timePresentation.js";
2
- import { formatInputRequestReceiptId, formatTaskRecordReference } from "../task/taskRecordReference.js";
3
- export function createInputRequestOperatorPresentation(request, context) {
4
- const policy = request.policy;
5
- const recommendation = policy.kind === "recommended"
6
- ? request.choices.find(({ key }) => key === policy.recommendedChoiceKey)
7
- : undefined;
8
- if (policy.kind === "recommended" && recommendation === undefined) {
9
- throw new Error(`Input request ${request.id} recommendation ${policy.recommendedChoiceKey} is missing.`);
10
- }
11
- return {
12
- category: "attention",
13
- taskId: request.taskId,
14
- receiptId: formatInputRequestReceiptId(request.taskId, request.id),
15
- source: { kind: "input-request", taskId: request.taskId, localId: request.id },
16
- text: [
17
- "A Task Leader is waiting for a boundary decision. Inspect it before involving the user.",
18
- "Present it only when it is a user-owned choice, authorization, credential, unavailable external fact, or irreversible operation.",
19
- `If it is an implementation, scheduling, review, or recoverable runtime choice, do not present it to the user; return it to the Leader with: yui task input cancel ${request.taskId} ${request.id} --reason \"Operator returned: not a user-owned boundary\"`,
20
- "For a user-owned boundary, do not answer it yourself; do not answer or choose on the user's behalf; relay the user's exact response only.",
21
- `Task: ${request.taskId}`,
22
- `Input: ${request.id}`,
23
- `Question: ${request.question}`,
24
- ...(request.choices.length === 0
25
- ? ["Answer type: free text"]
26
- : ["Choices:", ...request.choices.map(({ key, label }) => ` ${key}: ${label}`)]),
27
- ...(policy.kind === "required"
28
- ? ["Decision policy: this requires the user's response; there is no automatic fallback."]
29
- : [
30
- `Agent recommendation: ${recommendation.key}: ${recommendation.label}`,
31
- `Automatic fallback after: ${formatTimestamp(policy.timeoutAt, context.timeZone)}`
32
- ]),
33
- `Inspect: yui task input show ${formatTaskRecordReference(request.taskId, request.id, "inputRequest")}`,
34
- request.choices.length === 0
35
- ? `After the user replies: yui task input answer ${formatTaskRecordReference(request.taskId, request.id, "inputRequest")} --text "<answer>"`
36
- : `After the user chooses: yui task input answer ${formatTaskRecordReference(request.taskId, request.id, "inputRequest")} --choice <key>`
37
- ].join("\n")
38
- };
39
- }
40
- export function createLeaderRecoveryOperatorPresentation(notification) {
1
+ /**
2
+ * One mailbox batch becomes one short synthetic user message. The durable
3
+ * records remain the context authority; this wake text carries only stable
4
+ * identities and exact CLI reads, never copied Task or Provider narrative.
5
+ */
6
+ export function createOperatorBatchPresentation(batchId, items) {
7
+ if (items.length === 0)
8
+ throw new Error("Operator presentation requires at least one item.");
41
9
  return {
42
- category: "attention",
43
- taskId: notification.taskId,
44
- receiptId: `leader-recovery:${notification.taskId}:${notification.createdAt}`,
45
- source: { kind: "leader-recovery", id: notification.taskId },
10
+ receiptId: `operator-batch:${batchId}`,
46
11
  text: [
47
- "A Task cannot recover its Leader automatically and needs user attention.",
48
- `Task: ${notification.taskId}`,
49
- `Failure: ${notification.message}`,
50
- `Inspect: yui task show ${notification.taskId}`,
51
- "Recovery status: yui jobs list",
52
- `Retry after inspection: yui jobs retry leader-recovery:${notification.taskId}`
12
+ "[Yui updates]",
13
+ "Yui recorded the following durable updates. Inspect the referenced records and present only information that changes the user's understanding, authorization, or next action.",
14
+ ...items.flatMap(renderItem)
53
15
  ].join("\n")
54
16
  };
55
17
  }
56
- export function createLeaderStallOperatorPresentation(notification) {
57
- return {
58
- category: "attention",
59
- taskId: notification.taskId,
60
- receiptId: `leader-stall:${notification.taskId}:${notification.runId}:${notification.progressAt}`,
61
- source: {
62
- kind: "leader-stall",
63
- id: `${notification.runId}:${notification.progressAt}`
64
- },
65
- text: [
66
- "A Task Leader is truly stalled and needs user attention.",
67
- `Task: ${notification.taskId}`,
68
- `Leader Run: ${notification.runId}`,
69
- `lastProgressAt: ${notification.progressAt}`,
70
- "Classification: truly-stalled",
71
- `Evidence: ${notification.evidenceKey}`,
72
- "Controller preserved the exact Run/Session fence and performed no automatic Enter, reset, retry, kill, or Session replacement.",
73
- `Inspect: yui task context ${notification.taskId}`,
74
- "Choose the supported continuation, reset, retry, Agent change, or user-input action only after inspection."
75
- ].join("\n")
76
- };
18
+ function renderItem(item) {
19
+ if (item.kind === "input-request") {
20
+ const { request } = item;
21
+ return [
22
+ `- User input requested: ${request.taskId}/${request.id}`,
23
+ ` Inspect: yui task input show ${request.taskId}/${request.id}`
24
+ ];
25
+ }
26
+ const { event } = item;
27
+ return [
28
+ `- ${eventLabel(event)}: ${event.taskId}/${event.id}`,
29
+ ` Inspect: yui task event show ${event.taskId} ${event.id}`
30
+ ];
77
31
  }
78
- export function createTaskTerminalOperatorPresentation(notification) {
79
- const action = notification.status === "completed" ? "completed" : "retired";
80
- const actor = notification.by === "leader"
81
- ? "its Leader"
82
- : notification.by === "operator"
83
- ? "the Operator"
84
- : "the user";
85
- return {
86
- category: "information",
87
- taskId: notification.taskId,
88
- receiptId: `task-terminal:${notification.taskId}:${notification.status}:${notification.createdAt}`,
89
- source: { kind: "task-terminal", id: notification.taskId },
90
- text: [
91
- `Task ${notification.taskId} was ${action} by ${actor}.`,
92
- `Summary: ${notification.summary}`,
93
- `Inspect: yui task show ${notification.taskId}`
94
- ].join("\n")
95
- };
32
+ function eventLabel(event) {
33
+ switch (event.type) {
34
+ case "task.completed": return "Task completed";
35
+ case "task.retired": return "Task retired";
36
+ case "leader.attention-required": return "Task Leader needs attention";
37
+ case "run.stalled": return "Task Leader stalled";
38
+ default: return `Task event ${event.type}`;
39
+ }
96
40
  }
@@ -12,7 +12,8 @@ import { createYieldReceipt } from "../run/yieldReceipt.js";
12
12
  import { recordExecutionLaneResult } from "../execution/executionGroup.js";
13
13
  import { isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
14
14
  import { runOwnsBlockingProviderContinuation } from "../runtime/runtimeContinuationProjection.js";
15
- import { clearMatchingLeaderStallAttention, latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
15
+ import { runHasActiveRuntimeOperations } from "../runtime/runtimeObservation.js";
16
+ import { latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
16
17
  import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
17
18
  import { workItemExecutionGroupById, workItemOwnsUnresolvedExecutionLane, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
18
19
  /**
@@ -352,7 +353,7 @@ export function terminalizeExactTaskRun(store, input, now) {
352
353
  /**
353
354
  * Leader-controlled recovery boundary for one active AgentRun. This primitive
354
355
  * validates every durable fence in one transaction and records only a
355
- * structured request for retry/session replacement. It never writes terminal
356
+ * structured request for same-Run diagnosis/retry. It never writes terminal
356
357
  * bytes, retries a provider input, kills a host, or silently rebinds a native
357
358
  * generation. Explicit termination is the sole action that changes Run state.
358
359
  */
@@ -420,6 +421,35 @@ function recoverExactAgentRunInTransaction(store, input) {
420
421
  if (!matchesRecoverySessionFence(store, input)) {
421
422
  return stateChanged("session-or-launch-fence-mismatch");
422
423
  }
424
+ const recoveryBlocker = exactRecoveryExecutionBlocker(store, current);
425
+ if (recoveryBlocker !== null) {
426
+ return {
427
+ disposition: "blocked",
428
+ action: input.action,
429
+ run: current,
430
+ progressAt: progress.progressAt,
431
+ reason: recoveryBlocker
432
+ };
433
+ }
434
+ if (input.action === "terminate") {
435
+ const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
436
+ const session = sessions?.sessions[input.agentId];
437
+ const providerTurn = sessions?.providerBinding?.turn;
438
+ const exactTerminalEvidence = session?.status === "stopped"
439
+ || session?.status === "broken"
440
+ || providerTurn?.status === "failed"
441
+ || providerTurn?.status === "cancelled"
442
+ || providerTurn?.status === "rejected";
443
+ if (!exactTerminalEvidence) {
444
+ return {
445
+ disposition: "blocked",
446
+ action: input.action,
447
+ run: current,
448
+ progressAt: progress.progressAt,
449
+ reason: "runtime-not-terminal"
450
+ };
451
+ }
452
+ }
423
453
  if (input.providerAcceptance === "ambiguous"
424
454
  && input.action !== "diagnose") {
425
455
  return {
@@ -496,7 +526,6 @@ function recoverExactAgentRunInTransaction(store, input) {
496
526
  }
497
527
  }
498
528
  store.saveEvent(input.taskId, createTaskEvent(store.nextEventId(input.taskId), input.taskId, RUN_RECOVERY_APPLIED_EVENT, { ...eventPayload, status: "terminated" }, input.now));
499
- clearMatchingLeaderStallAttention(store, input.taskId, input.runId);
500
529
  if (input.roleName !== "leader") {
501
530
  enqueueWork(store, { kind: "role", taskId: input.taskId, roleName: "leader" }, "run-recovery-terminated", input.now, [{ type: "run", taskId: input.taskId, id: input.runId }]);
502
531
  }
@@ -507,6 +536,42 @@ function recoverExactAgentRunInTransaction(store, input) {
507
536
  progressAt: progress.progressAt
508
537
  };
509
538
  }
539
+ function exactRecoveryExecutionBlocker(store, run) {
540
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
541
+ const binding = sessions?.providerBinding;
542
+ const mailbox = store.getWorkMailbox({
543
+ kind: "role",
544
+ taskId: run.taskId,
545
+ roleName: run.roleName
546
+ });
547
+ if (mailbox?.inputDelivery != null)
548
+ return "provider-input-delivery-unsettled";
549
+ if (runOwnsBlockingProviderContinuation(store.listEvents(run.taskId), {
550
+ taskId: run.taskId,
551
+ roleName: run.roleName,
552
+ runId: run.id,
553
+ agentId: run.effective.agentId
554
+ }))
555
+ return "provider-continuation-writer-owned";
556
+ if (runHasActiveRuntimeOperations(store.listEvents(run.taskId), {
557
+ taskId: run.taskId,
558
+ roleName: run.roleName,
559
+ runId: run.id,
560
+ agentId: run.effective.agentId
561
+ }))
562
+ return "provider-operation-active";
563
+ if (run.deliveredAt !== undefined
564
+ && (binding === null || binding?.turn === null))
565
+ return "provider-turn-state-missing";
566
+ if (binding === null || binding === undefined)
567
+ return null;
568
+ if (["submitting", "accepted", "running", "delivery-unknown"].includes(binding.turn?.status ?? ""))
569
+ return "provider-turn-unsettled";
570
+ if (binding.authority.owner === "human" || binding.authority.owner === "unknown") {
571
+ return "provider-writer-authority-unavailable";
572
+ }
573
+ return null;
574
+ }
510
575
  function matchesRecoverySessionFence(store, input) {
511
576
  const sessions = store.getTaskRoleSessionSet(input.taskId, input.roleName);
512
577
  // Recovery must never proceed without a durable Session fence. A missing
@@ -521,8 +586,7 @@ function matchesRecoverySessionFence(store, input) {
521
586
  return false;
522
587
  if (session.agentId !== input.agentId || session.adapterId !== input.adapterId)
523
588
  return false;
524
- // A dead Session is precisely when replace-session/terminate recovery is
525
- // needed. Preserve its exact identity as the CAS fence; only same-Session
589
+ // Preserve a dead Session's exact identity as the CAS fence; only same-Session
526
590
  // retry is invalid once the native process is stopped or broken.
527
591
  if ((session.status === "stopped" || session.status === "broken")
528
592
  && input.action === "retry")
@@ -363,6 +363,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
363
363
  let stopped = 0;
364
364
  let other = 0;
365
365
  let resets = 0;
366
+ let conversationSwitches = 0;
366
367
  let lifecycleEvents = 0;
367
368
  let stopFailures = 0;
368
369
  const terminalByRunRelation = {
@@ -393,6 +394,9 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
393
394
  continue;
394
395
  if (event.type === "runtime.role-session-reset")
395
396
  resets += 1;
397
+ else if (event.type === "runtime.conversation-switch-resolved"
398
+ && event.payload.status === "applied")
399
+ conversationSwitches += 1;
396
400
  else if (runtimeObservationFromTaskEvent(event)?.kind.startsWith("session.")) {
397
401
  lifecycleEvents += 1;
398
402
  }
@@ -406,6 +410,7 @@ export function runExecutionAudit(home, options = {}, ports = createProductionEx
406
410
  stopped,
407
411
  other,
408
412
  resets,
413
+ conversationSwitches,
409
414
  lifecycleEvents,
410
415
  stopFailures,
411
416
  terminalByRunRelation
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { classifyReviewRoundOutcome } from "../review/reviewOutcomeClassifier.js";
2
3
  import { projectFirstProgressAdvisory } from "../runtime/firstProgressAdvisory.js";
3
4
  /** One Task's orchestration cost and advisory projection, with no writes. */
@@ -129,10 +130,14 @@ function projectAdvisories(facts, classifications, fullRounds, repeatedIdentitie
129
130
  const recent = semanticFull.slice(-3);
130
131
  const recentIds = new Set(recent.map(({ id }) => id));
131
132
  const newFinding = facts.reviewFindings.some(({ firstReviewRoundId }) => recentIds.has(firstReviewRoundId));
132
- if (semanticFull.length > 2 && !newFinding) {
133
+ const sameCandidate = recent.length === 3
134
+ && recent.every((round) => isDeepStrictEqual(round.taskCandidate, recent[0].taskCandidate));
135
+ const sameReviewer = recent.length === 3
136
+ && recent.every((round) => round.reviewerRoleName === recent[0].reviewerRoleName);
137
+ if (sameCandidate && sameReviewer && !newFinding) {
133
138
  result.push({
134
- code: "review-budget-exhausted",
135
- reason: `${semanticFull.length} full semantic Reviews ran and the latest three produced no new finding; stop repeating full rounds without a changed head or new risk.`,
139
+ code: "repeated-full-review",
140
+ reason: `The same Reviewer completed three full semantic Reviews of the same frozen candidate without a new finding; this is a cost advisory, not a Review limit.`,
136
141
  refs: recent.map(({ id }) => `review-round:${id}`)
137
142
  });
138
143
  }
@@ -75,7 +75,7 @@ export function builtinAgentProfileInputs() {
75
75
  {
76
76
  id: "reviewer",
77
77
  description: "Review one candidate against the user's core outcome, supported behavior, and direct evidence.",
78
- instructions: "Start from user intent and acceptance criteria. Inspect the complete relevant change and report only reachable, material, actionable problems with direct evidence. Separate defects from verification gaps, and prefer the smallest sufficient correction. Follow the bound Project's Policy and Knowledge for build, test, migration, release, and review expectations; do not import rules from another Project or Task. For normal software delivery, review the frozen Task result as one final ReviewRound rather than inventing a per-WorkItem protocol unless the Project Policy explicitly requires one. A Task-final Round has no synthetic WorkItem anchor, and a compatible Reviewer Session may continue across changed-head Rounds without reusing an earlier verdict. Do not turn speculative or extreme edge cases into new state, retries, fallbacks, or protocol. In a ReviewRound-owned workspace you may edit source or tests, run local checks, and optionally commit diagnostic evidence. Never push, integrate, mutate Task state, touch another workspace or stable checkout, or write the real Yui control-plane home. Report complete findings, checks actually run, uncertainty, and bounded next actions through the exact Review yield; Yui preserves the full free-form report. Expose evidence and options to the Leader, who decides.",
78
+ instructions: "Start from user intent and acceptance criteria. Inspect the complete relevant change and report only reachable, material, actionable problems with direct evidence. Separate defects from verification gaps, and prefer the smallest sufficient correction. Follow the bound Project's Policy and Knowledge for build, test, migration, release, and review expectations; do not import rules from another Project or Task. For normal software delivery, review the frozen Task result as one final ReviewRound rather than inventing a per-WorkItem protocol unless the Project Policy explicitly requires one. A Task-final Round has no synthetic WorkItem anchor, and the Reviewer Session and physical workspace continue across changed-head Rounds without reusing an earlier verdict. The current Run's frozen candidate remains the only scope even if Task main advances. For Delta Recheck, return equivalent-and-accepted, finding, or requires-full-review with explicit reasoning; never create the next Round. Do not turn speculative or extreme edge cases into new state, retries, fallbacks, or protocol. In the Review workspace you may edit source or tests, run local checks, and optionally commit diagnostic evidence. Never push, integrate, mutate Task state, touch another workspace or stable checkout, or write the real Yui control-plane home. Report complete findings, checks actually run, uncertainty, and bounded next actions through the exact Review yield; Yui preserves the full free-form report. Expose evidence and options to the Leader, who decides.",
79
79
  defaultAccess: "write"
80
80
  }
81
81
  ];
@@ -0,0 +1,123 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { UNSUPPORTED, collectRuntimeBuildIdentity, createProductionRuntimeIdentityPorts } from "../observability/runtimeIdentity.js";
4
+ import { readCompatibleHomeIdentity } from "../storage/compatibleTaskStore.js";
5
+ import { detectRunningRelease, readActiveReleasePointer, releaseDirectoryName } from "./runtimeRelease.js";
6
+ /**
7
+ * Prevent a checkout or otherwise unverified CLI from entering a Home that is
8
+ * already owned by an immutable active release. This fence runs before any
9
+ * TaskStore is opened, so even schema discovery cannot advance SQLite state.
10
+ * Published global packages remain the stable ordinary CLI boundary; an exact
11
+ * installed release may also run when it is active or is the explicit handover
12
+ * target.
13
+ */
14
+ export function assertCliHomeReleaseFence(input) {
15
+ const active = readActiveReleasePointer(input.home);
16
+ if (active === null)
17
+ return "unregistered-home";
18
+ const running = detectRunningRelease(input.entryPath);
19
+ if (running !== null) {
20
+ if (matchesActiveRelease(running.manifest, active))
21
+ return "active-release";
22
+ if (isExplicitActivation(input.args, running.manifest))
23
+ return "release-activation";
24
+ throw fenceError(input, active, "installed-release", running.manifest);
25
+ }
26
+ const build = collectRuntimeBuildIdentity(createProductionRuntimeIdentityPorts(input.packageRoot, input.entryPath));
27
+ const distribution = readPackageDistribution(input.packageRoot);
28
+ if (distribution === "published" && build.sourceCommit === UNSUPPORTED) {
29
+ return "published-package";
30
+ }
31
+ const source = distribution === "checkout" || build.sourceCommit !== UNSUPPORTED
32
+ ? "checkout-local"
33
+ : "unverified-local";
34
+ throw fenceError(input, active, source, undefined, build);
35
+ }
36
+ /** Render bounded, non-secret identity evidence for a migration diagnosis. */
37
+ export function describeCliHomeInvocation(input) {
38
+ const running = detectRunningRelease(input.entryPath);
39
+ const collected = collectRuntimeBuildIdentity(createProductionRuntimeIdentityPorts(input.packageRoot, input.entryPath));
40
+ const distribution = readPackageDistribution(input.packageRoot);
41
+ const source = running !== null
42
+ ? "installed-release"
43
+ : distribution === "published" && collected.sourceCommit === UNSUPPORTED
44
+ ? "published-package"
45
+ : distribution === "checkout" || collected.sourceCommit !== UNSUPPORTED
46
+ ? "checkout-local"
47
+ : "unverified-local";
48
+ let homeId = "unknown";
49
+ try {
50
+ homeId = readCompatibleHomeIdentity(input.home).homeId;
51
+ }
52
+ catch {
53
+ // The primary migration error remains authoritative when identity is unreadable.
54
+ }
55
+ let activeRelease = "none";
56
+ let activeBuild = "none";
57
+ try {
58
+ const active = readActiveReleasePointer(input.home);
59
+ if (active !== null) {
60
+ activeRelease = active.releaseId;
61
+ activeBuild = active.buildId;
62
+ }
63
+ }
64
+ catch {
65
+ activeRelease = "unreadable";
66
+ activeBuild = "unreadable";
67
+ }
68
+ const version = running?.manifest.version ?? collected.packageVersion;
69
+ const buildId = running?.manifest.buildId
70
+ ?? (source === "checkout-local" ? "checkout" : source);
71
+ const sourceCommit = running?.manifest.sourceCommit ?? (collected.sourceCommit === UNSUPPORTED ? "unknown" : collected.sourceCommit);
72
+ return [
73
+ `CLI: version=${diagnostic(version)} build=${diagnostic(buildId)} sourceCommit=${diagnostic(sourceCommit)}`,
74
+ `Home: homeId=${diagnostic(homeId)} activeRelease=${diagnostic(activeRelease)} activeBuild=${diagnostic(activeBuild)}`,
75
+ `Invocation: source=${source}`
76
+ ].join("\n");
77
+ }
78
+ function matchesActiveRelease(manifest, active) {
79
+ return active.releaseId === releaseDirectoryName(manifest)
80
+ && active.version === manifest.version
81
+ && active.buildId === manifest.buildId
82
+ && active.packageDigest === manifest.packageDigest;
83
+ }
84
+ function isExplicitActivation(args, manifest) {
85
+ if (args.length !== 3 || args[0] !== "release" || args[1] !== "activate")
86
+ return false;
87
+ return args[2] === releaseDirectoryName(manifest) || args[2] === manifest.buildId;
88
+ }
89
+ function readPackageDistribution(packageRoot) {
90
+ try {
91
+ const value = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
92
+ if (value.private === true)
93
+ return "checkout";
94
+ if (value.private === false)
95
+ return "published";
96
+ }
97
+ catch {
98
+ // A missing or damaged package identity is not publication evidence.
99
+ }
100
+ return "unknown";
101
+ }
102
+ function fenceError(input, active, source, release, collected = collectRuntimeBuildIdentity(createProductionRuntimeIdentityPorts(input.packageRoot, input.entryPath))) {
103
+ let homeId = "unknown";
104
+ try {
105
+ homeId = readCompatibleHomeIdentity(input.home).homeId;
106
+ }
107
+ catch {
108
+ // The release fence must still diagnose and reject an unreadable Home.
109
+ }
110
+ const version = release?.version ?? collected.packageVersion;
111
+ const buildId = release?.buildId ?? (source === "checkout-local" ? "checkout" : "unknown");
112
+ const sourceCommit = release?.sourceCommit ?? (collected.sourceCommit === UNSUPPORTED ? "unknown" : collected.sourceCommit);
113
+ return new Error([
114
+ `Refusing ${source} CLI access to a Home owned by another active release.`,
115
+ `CLI: version=${diagnostic(version)} build=${diagnostic(buildId)} sourceCommit=${diagnostic(sourceCommit)}`,
116
+ `Home: homeId=${diagnostic(homeId)} activeRelease=${diagnostic(active.releaseId)} activeBuild=${diagnostic(active.buildId)}`,
117
+ `Invocation: source=${source}`,
118
+ "Use the published global yui for this Home, or run make install-local without overriding its isolated YUI_HOME."
119
+ ].join("\n"));
120
+ }
121
+ function diagnostic(value) {
122
+ return value.replace(/[^A-Za-z0-9._:+/@-]/gu, "?").slice(0, 160);
123
+ }
@@ -267,6 +267,26 @@ export function isHandoverLockHeld(home) {
267
267
  return !isEnoent(error);
268
268
  }
269
269
  }
270
+ /**
271
+ * True when a live handover is owned by a process other than `allowedOwnerPid`.
272
+ *
273
+ * Foreground maintenance commands acquire the lock and still need to call the
274
+ * Controller they are draining, while every unrelated CLI/managed Session must
275
+ * wait rather than starting a second Controller during the replacement window.
276
+ * An unreadable lock remains a foreign live lock so callers fail closed.
277
+ */
278
+ export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
279
+ const lockPath = join(resolve(home), "runtime", "handover.lock");
280
+ try {
281
+ const owner = JSON.parse(readFileSync(lockPath, "utf8"));
282
+ if (!isHandoverLockLive(owner))
283
+ return false;
284
+ return owner.pid !== allowedOwnerPid;
285
+ }
286
+ catch (error) {
287
+ return !isEnoent(error);
288
+ }
289
+ }
270
290
  export function acquireHandoverLock(home) {
271
291
  const lockPath = join(resolve(home), "runtime", "handover.lock");
272
292
  mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });