@zq-silk/yui 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +80 -13
  3. package/dist/cli/commandCatalog.js +30 -5
  4. package/dist/cli.js +105 -43
  5. package/dist/commands/configCommands.js +12 -45
  6. package/dist/commands/executionAuditCommands.js +1 -1
  7. package/dist/commands/jobCommands.js +4 -10
  8. package/dist/commands/projectCommands.js +581 -2
  9. package/dist/commands/taskCommands.js +159 -172
  10. package/dist/commands/taskCompletionGate.js +29 -54
  11. package/dist/commands/taskContextCommand.js +55 -11
  12. package/dist/commands/taskInputCommands.js +8 -5
  13. package/dist/commands/taskIntegrationCommands.js +2 -1
  14. package/dist/commands/taskNextActionCommand.js +56 -6
  15. package/dist/commands/taskOverviewCommand.js +17 -31
  16. package/dist/commands/taskRoleRuntimeStatus.js +24 -0
  17. package/dist/commands/taskWorkspaceCommands.js +5 -2
  18. package/dist/config/configCatalog.js +2 -2
  19. package/dist/config/yuiConfig.js +3 -7
  20. package/dist/context/wakeNotification.js +20 -5
  21. package/dist/controller/agentRuntimeObserver.js +247 -51
  22. package/dist/controller/clientRuntime.js +1 -1
  23. package/dist/controller/controller.js +8 -12
  24. package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
  25. package/dist/controller/runtime.js +3 -3
  26. package/dist/controller/runtimeEventInbox.js +17 -7
  27. package/dist/controller/runtimeEventProcessor.js +12 -19
  28. package/dist/controller/runtimeObservationHook.js +45 -0
  29. package/dist/execution/resourceBroker.js +5 -4
  30. package/dist/executor/agentExecutor.js +4 -4
  31. package/dist/executor/effectiveLaunch.js +8 -48
  32. package/dist/executor/fileRoleLaunchPlanner.js +36 -16
  33. package/dist/executor/workspacePreflightClassification.js +23 -2
  34. package/dist/interaction/operatorPresentation.js +33 -89
  35. package/dist/lifecycle/exactRunTerminalization.js +1 -2
  36. package/dist/lifecycle/taskRoleSessionReset.js +5 -13
  37. package/dist/observability/orchestrationMetrics.js +8 -3
  38. package/dist/profile/agentProfile.js +1 -1
  39. package/dist/release/cliHomeReleaseFence.js +123 -0
  40. package/dist/repository/checkoutSwap.js +61 -0
  41. package/dist/repository/gitWorkspace.js +36 -0
  42. package/dist/repository/project.js +53 -3
  43. package/dist/repository/taskWorkspacePreparer.js +176 -60
  44. package/dist/resources/sqliteResourceRegistry.js +1 -1
  45. package/dist/review/deltaRecheck.js +12 -51
  46. package/dist/review/reviewAcceptance.js +26 -0
  47. package/dist/review/reviewConfig.js +0 -31
  48. package/dist/review/reviewDecision.js +113 -0
  49. package/dist/review/reviewOutcomeClassifier.js +1 -1
  50. package/dist/review/reviewRound.js +1 -1
  51. package/dist/review/reviewerAvailability.js +69 -0
  52. package/dist/runtime/agentDriverObservation.js +24 -10
  53. package/dist/runtime/builtinAgentDrivers.js +4 -3
  54. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  55. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  56. package/dist/runtime/index.js +1 -0
  57. package/dist/runtime/runtimeObservation.js +13 -0
  58. package/dist/runtime/runtimeProjection.js +16 -25
  59. package/dist/runtime/sessionTokenMetrics.js +181 -0
  60. package/dist/runtime/structuredProviderHost.js +7 -1
  61. package/dist/scheduler/activeRoleRunDelivery.js +4 -4
  62. package/dist/scheduler/activeTaskProgress.js +7 -6
  63. package/dist/scheduler/leaderWakeupProcessor.js +3 -5
  64. package/dist/scheduler/operatorEvent.js +34 -0
  65. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  66. package/dist/scheduler/roleRunStall.js +0 -14
  67. package/dist/scheduler/taskExecutionProjection.js +97 -76
  68. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  69. package/dist/storage/migration/productionRegistry.js +467 -0
  70. package/dist/storage/sqliteSchema.js +18 -8
  71. package/dist/storage/sqliteStore.js +61 -21
  72. package/dist/storage/storeRpc.js +0 -1
  73. package/dist/storage/taskStore.js +47 -31
  74. package/dist/storage/upgrade/recordVersions.js +3 -2
  75. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  76. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  77. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  78. package/dist/task/completionReadiness.js +10 -6
  79. package/dist/task/nextAction.js +82 -24
  80. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  81. package/dist/web/assets/client/components.js +13 -3
  82. package/dist/web/assets/client/i18n.js +6 -2
  83. package/dist/web/webSnapshot.js +4 -0
  84. package/i18n/README.zh-CN.md +2 -2
  85. package/package.json +1 -1
  86. package/skills/yui-leader/SKILL.md +53 -16
  87. package/skills/yui-operator/SKILL.md +9 -0
  88. package/skills/yui-reviewer/SKILL.md +28 -7
  89. package/dist/context/sessionContextBudget.js +0 -71
  90. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  91. 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,7 @@ 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 { latestRunDurableProgressAt, RUN_RECOVERY_APPLIED_EVENT, RUN_RECOVERY_REQUESTED_EVENT } from "../scheduler/roleRunStall.js";
16
16
  import { markTaskWakeConsumed } from "../scheduler/taskWake.js";
17
17
  import { workItemExecutionGroupById, workItemOwnsUnresolvedExecutionLane, updateWorkItemExecutionGroup, updateWorkItemStatus } from "../workItem/workItem.js";
18
18
  /**
@@ -496,7 +496,6 @@ function recoverExactAgentRunInTransaction(store, input) {
496
496
  }
497
497
  }
498
498
  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
499
  if (input.roleName !== "leader") {
501
500
  enqueueWork(store, { kind: "role", taskId: input.taskId, roleName: "leader" }, "run-recovery-terminated", input.now, [{ type: "run", taskId: input.taskId, id: input.runId }]);
502
501
  }
@@ -3,7 +3,7 @@ import { resetTaskRoleSession } from "../executor/agentExecutor.js";
3
3
  import { createTaskEvent } from "../event/taskEvent.js";
4
4
  import { createTaskMessage } from "../message/message.js";
5
5
  import { updateRoleStatus } from "../role/role.js";
6
- import { createLeaderRecoveryNotification } from "../scheduler/operatorNotification.js";
6
+ import { enqueueOperatorEvent } from "../scheduler/operatorEvent.js";
7
7
  import { recordLeaderFailure } from "../scheduler/leaderFailure.js";
8
8
  import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
9
9
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
@@ -70,27 +70,19 @@ export function resetTaskRoleSessionGeneration(store, taskId, roleName, reason,
70
70
  ...(activeRun.workItemId === undefined ? {} : { workItemId: activeRun.workItemId })
71
71
  });
72
72
  store.saveMessage(task.id, message);
73
- store.saveEvent(task.id, createTaskEvent(store.nextEventId(task.id), task.id, "runtime.role-session-reset", {
73
+ const resetEvent = createTaskEvent(store.nextEventId(task.id), task.id, "runtime.role-session-reset", {
74
74
  roleName: role.name,
75
75
  reason: summary,
76
76
  ...(activeRun === null ? {} : { runId: activeRun.id }),
77
77
  ...(current?.nativeSessionId === undefined
78
78
  ? {}
79
79
  : { nativeSessionId: current.nativeSessionId })
80
- }, now));
80
+ }, now);
81
+ store.saveEvent(task.id, resetEvent);
81
82
  if (role.name === "leader") {
82
83
  const nativeSessionId = current?.nativeSessionId ?? `reset-${task.id}`;
83
84
  store.saveLeaderFailure(recordLeaderFailure(task.id, nativeSessionId, summary, now, store.getLeaderFailure(task.id)));
84
- store.saveOperatorNotification(createLeaderRecoveryNotification(task.id, summary, now, store.getOperatorNotification(task.id)));
85
- enqueueWork(store, { kind: "operator" }, "leader-run-failed", now, [
86
- { type: "task", id: task.id },
87
- { type: "message", taskId: task.id, id: message.id },
88
- ...(activeRun === null ? [] : [{
89
- type: "run",
90
- taskId: task.id,
91
- id: activeRun.id
92
- }])
93
- ]);
85
+ enqueueOperatorEvent(store, resetEvent, "leader-run-failed", now);
94
86
  }
95
87
  else {
96
88
  enqueueWork(store, { kind: "role", taskId: task.id, roleName: "leader" }, "role-run-failed", now, [
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rename as defaultRename, rm as defaultRm } from "node:fs/promises";
3
+ const realPorts = {
4
+ rename: (sourcePath, targetPath) => defaultRename(sourcePath, targetPath),
5
+ remove: (path) => defaultRm(path, { recursive: true, force: true })
6
+ };
7
+ /**
8
+ * Heal the on-disk state of a crashed earlier swap before any new destructive
9
+ * step runs. A backup next to a live checkout is a leftover from a completed
10
+ * swap whose cleanup crashed and is removed; a backup without a live checkout
11
+ * means the crash happened mid-swap, so the previous checkout is restored
12
+ * intact. With no backup there is nothing to heal.
13
+ */
14
+ export async function healCheckoutSwap(request, ports = realPorts) {
15
+ if (!existsSync(request.backupPath))
16
+ return;
17
+ if (existsSync(request.currentPath)) {
18
+ await ports.remove(request.backupPath);
19
+ return;
20
+ }
21
+ await ports.rename(request.backupPath, request.currentPath);
22
+ }
23
+ /**
24
+ * Promote a prepared staging checkout into the registered path without ever
25
+ * leaving the registered path without a valid repository. The previous
26
+ * checkout is moved to the backup path first; if the promotion fails it is
27
+ * restored and the staging clone is removed. A crash between the two renames
28
+ * is healed by {@link healCheckoutSwap} on the next run.
29
+ */
30
+ export async function swapManagedCheckout(request, ports = realPorts) {
31
+ await healCheckoutSwap(request, ports);
32
+ await ports.rename(request.currentPath, request.backupPath);
33
+ try {
34
+ await ports.rename(request.stagingPath, request.currentPath);
35
+ }
36
+ catch (error) {
37
+ try {
38
+ await ports.rename(request.backupPath, request.currentPath);
39
+ }
40
+ catch (rollbackError) {
41
+ throw new Error(`Checkout swap failed and rollback was incomplete: ${messageOf(error)}; `
42
+ + `rollback failed: ${messageOf(rollbackError)}. `
43
+ + `The previous checkout remains parked at ${request.backupPath}.`);
44
+ }
45
+ await ports.remove(request.stagingPath).catch(() => { });
46
+ throw error;
47
+ }
48
+ }
49
+ /**
50
+ * Restore the parked previous checkout after a committed swap had to be undone
51
+ * (for example because the catalog transaction refused the change). The
52
+ * promoted clone is discarded first; if the restore fails the parked checkout
53
+ * stays recoverable through {@link healCheckoutSwap}.
54
+ */
55
+ export async function restoreCheckoutSwap(request, ports = realPorts) {
56
+ await ports.remove(request.currentPath);
57
+ await ports.rename(request.backupPath, request.currentPath);
58
+ }
59
+ function messageOf(error) {
60
+ return error instanceof Error ? error.message : String(error);
61
+ }
@@ -694,6 +694,42 @@ export class NodeGitWorkspace {
694
694
  throw new Error(`Git reset compensation failed for ${target}; Candidate materialization remains retained for diagnosis.`, { cause: error });
695
695
  }
696
696
  }
697
+ async resetToCommit(input) {
698
+ const root = await canonicalDirectory(input.repositoryPath, "Project");
699
+ const expectedHead = requireText(input.expectedHead, "Expected head");
700
+ const targetCommit = requireText(input.targetCommit, "Target commit");
701
+ if (!await this.isClean(root)) {
702
+ throw new Error(`Project checkout must be clean before reset: ${root}.`);
703
+ }
704
+ const currentHead = (await this.inspect(root, "HEAD")).baseCommit;
705
+ if (currentHead.toLowerCase() !== expectedHead.toLowerCase()) {
706
+ throw new Error(`Project checkout changed before reset: ${root} (${currentHead}).`);
707
+ }
708
+ // Verify the target commit resolves before moving the branch.
709
+ await gitLine(["-C", root, "rev-parse", "--verify", "--end-of-options", `${targetCommit}^{commit}`]);
710
+ await git(["-C", root, "reset", "--hard", targetCommit]);
711
+ const restoredHead = (await this.inspect(root, "HEAD")).baseCommit;
712
+ if (restoredHead.toLowerCase() !== targetCommit.toLowerCase() || !await this.isClean(root)) {
713
+ throw new Error(`Project checkout did not reset to the exact target commit: ${root}.`);
714
+ }
715
+ }
716
+ async listWorktrees(repositoryPath) {
717
+ const root = await canonicalDirectory(repositoryPath, "Project");
718
+ const output = await git(["-C", root, "worktree", "list", "--porcelain"]);
719
+ return output
720
+ .split(/\r?\n/u)
721
+ .filter((line) => line.startsWith("worktree "))
722
+ .map((line) => line.slice("worktree ".length).trim());
723
+ }
724
+ async listCommitsBetween(input) {
725
+ const root = await canonicalDirectory(input.repositoryPath, "Project");
726
+ const range = `${requireText(input.baseCommit, "Base commit")}..${requireText(input.headCommit, "Head commit")}`;
727
+ const output = await git(["-C", root, "log", "--oneline", range]);
728
+ return output
729
+ .split(/\r?\n/u)
730
+ .map((line) => line.trim())
731
+ .filter((line) => line.length > 0);
732
+ }
697
733
  async #assertRefreshCheckout(repositoryPath, stableRef, expectedCommit) {
698
734
  if (stableRef !== "HEAD") {
699
735
  const currentBranch = await this.headRef(repositoryPath);
@@ -8,7 +8,7 @@ export function managedProjectPath(home, projectId) {
8
8
  export function createProject(id, name, path, branches, now, metadata = {}) {
9
9
  const timestamp = now.toISOString();
10
10
  return validateProject({
11
- schemaVersion: 4,
11
+ schemaVersion: 5,
12
12
  id: requireIdentity(id, "Project id"),
13
13
  name: validateProjectName(name),
14
14
  aliases: normalizeAliases(metadata.aliases ?? [], name),
@@ -19,6 +19,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
19
19
  : { remoteUrl: requireText(metadata.remoteUrl, "Project remote URL") }),
20
20
  stableBranch: requireGitRef(branches.stable, "Project stable branch"),
21
21
  developmentBranch: requireGitRef(branches.development, "Project development branch"),
22
+ status: "active",
22
23
  knowledge: [],
23
24
  knowledgeProposals: [],
24
25
  createdAt: timestamp,
@@ -28,6 +29,38 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
28
29
  export function validateProjectName(value) {
29
30
  return requireIdentity(value, "Project name");
30
31
  }
32
+ /**
33
+ * Soft-retire a Project: mark it retired and record the audit trail. The
34
+ * catalog record, checkout, and every historical Task/Run/Review/Integration/
35
+ * Publication reference are retained. Retiring an already-retired Project
36
+ * fails closed; a retired Project must be deleted (or kept) as-is.
37
+ */
38
+ export function retireProject(project, reason, by, now) {
39
+ if (project.status === "retired") {
40
+ throw new Error(`Project is already retired: ${project.id}.`);
41
+ }
42
+ const timestamp = now.toISOString();
43
+ return validateProject({
44
+ ...project,
45
+ status: "retired",
46
+ retirement: {
47
+ reason: requireText(reason, "Project retirement reason"),
48
+ retiredBy: by,
49
+ retiredAt: timestamp
50
+ },
51
+ updatedAt: timestamp
52
+ });
53
+ }
54
+ /**
55
+ * Refuse a mutation or new binding against a retired Project. Read-only
56
+ * inspection (`project show`, `project diagnose`, knowledge reads) remains
57
+ * allowed, so historical evidence stays auditable.
58
+ */
59
+ export function assertProjectActive(project, action) {
60
+ if (project.status === "retired") {
61
+ throw new Error(`Project is retired and cannot ${action}: ${project.id}.`);
62
+ }
63
+ }
31
64
  export function addProjectKnowledge(project, id, title, body, now, provenance) {
32
65
  const timestamp = now.toISOString();
33
66
  const knowledge = {
@@ -243,8 +276,8 @@ export function assertProjectCatalog(projects) {
243
276
  }
244
277
  }
245
278
  export function validateProject(project) {
246
- if (project.schemaVersion !== 4) {
247
- throw new Error("Project must use schemaVersion 4.");
279
+ if (project.schemaVersion !== 5) {
280
+ throw new Error("Project must use schemaVersion 5.");
248
281
  }
249
282
  requireIdentity(project.id, "Project id");
250
283
  requireIdentity(project.name, "Project name");
@@ -252,6 +285,23 @@ export function validateProject(project) {
252
285
  if (project.ownership !== "managed" && project.ownership !== "external") {
253
286
  throw new Error(`Project ownership is invalid: ${String(project.ownership)}.`);
254
287
  }
288
+ if (project.status !== "active" && project.status !== "retired") {
289
+ throw new Error(`Project status is invalid: ${String(project.status)}.`);
290
+ }
291
+ if (project.status === "retired") {
292
+ const retirement = project.retirement;
293
+ if (retirement === undefined) {
294
+ throw new Error(`Retired Project must carry its retirement record: ${project.id}.`);
295
+ }
296
+ requireText(retirement.reason, "Project retirement reason");
297
+ if (retirement.retiredBy !== "user" && retirement.retiredBy !== "operator") {
298
+ throw new Error("Project retirement actor is invalid.");
299
+ }
300
+ requireTimestamp(retirement.retiredAt, "Project retirement retiredAt");
301
+ }
302
+ else if (project.retirement !== undefined) {
303
+ throw new Error(`Active Project must not carry a retirement record: ${project.id}.`);
304
+ }
255
305
  const references = new Set();
256
306
  for (const reference of [project.id, project.name, ...project.aliases]) {
257
307
  const folded = reference.toLocaleLowerCase();