@zq-silk/yui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,1720 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { accessSync, constants } from "node:fs";
4
+ import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { runUpdate } from "../cli/updateOrchestrator.js";
9
+ import { activatedControllerEntrypoint } from "../cli/updatePorts.js";
10
+ import { restartFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
11
+ import { runProjectCommand } from "../commands/projectCommands.js";
12
+ import { createFileReleaseIdempotencyStore } from "./releaseIdempotencyStore.js";
13
+ import { isConcreteVersion } from "../domain/validation.js";
14
+ import { resolveProject } from "../repository/project.js";
15
+ import { resolveVerificationGate } from "../verification/verificationGateService.js";
16
+ import { findL2ArtifactForCommit } from "../verification/gateArtifactStore.js";
17
+ const defaultRunCommand = async (command, args, cwd, env) => {
18
+ const exec = promisify(execFile);
19
+ try {
20
+ const { stdout, stderr } = await exec(command, args, {
21
+ cwd,
22
+ maxBuffer: 16 * 1024 * 1024,
23
+ ...(env === undefined ? {} : { env: { ...process.env, ...env } })
24
+ });
25
+ return { code: 0, stdout, stderr };
26
+ }
27
+ catch (error) {
28
+ const failure = error;
29
+ return {
30
+ code: typeof failure.code === "number" ? failure.code : 1,
31
+ stdout: failure.stdout ?? "",
32
+ stderr: failure.stderr ?? failure.message ?? String(error)
33
+ };
34
+ }
35
+ };
36
+ /**
37
+ * The external commands the production adapter shells out to. Each one is
38
+ * resolved to an absolute path once, at adapter construction time, so a PATH
39
+ * change mid-run cannot swap `gh`/`git`/`npm`/`tar`/`sh` for another binary
40
+ * (P1-2, rr22). The pinning is per-process at construction time, so a resume
41
+ * process re-resolves from its own PATH.
42
+ */
43
+ const PINNED_EXTERNAL_COMMANDS = ["gh", "git", "npm", "tar", "sh"];
44
+ /**
45
+ * Resolve an external command to its absolute executable path by walking the
46
+ * given PATH. A command that is already absolute is returned unchanged. A
47
+ * command that cannot be resolved returns undefined. Callers must fail closed
48
+ * without invoking a mutable PATH fallback.
49
+ */
50
+ export function resolveExecutable(command, environmentPath) {
51
+ if (isAbsolute(command))
52
+ return command;
53
+ const pathValue = environmentPath ?? "";
54
+ for (const directory of pathValue.split(delimiter)) {
55
+ if (directory.length === 0)
56
+ continue;
57
+ const candidate = resolve(directory, command);
58
+ try {
59
+ accessSync(candidate, constants.X_OK);
60
+ return candidate;
61
+ }
62
+ catch {
63
+ // Keep walking PATH.
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+ /**
69
+ * Wrap the default runner so every external command is pinned to the
70
+ * absolute path resolved at construction. Injected runners (tests) are used
71
+ * as-is: the pinning is a production-adapter guarantee.
72
+ *
73
+ * Exported for deterministic tests (P2, rr24): the negative cache must
74
+ * survive a later PATH addition, so a command missing at construction stays
75
+ * unresolvable for the adapter's lifetime.
76
+ */
77
+ export function createPinnedRunner(base) {
78
+ const pinned = new Map();
79
+ for (const name of PINNED_EXTERNAL_COMMANDS) {
80
+ pinned.set(name, resolveExecutable(name, process.env.PATH));
81
+ }
82
+ return (command, args, cwd, env) => {
83
+ // P2 (rr24): `Map.get` returns undefined both for a missing key and for
84
+ // a key whose resolved value is undefined (a command that was not on
85
+ // PATH at construction). Use `Map.has` to distinguish them so the
86
+ // negative cache holds: a command resolved as missing stays missing even
87
+ // if it appears on PATH later in the adapter's lifetime.
88
+ let resolved;
89
+ if (pinned.has(command)) {
90
+ resolved = pinned.get(command);
91
+ }
92
+ else {
93
+ resolved = isAbsolute(command) ? command : resolveExecutable(command, process.env.PATH);
94
+ pinned.set(command, resolved);
95
+ }
96
+ if (resolved === undefined) {
97
+ return Promise.resolve({
98
+ code: 127,
99
+ stdout: "",
100
+ stderr: `Unable to resolve trusted executable: ${command}`
101
+ });
102
+ }
103
+ return base(resolved, args, cwd, env);
104
+ };
105
+ }
106
+ /**
107
+ * The real adapter. It is NEVER exercised by the deterministic test suite
108
+ * (fakes are); every branch dispatches to an existing atomic operation.
109
+ */
110
+ export function createReleaseWorkflowPorts(deps) {
111
+ const run = deps.runCommand !== undefined
112
+ ? deps.runCommand
113
+ : createPinnedRunner(defaultRunCommand);
114
+ // Capture the same npm resolution used by the production pinned runner at
115
+ // adapter construction. The effect target must never be re-resolved from
116
+ // a later PATH, which may belong to a replacement process or environment.
117
+ const pinnedNpmPath = resolveExecutable("npm", process.env.PATH);
118
+ const idempotency = deps.idempotencyStore ?? createFileReleaseIdempotencyStore(deps.home);
119
+ async function executeStepOnce({ step, idempotencyKey, source, params }) {
120
+ switch (step.kind) {
121
+ case "cli-update": {
122
+ // A cli-update is an irreversible global binary/Home change. It must
123
+ // be bound to a concrete frozen version: a plan without one would
124
+ // stage and activate whatever `latest` resolves to, which can move
125
+ // between the plan and the activation. Fail closed before staging.
126
+ const frozenVersion = params.version;
127
+ if (frozenVersion === undefined || !isConcreteVersion(frozenVersion)) {
128
+ return {
129
+ outcome: "failed",
130
+ error: frozenVersion === undefined
131
+ ? "cli-update requires params.version: a concrete frozen version must be staged and activated."
132
+ : `cli-update: params.version ${frozenVersion} is not a concrete version; refusing to stage a moving tag.`,
133
+ logs: ["cli-update: refusing to update without a concrete frozen version"]
134
+ };
135
+ }
136
+ // Stage the exact frozen version side-by-side and inspect it BEFORE
137
+ // any activation: a registry that resolved a different version must
138
+ // fail with the active CLI untouched.
139
+ let staged;
140
+ try {
141
+ staged = deps.updatePorts.stage(frozenVersion);
142
+ }
143
+ catch (error) {
144
+ return {
145
+ outcome: "failed",
146
+ error: `cli-update: staging failed: ${error instanceof Error ? error.message : String(error)}`,
147
+ logs: ["cli-update: staging failed before any activation"]
148
+ };
149
+ }
150
+ if (staged.version !== frozenVersion) {
151
+ try {
152
+ deps.updatePorts.cleanup(staged);
153
+ }
154
+ catch {
155
+ // Best-effort cleanup; staging is side-by-side so the live
156
+ // install is untouched either way.
157
+ }
158
+ return {
159
+ outcome: "failed",
160
+ error: `cli-update: staged version ${staged.version} does not match frozen version ${frozenVersion}`,
161
+ logs: [`cli-update: refusing to activate a version other than the frozen ${frozenVersion}`]
162
+ };
163
+ }
164
+ // Reuse the inspected staged package so runUpdate activates exactly
165
+ // what was inspected rather than staging a second artifact.
166
+ const inspectedPorts = {
167
+ ...deps.updatePorts,
168
+ stage: () => staged
169
+ };
170
+ // P1-2 (rr23): persist the exact activation target (Home + global
171
+ // npm prefix) to a durable file BEFORE the irreversible effect. A
172
+ // hard-exit recovery query reads this file instead of deriving the
173
+ // target from the resume caller's npm/PATH environment. Best-effort:
174
+ // an unpinnable target or a failed write must not block the update.
175
+ try {
176
+ await persistCliUpdateIdentity(run, deps.home, idempotencyKey);
177
+ }
178
+ catch {
179
+ // Best-effort persistence; the effect may still succeed.
180
+ }
181
+ const result = runUpdate(inspectedPorts, { home: deps.home });
182
+ if (result.outcome === "updated" || result.outcome === "already-current") {
183
+ return {
184
+ outcome: "succeeded",
185
+ externalId: result.version,
186
+ logs: [`cli-update: ${result.outcome} (${result.version})`]
187
+ };
188
+ }
189
+ if (result.outcome === "ambiguous" || (result.outcome === "aborted" && !result.recoverable)) {
190
+ // P1-2 (rr20): An aborted activation with recoverable=false means
191
+ // npm activation began but its outcome is unknowable — the global
192
+ // install may or may not have landed. Treat it as ambiguous:
193
+ // persist a queryable identity so the engine never re-submits
194
+ // blindly. Only recoverable:true aborts (old binary and Home
195
+ // provably intact) are safe to retry as plain failures.
196
+ //
197
+ // P1 (rr21): A Controller ownership/handoff failure
198
+ // (controllerOwnershipUnknown) is different: the replacement
199
+ // Controller's identity could not be authenticated, so the
200
+ // Controller lifecycle handoff is unresolved. A controller-home
201
+ // query would confirm the global binary but NOT prove the
202
+ // Controller handoff completed. Return timeout WITHOUT
203
+ // externalIdentity so the engine marks the step as `unknown`
204
+ // and stops — resume must not continue until the expected
205
+ // Controller lifecycle/identity handoff is proven complete.
206
+ if (result.outcome === "aborted" && result.controllerOwnershipUnknown === true) {
207
+ return {
208
+ outcome: "timeout",
209
+ logs: [`cli-update Controller ownership unknown at phase ${result.phase}: ${result.message}`]
210
+ };
211
+ }
212
+ // P1-2 (rr22): persist the EXACT activation target (the resolved
213
+ // global prefix) alongside the Home, so a resume query checks the
214
+ // same installation instead of re-deriving the target from the
215
+ // caller's npm/PATH environment.
216
+ const identity = await controllerHomeIdentity(run, deps.home);
217
+ return {
218
+ outcome: "timeout",
219
+ ...(identity === undefined ? {} : { externalIdentity: identity }),
220
+ logs: [`cli-update ambiguous at phase ${result.phase}: ${result.message}`]
221
+ };
222
+ }
223
+ return {
224
+ outcome: "failed",
225
+ error: `cli-update aborted at phase ${result.phase}: ${result.message}`,
226
+ logs: [`cli-update: ${result.message}`]
227
+ };
228
+ }
229
+ case "controller-replace": {
230
+ try {
231
+ await stopFileTaskController(deps.home, deps.controllerOptions);
232
+ const restarted = await restartFileTaskController(deps.home, deps.controllerOptions);
233
+ const externalId = restarted.pid === undefined ? "restarted" : `pid:${restarted.pid}`;
234
+ return {
235
+ outcome: "succeeded",
236
+ externalId,
237
+ logs: [`controller-replace: previous pid ${restarted.previousPid ?? "-"} -> ${externalId}`]
238
+ };
239
+ }
240
+ catch (error) {
241
+ return {
242
+ outcome: "failed",
243
+ error: error instanceof Error ? error.message : String(error)
244
+ };
245
+ }
246
+ }
247
+ case "project-migrate": {
248
+ const project = params.project;
249
+ if (project === undefined) {
250
+ return { outcome: "failed", error: "project-migrate requires params.project." };
251
+ }
252
+ try {
253
+ const result = await runProjectCommand(["migrate", project], deps.projectStore, deps.projectOptions);
254
+ return {
255
+ outcome: "succeeded",
256
+ externalId: project,
257
+ logs: [`project-migrate: ${result.output.trim()}`]
258
+ };
259
+ }
260
+ catch (error) {
261
+ return {
262
+ outcome: "failed",
263
+ error: error instanceof Error ? error.message : String(error)
264
+ };
265
+ }
266
+ }
267
+ case "pr-create-or-reuse": {
268
+ // A commit SHA is not a valid head branch. Derive a proper branch
269
+ // name when the caller does not supply one.
270
+ const head = params.head ?? `release/${source.commit.slice(0, 12)}`;
271
+ const repo = `${source.repository.owner}/${source.repository.name}`;
272
+ const lookup = await findHeadPullRequest(run, head, repo);
273
+ if (lookup.status === "failed") {
274
+ // A failed lookup is fail-closed: never create a PR when the
275
+ // reuse check could not complete.
276
+ return {
277
+ outcome: "failed",
278
+ error: `pr-create-or-reuse: PR lookup failed: ${lookup.error}`,
279
+ logs: ["pr-create-or-reuse: lookup failed; not creating a PR"]
280
+ };
281
+ }
282
+ if (lookup.status === "found") {
283
+ // A PR for the head branch is only reusable when its head is the
284
+ // exact frozen source commit: a branch that moved, or a PR opened
285
+ // from an older push, must not be reused for this release.
286
+ if (lookup.headSha !== source.commit) {
287
+ return {
288
+ outcome: "failed",
289
+ error: `pr-create-or-reuse: PR #${lookup.prNumber} head ${lookup.headSha} does not match frozen source commit ${source.commit}`,
290
+ logs: ["pr-create-or-reuse: refusing to reuse a PR whose head is not the frozen source commit"]
291
+ };
292
+ }
293
+ return {
294
+ outcome: "succeeded",
295
+ externalId: `pr:${lookup.prNumber}`,
296
+ logs: [`pr-create-or-reuse: reused PR #${lookup.prNumber}`]
297
+ };
298
+ }
299
+ // P2-2: Authoritatively resolve the proposed remote head before
300
+ // creating a PR. A moved or missing head would create an external
301
+ // PR for the wrong commit; fail closed before any external effect.
302
+ const remoteHead = await resolveRemoteHead(run, repo, head);
303
+ if (remoteHead === undefined) {
304
+ return {
305
+ outcome: "failed",
306
+ error: `pr-create-or-reuse: cannot resolve remote head ${head} in ${repo}`,
307
+ logs: ["pr-create-or-reuse: refusing to create a PR for an unresolvable head"]
308
+ };
309
+ }
310
+ if (remoteHead !== source.commit) {
311
+ return {
312
+ outcome: "failed",
313
+ error: `pr-create-or-reuse: remote head ${head} is ${remoteHead}, not the frozen source commit ${source.commit}`,
314
+ logs: ["pr-create-or-reuse: refusing to create a PR for a head that moved"]
315
+ };
316
+ }
317
+ const created = await run("gh", [
318
+ "pr", "create", "--head", head,
319
+ "--repo", repo,
320
+ "--title", params.title ?? `Release ${head}`,
321
+ "--body", params.body ?? `Automated release workflow ${idempotencyKey}`
322
+ ]);
323
+ if (created.code !== 0) {
324
+ // A race may have opened the PR between our query and create.
325
+ if (/already exists/i.test(created.stderr)) {
326
+ const raced = await findHeadPullRequest(run, head, repo);
327
+ if (raced.status === "found" && raced.headSha === source.commit) {
328
+ return {
329
+ outcome: "succeeded",
330
+ externalId: `pr:${raced.prNumber}`,
331
+ logs: [`pr-create-or-reuse: reused PR #${raced.prNumber} after create race`]
332
+ };
333
+ }
334
+ }
335
+ return { outcome: "failed", error: created.stderr.trim() || "gh pr create failed" };
336
+ }
337
+ const match = created.stdout.match(/pull\/(\d+)/);
338
+ const prNumber = match === null ? created.stdout.trim() : match[1];
339
+ // P2-2: Verify the created PR's head SHA matches the frozen source
340
+ // commit. A race or a branch move between create and now could
341
+ // leave a PR pointing at a different commit.
342
+ const verified = await run("gh", [
343
+ "pr", "view", prNumber,
344
+ "--repo", repo,
345
+ "--json", "number,headRefOid"
346
+ ]);
347
+ if (verified.code !== 0) {
348
+ return {
349
+ outcome: "failed",
350
+ error: `pr-create-or-reuse: created PR #${prNumber} but cannot verify its head: ${verified.stderr.trim() || "gh pr view failed"}`,
351
+ logs: ["pr-create-or-reuse: created PR but head verification failed"]
352
+ };
353
+ }
354
+ const headInfo = parsePrHeadEntry(verified.stdout);
355
+ if (headInfo === undefined || headInfo.headSha !== source.commit) {
356
+ return {
357
+ outcome: "failed",
358
+ error: `pr-create-or-reuse: created PR #${prNumber} head ${headInfo?.headSha ?? "unknown"} does not match frozen source commit ${source.commit}`,
359
+ logs: ["pr-create-or-reuse: refusing to accept a PR whose head is not the frozen source commit"]
360
+ };
361
+ }
362
+ return {
363
+ outcome: "succeeded",
364
+ externalId: `pr:${prNumber}`,
365
+ logs: ["pr-create-or-reuse: created PR"]
366
+ };
367
+ }
368
+ case "ci-confirm": {
369
+ // Issue 08: when the step binds a Project with a VerificationPlan,
370
+ // a local hermetic L2 GateArtifact for the exact frozen commit is
371
+ // first-class release evidence. It is recorded as local evidence
372
+ // (never disguised as CI); when absent or unverifiable, the step
373
+ // falls back to the predeclared CI query below.
374
+ const gateProjectId = params.projectId;
375
+ if (gateProjectId !== undefined && gateProjectId !== "" && !gateProjectId.startsWith("-")) {
376
+ const project = resolveProject(deps.projectStore.listProjects(), gateProjectId);
377
+ if (project !== null) {
378
+ const gate = resolveVerificationGate(project, process.env);
379
+ if (gate !== undefined) {
380
+ const artifact = await findL2ArtifactForCommit(deps.projectStore, {
381
+ projectId: project.id,
382
+ commit: source.commit,
383
+ planDigest: gate.planDigest,
384
+ toolchainDigest: gate.toolchainDigest,
385
+ targetRef: params.targetRef ?? "master"
386
+ });
387
+ if (artifact !== null) {
388
+ return {
389
+ outcome: "succeeded",
390
+ externalId: `gate:${artifact.key}`,
391
+ logs: [
392
+ `ci-confirm: local hermetic L2 gate artifact ${artifact.key} `
393
+ + `for ${source.commit} (plan ${gate.plan.id}@${gate.plan.version}); `
394
+ + `not CI evidence`
395
+ ]
396
+ };
397
+ }
398
+ }
399
+ }
400
+ }
401
+ // Bind the CI query to the exact frozen source commit AND a
402
+ // predeclared workflow + branch, so an unrelated successful
403
+ // workflow run on the same SHA cannot satisfy the release gate.
404
+ const ciWorkflow = params.workflow;
405
+ if (ciWorkflow === undefined || ciWorkflow === "") {
406
+ return {
407
+ outcome: "failed",
408
+ error: "ci-confirm requires params.workflow (the CI workflow filename, e.g. ci.yml)",
409
+ logs: ["ci-confirm: refusing to query CI without a predeclared workflow binding"]
410
+ };
411
+ }
412
+ if (ciWorkflow.startsWith("-")) {
413
+ return {
414
+ outcome: "failed",
415
+ error: `ci-confirm: workflow must not start with '-': ${ciWorkflow}`,
416
+ logs: ["ci-confirm: refusing an option-looking workflow name"]
417
+ };
418
+ }
419
+ const ciBranch = params.branch;
420
+ if (ciBranch === undefined || ciBranch === "") {
421
+ return {
422
+ outcome: "failed",
423
+ error: "ci-confirm requires params.branch (the branch the CI workflow runs on)",
424
+ logs: ["ci-confirm: refusing to query CI without a predeclared branch binding"]
425
+ };
426
+ }
427
+ if (ciBranch.startsWith("-")) {
428
+ return {
429
+ outcome: "failed",
430
+ error: `ci-confirm: branch must not start with '-': ${ciBranch}`,
431
+ logs: ["ci-confirm: refusing an option-looking branch name"]
432
+ };
433
+ }
434
+ const status = await run("gh", [
435
+ "run", "list",
436
+ "--commit", source.commit,
437
+ "--workflow", ciWorkflow,
438
+ "--branch", ciBranch,
439
+ "--limit", "1",
440
+ "--repo", `${source.repository.owner}/${source.repository.name}`,
441
+ "--json", "conclusion,status",
442
+ "--jq", ".[0] | {conclusion,status}"
443
+ ]);
444
+ if (status.code !== 0) {
445
+ return { outcome: "failed", error: status.stderr.trim() || "gh run list failed" };
446
+ }
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(status.stdout.trim());
450
+ }
451
+ catch {
452
+ return { outcome: "failed", error: `ci-confirm: cannot parse gh run list output: ${status.stdout.trim()}` };
453
+ }
454
+ if (parsed === undefined || parsed === null) {
455
+ return { outcome: "failed", error: `ci-confirm: no ${ciWorkflow} run found on ${ciBranch} for ${source.commit}` };
456
+ }
457
+ if (parsed.status !== "completed") {
458
+ return { outcome: "failed", error: `CI not finished: ${parsed.status ?? "unknown"}` };
459
+ }
460
+ if (parsed.conclusion === "success") {
461
+ return { outcome: "succeeded", externalId: `ci:${source.commit.slice(0, 7)}`, logs: [`ci-confirm: ${ciWorkflow} on ${ciBranch} @ ${source.commit}: ${parsed.conclusion}`] };
462
+ }
463
+ return { outcome: "failed", error: `CI conclusion: ${parsed.conclusion ?? "unknown"}` };
464
+ }
465
+ case "merge": {
466
+ return mergePullRequest(run, source, params.pr, params);
467
+ }
468
+ case "version-tag": {
469
+ const tag = params.tag;
470
+ if (tag === undefined) {
471
+ return { outcome: "failed", error: "version-tag requires params.tag." };
472
+ }
473
+ // P1-3: A tag that starts with '-' would be interpreted as a git
474
+ // option. Reject it before any command runs.
475
+ if (tag.startsWith("-")) {
476
+ return {
477
+ outcome: "failed",
478
+ error: `version-tag: tag ${tag} must not start with '-' (would be interpreted as a git option)`,
479
+ logs: ["version-tag: refusing to create a tag that looks like an option"]
480
+ };
481
+ }
482
+ // The tag is created and pushed from inside the exact source
483
+ // repository checkout, never the Controller's cwd.
484
+ const cwd = params.repositoryPath;
485
+ if (cwd === undefined) {
486
+ return { outcome: "failed", error: "version-tag requires params.repositoryPath." };
487
+ }
488
+ // The checkout must be the granted source repository. An arbitrary
489
+ // params.repositoryPath would push the release tag to that clone's
490
+ // origin, so verify the remote before any local effect.
491
+ // P1-5: Verify both the fetch URL and the push URL. `git push` uses
492
+ // the push URL, which can differ from the fetch URL when pushurl is
493
+ // configured. A mismatch would send the tag to the wrong remote.
494
+ const fetchUrl = await run("git", ["remote", "get-url", "origin"], cwd);
495
+ if (fetchUrl.code !== 0) {
496
+ return {
497
+ outcome: "failed",
498
+ error: `version-tag: cannot read origin of ${cwd}: ${fetchUrl.stderr.trim() || "git remote get-url failed"}`,
499
+ logs: ["version-tag: refusing to tag a checkout whose origin cannot be verified"]
500
+ };
501
+ }
502
+ if (!remoteMatchesRepository(fetchUrl.stdout, source.repository)) {
503
+ return {
504
+ outcome: "failed",
505
+ error: `version-tag: origin ${fetchUrl.stdout.trim()} is not the granted repository ${source.repository.owner}/${source.repository.name}`,
506
+ logs: ["version-tag: refusing to tag a checkout whose origin is not the granted repository"]
507
+ };
508
+ }
509
+ // P1-5: Verify ALL configured push URLs. `git push` writes to every
510
+ // pushurl, so a matching first URL plus a foreign second URL would
511
+ // still send the release tag to an unauthorized target.
512
+ const pushUrls = await run("git", ["remote", "get-url", "--push", "--all", "origin"], cwd);
513
+ if (pushUrls.code !== 0) {
514
+ return {
515
+ outcome: "failed",
516
+ error: `version-tag: cannot read push URLs of ${cwd}: ${pushUrls.stderr.trim() || "git remote get-url --push --all failed"}`,
517
+ logs: ["version-tag: refusing to tag a checkout whose push URLs cannot be verified"]
518
+ };
519
+ }
520
+ const effectivePushUrls = pushUrls.stdout
521
+ .split(/\r?\n/)
522
+ .map((url) => url.trim())
523
+ .filter((url) => url.length > 0);
524
+ if (effectivePushUrls.length === 0) {
525
+ return {
526
+ outcome: "failed",
527
+ error: `version-tag: origin of ${cwd} has no push URL`,
528
+ logs: ["version-tag: refusing to tag a checkout with no push URL"]
529
+ };
530
+ }
531
+ const foreignPushUrl = effectivePushUrls.find((url) => !remoteMatchesRepository(url, source.repository));
532
+ if (foreignPushUrl !== undefined) {
533
+ return {
534
+ outcome: "failed",
535
+ error: `version-tag: push URL ${foreignPushUrl} is not the granted repository ${source.repository.owner}/${source.repository.name}`,
536
+ logs: ["version-tag: refusing to tag a checkout with a foreign push URL"]
537
+ };
538
+ }
539
+ // Recovery convergence: a previous attempt may have created the
540
+ // local tag before the push failed. Re-creating it would fail
541
+ // ("tag already exists") and strand the recovery, so verify an
542
+ // existing tag points at the frozen commit and proceed to push;
543
+ // only create the tag when it is absent.
544
+ // P1-3: Validate the tag is a well-formed ref name so it can never
545
+ // be interpreted as an option or an unexpected revision.
546
+ const refCheck = await run("git", ["check-ref-format", `refs/tags/${tag}`], cwd);
547
+ if (refCheck.code !== 0) {
548
+ return {
549
+ outcome: "failed",
550
+ error: `version-tag: tag ${tag} is not a valid git ref name`,
551
+ logs: ["version-tag: refusing to create a tag that is not a valid ref"]
552
+ };
553
+ }
554
+ // Resolve the explicit refs/tags/<tag> revision WITHOUT a `--`
555
+ // separator: `--` would make git parse the revision as a path and
556
+ // report a real existing tag as absent.
557
+ const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], cwd);
558
+ if (existing.code === 0) {
559
+ if (existing.stdout.trim() !== source.commit) {
560
+ return {
561
+ outcome: "failed",
562
+ error: `version-tag: local tag ${tag} points at ${existing.stdout.trim()}, not the frozen commit ${source.commit}`,
563
+ logs: ["version-tag: refusing to push a local tag that does not name the frozen commit"]
564
+ };
565
+ }
566
+ }
567
+ else {
568
+ // Place -m and its value before the `--` separator so the tag
569
+ // name and commit are the only operands; `--` after options keeps
570
+ // the tag from ever being parsed as an option.
571
+ const tagged = await run("git", ["tag", "-a", "-m", `Release ${tag}`, "--", tag, source.commit], cwd);
572
+ if (tagged.code !== 0) {
573
+ return { outcome: "failed", error: tagged.stderr.trim() || "git tag failed" };
574
+ }
575
+ }
576
+ // P1-3: Use an explicit refspec so the tag is pushed to the exact
577
+ // refs/tags/<tag> path, never interpreted as an option or a ref.
578
+ const sent = await run("git", ["push", "origin", `refs/tags/${tag}:refs/tags/${tag}`], cwd);
579
+ if (sent.code !== 0) {
580
+ return { outcome: "timeout", externalIdentity: { kind: "git-tag", value: tag }, error: sent.stderr.trim() };
581
+ }
582
+ return { outcome: "succeeded", externalId: `tag:${tag}`, logs: [`version-tag: ${tag} @ ${source.commit}`] };
583
+ }
584
+ case "npm-publish": {
585
+ const tarball = params.tarball;
586
+ if (tarball === undefined) {
587
+ return { outcome: "failed", error: "npm-publish requires params.tarball." };
588
+ }
589
+ // P1-6: Require a content-addressed source artifact for every
590
+ // npm-publish. Without an artifact, the tarball is unbound: any
591
+ // same-named file could be published, violating the exact-source
592
+ // guarantee. The artifact's sha512 is verified before publishing.
593
+ if (source.artifact === undefined) {
594
+ return {
595
+ outcome: "failed",
596
+ error: "npm-publish: source.artifact is required (content-addressed tarball with sha512)",
597
+ logs: ["npm-publish: refusing to publish without a frozen artifact"]
598
+ };
599
+ }
600
+ const tarballName = tarball.split("/").pop() ?? tarball;
601
+ if (tarballName !== source.artifact.name) {
602
+ return {
603
+ outcome: "failed",
604
+ error: `npm-publish: tarball ${tarballName} does not match frozen artifact ${source.artifact.name}`,
605
+ logs: ["npm-publish: refusing to publish a tarball that is not the frozen artifact"]
606
+ };
607
+ }
608
+ // The frozen artifact is content-addressed: a same-named tarball
609
+ // with different bytes would still publish the wrong package.
610
+ // Verify the tarball's sha512 against the recorded integrity
611
+ // before publishing, and fail closed when the file cannot be read.
612
+ let bytes;
613
+ try {
614
+ bytes = await readFile(tarball);
615
+ }
616
+ catch (error) {
617
+ return {
618
+ outcome: "failed",
619
+ error: `npm-publish: cannot read tarball ${tarball}: ${error instanceof Error ? error.message : String(error)}`,
620
+ logs: ["npm-publish: refusing to publish a tarball that cannot be verified"]
621
+ };
622
+ }
623
+ const actualIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
624
+ if (actualIntegrity !== source.artifact.integrity) {
625
+ return {
626
+ outcome: "failed",
627
+ error: `npm-publish: tarball integrity ${actualIntegrity} does not match frozen artifact ${source.artifact.integrity}`,
628
+ logs: ["npm-publish: refusing to publish a tarball that is not the frozen artifact"]
629
+ };
630
+ }
631
+ // Reject option-looking tarball paths before any subprocess sees
632
+ // them: both `tar -xOf` (manifest inspection) and `npm publish`
633
+ // would interpret a file named "--dry-run" as a flag, not an
634
+ // operand, and report a normal result without any registry effect.
635
+ if (tarball.startsWith("-")) {
636
+ return {
637
+ outcome: "failed",
638
+ error: `npm-publish: tarball path must not start with '-': ${tarball}`,
639
+ logs: ["npm-publish: refusing to publish an option-looking tarball path"]
640
+ };
641
+ }
642
+ // P1-3 (rr22): Snapshot the verified bytes to a workflow-private,
643
+ // read-only temp file. Both the manifest inspection and `npm
644
+ // publish` read THIS snapshot, so a TOCTOU replacement of the
645
+ // original path after the integrity check cannot change what is
646
+ // published. The snapshot is removed once publish completes.
647
+ const snapshot = await writeVerifiedTarballSnapshot(bytes);
648
+ try {
649
+ // Inspect the tarball manifest before publishing: the actual
650
+ // package name/version must match the frozen declaration, not
651
+ // just the file name or bytes. A same-named tarball with a
652
+ // different manifest would publish the wrong package, and a
653
+ // source without a frozen artifact still gets this check.
654
+ const target = params.package ?? derivePackageName(source);
655
+ const manifest = await readTarballManifest(run, snapshot);
656
+ if (manifest === undefined) {
657
+ return {
658
+ outcome: "failed",
659
+ error: `npm-publish: cannot read manifest of ${tarball}`,
660
+ logs: ["npm-publish: refusing to publish a tarball whose manifest cannot be verified"]
661
+ };
662
+ }
663
+ if (manifest.name !== target) {
664
+ return {
665
+ outcome: "failed",
666
+ error: `npm-publish: tarball manifest ${manifest.name} does not match publish target ${target}`,
667
+ logs: ["npm-publish: refusing to publish a tarball that is not the frozen package"]
668
+ };
669
+ }
670
+ if (params.version !== undefined && manifest.version !== params.version) {
671
+ return {
672
+ outcome: "failed",
673
+ error: `npm-publish: tarball version ${manifest.version} does not match frozen version ${params.version}`,
674
+ logs: ["npm-publish: refusing to publish a tarball that is not the frozen version"]
675
+ };
676
+ }
677
+ // P1 (rr25): resolve and persist the exact npm executable and
678
+ // registry BEFORE the irreversible effect. A resume in a
679
+ // different environment reads this durable receipt instead of
680
+ // invoking its own npm/registry: a different mirror serving the
681
+ // same version+integrity would otherwise falsely confirm this
682
+ // publish. The registry is also passed to publish explicitly so
683
+ // the receipt names the target the effect actually used.
684
+ if (pinnedNpmPath === undefined) {
685
+ return {
686
+ outcome: "failed",
687
+ error: "npm-publish: npm is not resolvable at adapter construction; refusing an unbound effect target",
688
+ logs: ["npm-publish: refusing to publish without a pinned npm executable"]
689
+ };
690
+ }
691
+ const registry = await resolveNpmPublishRegistry(run, manifest.name, manifest.publishConfig?.registry);
692
+ if (registry === undefined) {
693
+ return {
694
+ outcome: "failed",
695
+ error: "npm-publish: registry target could not be resolved; refusing an unbound effect target",
696
+ logs: ["npm-publish: refusing to publish without a pinned registry"]
697
+ };
698
+ }
699
+ try {
700
+ await persistNpmPublishTarget(deps.home, idempotencyKey, {
701
+ npmPath: pinnedNpmPath,
702
+ registry
703
+ });
704
+ }
705
+ catch (error) {
706
+ return {
707
+ outcome: "failed",
708
+ error: `npm-publish: cannot persist effect target before publish: ${error instanceof Error ? error.message : String(error)}`,
709
+ logs: ["npm-publish: refusing to publish when the durable target cannot be recorded"]
710
+ };
711
+ }
712
+ const published = await run("npm", [
713
+ "publish",
714
+ "--registry",
715
+ registry,
716
+ "--",
717
+ snapshot
718
+ ]);
719
+ if (published.code !== 0) {
720
+ // A transport failure after the upload may have actually published
721
+ // the package. Record the identity so the engine can query npm
722
+ // authoritatively on resume instead of re-publishing blindly.
723
+ if (isTransportFailure(published.stderr)) {
724
+ return {
725
+ outcome: "failed",
726
+ externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
727
+ error: published.stderr.trim() || "npm publish failed",
728
+ logs: ["npm-publish: transport failure after upload; querying registry on resume"]
729
+ };
730
+ }
731
+ return { outcome: "failed", error: published.stderr.trim() || "npm publish failed" };
732
+ }
733
+ // Verify the publish result matches the frozen declaration. npm
734
+ // reports the published id as "+ name@version"; a result naming a
735
+ // different package or version must not satisfy the step, and a
736
+ // result without a parseable confirmation is uncertain: the engine
737
+ // re-queries the registry by the exact package identity on resume.
738
+ const confirmation = parsePublishConfirmation(published.stdout);
739
+ if (confirmation === undefined) {
740
+ return {
741
+ outcome: "timeout",
742
+ externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
743
+ error: "npm-publish: publish result did not confirm the frozen package@version",
744
+ logs: ["npm-publish: publish result unconfirmed; querying registry on resume"]
745
+ };
746
+ }
747
+ if (confirmation.name !== target || confirmation.version !== manifest.version) {
748
+ return {
749
+ outcome: "failed",
750
+ externalIdentity: { kind: "npm-package", value: `${target}@${manifest.version}` },
751
+ error: `npm-publish: published ${confirmation.name}@${confirmation.version} does not match frozen ${target}@${manifest.version}`,
752
+ logs: ["npm-publish: refusing to satisfy the step with a different publish result"]
753
+ };
754
+ }
755
+ return {
756
+ outcome: "succeeded",
757
+ externalId: manifest.version,
758
+ logs: [`npm-publish: ${tarball}`]
759
+ };
760
+ }
761
+ finally {
762
+ // Remove the private snapshot on every exit path (success,
763
+ // failure, or throw) so the verified bytes never linger.
764
+ await rm(snapshot, { force: true }).catch(() => { });
765
+ }
766
+ }
767
+ case "fresh-install-smoke": {
768
+ const version = params.version;
769
+ // P2-3: Require a concrete, pinned version. Without one, npx
770
+ // resolves to "latest" (or a moving range), which could install a
771
+ // different release than the one this workflow just published.
772
+ if (version === undefined || !isConcreteVersion(version)) {
773
+ return {
774
+ outcome: "failed",
775
+ error: `fresh-install-smoke: params.version ${version ?? "(missing)"} is not a concrete pinned version (e.g. 1.2.3)`,
776
+ logs: ["fresh-install-smoke: refusing to install a moving or unpinned version"]
777
+ };
778
+ }
779
+ // Install the released scoped package derived from the exact source
780
+ // repository, not an unscoped or stale name.
781
+ const pkg = params.package ?? derivePackageName(source);
782
+ // P2-3: Validate the package spec is not an option.
783
+ if (pkg.startsWith("-")) {
784
+ return {
785
+ outcome: "failed",
786
+ error: `fresh-install-smoke: package ${pkg} must not start with '-' (would be interpreted as an npx option)`,
787
+ logs: ["fresh-install-smoke: refusing to install a package that looks like an option"]
788
+ };
789
+ }
790
+ // P2-2 (rr19): Install in a fresh temp directory with an isolated
791
+ // npm cache, so npx cannot reuse a local node_modules binary. Then
792
+ // run the installed binary directly and verify the output matches
793
+ // the pinned version.
794
+ const smokeDir = await mkdtemp(join(tmpdir(), "yui-smoke-"));
795
+ const smokeCache = join(smokeDir, "npm-cache");
796
+ try {
797
+ const install = await run("npm", [
798
+ "install",
799
+ "--prefix", smokeDir,
800
+ "--cache", smokeCache,
801
+ "--no-audit", "--no-fund",
802
+ `${pkg}@${version}`
803
+ ]);
804
+ if (install.code !== 0) {
805
+ return { outcome: "failed", error: install.stderr.trim() || `npm install ${pkg}@${version} failed` };
806
+ }
807
+ // Derive the binary name from the package name (scoped or unscoped).
808
+ const binName = pkg.includes("/") ? pkg.split("/").pop() : pkg;
809
+ const binPath = join(smokeDir, "node_modules", ".bin", binName);
810
+ const smoke = await run(binPath, ["--version"]);
811
+ if (smoke.code !== 0) {
812
+ return { outcome: "failed", error: smoke.stderr.trim() || "fresh-install smoke failed" };
813
+ }
814
+ const output = smoke.stdout.trim();
815
+ if (output !== version) {
816
+ return {
817
+ outcome: "failed",
818
+ error: `fresh-install-smoke: installed version ${output} does not match pinned ${version}`,
819
+ logs: [`fresh-install-smoke: refusing to accept a version mismatch (got ${output}, want ${version})`]
820
+ };
821
+ }
822
+ return { outcome: "succeeded", externalId: version, logs: [`fresh-install-smoke: ${output}`] };
823
+ }
824
+ finally {
825
+ await rm(smokeDir, { recursive: true, force: true }).catch(() => { });
826
+ }
827
+ }
828
+ case "post-verify": {
829
+ const command = params.command;
830
+ if (command === undefined) {
831
+ return { outcome: "failed", error: "post-verify requires params.command." };
832
+ }
833
+ const verified = await run("sh", ["-c", command], params.cwd);
834
+ if (verified.code !== 0) {
835
+ return { outcome: "failed", error: verified.stderr.trim() || `post-verify exited ${verified.code}` };
836
+ }
837
+ return { outcome: "succeeded", logs: [`post-verify: ${command}`] };
838
+ }
839
+ default:
840
+ return { outcome: "failed", error: `Unsupported step kind: ${String(step.kind)}` };
841
+ }
842
+ }
843
+ return {
844
+ home: deps.home,
845
+ async executeStep(input) {
846
+ // Adapter-level idempotency: a crash after the effect landed but
847
+ // before the engine persisted it re-invokes executeStep under the
848
+ // same key. Replay the recorded success instead of producing a
849
+ // second shell execution. A store that cannot be read fails the
850
+ // step, since at-most-once cannot be proven.
851
+ let recorded;
852
+ try {
853
+ recorded = await idempotency.load(input.idempotencyKey);
854
+ }
855
+ catch (error) {
856
+ return {
857
+ outcome: "failed",
858
+ error: `idempotency store unreadable: ${error instanceof Error ? error.message : String(error)}`,
859
+ logs: ["executeStep: refusing to run without the idempotency record"]
860
+ };
861
+ }
862
+ if (recorded !== undefined) {
863
+ return {
864
+ ...recorded,
865
+ logs: [
866
+ ...(recorded.logs ?? []),
867
+ `idempotent: replayed recorded effect for ${input.idempotencyKey}`
868
+ ]
869
+ };
870
+ }
871
+ const effect = await executeStepOnce(input);
872
+ if (effect.outcome === "succeeded") {
873
+ try {
874
+ await idempotency.recordSuccess(input.idempotencyKey, effect);
875
+ }
876
+ catch (error) {
877
+ // The effect landed but its dedup record is not durable. Never
878
+ // report success: a crash before the engine persisted the step
879
+ // would re-run the effect. Fail closed as ambiguous, carrying a
880
+ // queryable identity when the step kind has one so the engine
881
+ // re-queries authoritatively on resume instead of re-submitting.
882
+ const identity = await queryIdentityFor(input.step, input.params, effect, input.source, deps.home, run);
883
+ return {
884
+ outcome: "timeout",
885
+ ...(identity === undefined ? {} : { externalIdentity: identity }),
886
+ error: `idempotency record not persisted: ${error instanceof Error ? error.message : String(error)}`,
887
+ logs: [
888
+ ...(effect.logs ?? []),
889
+ "executeStep: effect landed but its idempotency record is not durable; failing closed"
890
+ ]
891
+ };
892
+ }
893
+ }
894
+ return effect;
895
+ },
896
+ async queryStepEffect({ step, source, externalIdentity }) {
897
+ // P2-1: Check the durable idempotency store first. A recorded success
898
+ // proves the effect landed even when the engine has no
899
+ // externalIdentity (e.g. a crash between executeStep and the engine's
900
+ // completeStep save). Without this, a successfully executed step is
901
+ // permanently stranded as unconfirmed.
902
+ const durable = await idempotency.load(step.idempotencyKey);
903
+ if (durable !== undefined) {
904
+ return { state: "exists", externalId: durable.externalId };
905
+ }
906
+ if (externalIdentity === undefined) {
907
+ // P2-1 (rr19/rr20): When no identity was recorded (a hard exit
908
+ // between the external effect and the idempotency/identity
909
+ // persistence), derive the identity from the frozen plan for kinds
910
+ // that support it. This prevents a permanently stranded unconfirmed
911
+ // step when the effect actually landed.
912
+ if (step.kind === "cli-update") {
913
+ // P1-2 (rr23): A hard-exit cli-update may only be queried against
914
+ // the exact activation target persisted to a durable file BEFORE
915
+ // the irreversible effect. Never derive the target from the resume
916
+ // caller's npm/PATH environment: a different global prefix on
917
+ // resume could confirm a different installation. With no durable
918
+ // record (the process exited before the pre-effect persistence)
919
+ // the effect is unprovable, so fail closed.
920
+ externalIdentity = await readPersistedCliUpdateIdentity(deps.home, step.idempotencyKey);
921
+ if (externalIdentity === undefined)
922
+ return { state: "unknown" };
923
+ }
924
+ else {
925
+ const derived = deriveIdentityFromPlan(step, source);
926
+ if (derived !== undefined) {
927
+ externalIdentity = derived;
928
+ }
929
+ else {
930
+ // A step that crashed before recording an effect identity cannot be
931
+ // re-queried by a stable handle. The step plan alone is not an
932
+ // authoritative handle for every kind, so fail closed: the engine
933
+ // treats the disposition as unconfirmed rather than re-submitting.
934
+ return { state: "unknown" };
935
+ }
936
+ }
937
+ }
938
+ switch (externalIdentity.kind) {
939
+ case "pull-request": {
940
+ // P2-2: Bind the query to the source repository and require the
941
+ // PR's head to be the exact frozen commit. A same-numbered PR in a
942
+ // different repository, or one whose head moved, must not confirm
943
+ // the effect.
944
+ if (source === undefined)
945
+ return { state: "unknown" };
946
+ const repo = `${source.repository.owner}/${source.repository.name}`;
947
+ const found = await run("gh", [
948
+ "pr", "view", externalIdentity.value,
949
+ "--repo", repo,
950
+ "--json", "number,headRefOid"
951
+ ]);
952
+ if (found.code !== 0) {
953
+ // A non-zero exit is ambiguous (the PR may be gone, or the
954
+ // transport failed); fail closed rather than authorizing a
955
+ // re-submission.
956
+ return { state: "unknown" };
957
+ }
958
+ const headInfo = parsePrHeadEntry(found.stdout);
959
+ if (headInfo === undefined || headInfo.headSha !== source.commit) {
960
+ return { state: "unknown" };
961
+ }
962
+ return { state: "exists", externalId: `pr:${headInfo.prNumber}` };
963
+ }
964
+ case "git-tag": {
965
+ // A local tag is not authoritative: query the remote so a tag that
966
+ // was never pushed reads as absent. The query runs inside the
967
+ // step's source repository checkout, never the Controller's cwd.
968
+ // An empty result (exit 0, no output) is an authoritative absent;
969
+ // a non-zero exit is a transport failure and reads as unknown.
970
+ //
971
+ // Re-attest the checkout before querying: an answer from a foreign
972
+ // or unverifiable origin is worthless (and a foreign origin could
973
+ // confirm a tag we never pushed). Without the bound source or the
974
+ // checkout path the binding cannot be checked, so fail closed.
975
+ // P1-5: Verify both fetch and push URLs.
976
+ const checkoutPath = step.params?.repositoryPath;
977
+ if (source === undefined || checkoutPath === undefined) {
978
+ return { state: "unknown" };
979
+ }
980
+ const fetchUrl = await run("git", ["remote", "get-url", "origin"], checkoutPath);
981
+ if (fetchUrl.code !== 0 || !remoteMatchesRepository(fetchUrl.stdout, source.repository)) {
982
+ return { state: "unknown" };
983
+ }
984
+ // P1-5: Verify ALL configured push URLs in the recovery path too.
985
+ const pushUrls = await run("git", ["remote", "get-url", "--push", "--all", "origin"], checkoutPath);
986
+ if (pushUrls.code !== 0) {
987
+ return { state: "unknown" };
988
+ }
989
+ const recoveryPushUrls = pushUrls.stdout
990
+ .split(/\r?\n/)
991
+ .map((url) => url.trim())
992
+ .filter((url) => url.length > 0);
993
+ if (recoveryPushUrls.length === 0
994
+ || recoveryPushUrls.some((url) => !remoteMatchesRepository(url, source.repository))) {
995
+ return { state: "unknown" };
996
+ }
997
+ const found = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${externalIdentity.value}`], checkoutPath);
998
+ if (found.code !== 0) {
999
+ return { state: "unknown" };
1000
+ }
1001
+ if (found.stdout.trim().length > 0) {
1002
+ // Verify the tag points at the expected source commit. A tag that
1003
+ // points at a different commit is not the effect we recorded.
1004
+ // Prefer the peeled ref (refs/tags/name^{}): for an annotated tag
1005
+ // the direct line names the tag object, not the commit it
1006
+ // records, so its SHA differs from the source commit even when
1007
+ // the tag is ours. The peeled line names the commit the tag
1008
+ // ultimately points at; a lightweight tag has no peeled line and
1009
+ // the direct line's SHA is the commit itself.
1010
+ const lines = found.stdout
1011
+ .split(/\r?\n/)
1012
+ .map((line) => line.trim())
1013
+ .filter((line) => line.length > 0);
1014
+ const peeled = lines.find((line) => line.endsWith(`refs/tags/${externalIdentity.value}^{}`));
1015
+ const direct = lines.find((line) => line.endsWith(`refs/tags/${externalIdentity.value}`));
1016
+ const actualCommit = (peeled ?? direct)?.split(/\s+/)[0];
1017
+ if (source !== undefined && actualCommit !== undefined && actualCommit !== source.commit) {
1018
+ return { state: "unknown" };
1019
+ }
1020
+ return { state: "exists", externalId: `tag:${externalIdentity.value}` };
1021
+ }
1022
+ return { state: "absent" };
1023
+ }
1024
+ case "npm-package": {
1025
+ // The externalIdentity value may be "package" or "package@version".
1026
+ // Query the exact version, not the latest, so a newer concurrent
1027
+ // publish cannot confirm our step.
1028
+ const atIndex = externalIdentity.value.lastIndexOf("@");
1029
+ let pkg = externalIdentity.value;
1030
+ let expectedVersion;
1031
+ if (atIndex > 0) {
1032
+ pkg = externalIdentity.value.slice(0, atIndex);
1033
+ expectedVersion = externalIdentity.value.slice(atIndex + 1);
1034
+ }
1035
+ // Fall back to the step params for the expected version.
1036
+ if (expectedVersion === undefined) {
1037
+ expectedVersion = step.params?.version;
1038
+ }
1039
+ // Without an exact version, `npm view <pkg>` answers for whatever
1040
+ // the latest publish is — a concurrent release could confirm our
1041
+ // step with someone else's version. Fail closed instead of
1042
+ // querying the moving "latest" target.
1043
+ if (expectedVersion === undefined) {
1044
+ return { state: "unknown" };
1045
+ }
1046
+ // P1 (rr24): an npm-publish recovery query may only run against
1047
+ // the exact npm executable and registry persisted BEFORE the
1048
+ // original effect. Never fall back to the resume caller's
1049
+ // npm/registry: a different mirror serving the same
1050
+ // version+integrity would falsely confirm environment A's
1051
+ // publish. With no durable record (the process exited before the
1052
+ // pre-effect persistence, or the record is malformed) the effect
1053
+ // is unprovable, so fail closed.
1054
+ const persistedTarget = await readPersistedNpmPublishTarget(deps.home, step.idempotencyKey);
1055
+ if (persistedTarget === undefined) {
1056
+ return { state: "unknown" };
1057
+ }
1058
+ const view = (field) => run(persistedTarget.npmPath, ["view", `${pkg}@${expectedVersion}`, field, "--registry", persistedTarget.registry]);
1059
+ const found = await view("version");
1060
+ if (found.code === 0 && found.stdout.trim().length > 0) {
1061
+ const actualVersion = found.stdout.trim();
1062
+ // The published version must match the exact expected version.
1063
+ if (actualVersion !== expectedVersion) {
1064
+ return { state: "unknown" };
1065
+ }
1066
+ // P1-4 (rr22): a matching version string alone does not prove
1067
+ // the frozen artifact landed. Compare the published
1068
+ // `dist.integrity` with the frozen source artifact's integrity:
1069
+ // a version republished with different bytes is a conflict and
1070
+ // is never confirmed. Without a frozen integrity the published
1071
+ // bytes cannot be proven to be the granted artifact, so fail
1072
+ // closed there too.
1073
+ const frozenIntegrity = source?.artifact?.integrity;
1074
+ if (frozenIntegrity === undefined) {
1075
+ return { state: "unknown" };
1076
+ }
1077
+ const integrity = await view("dist.integrity");
1078
+ if (integrity.code !== 0 || integrity.stdout.trim().length === 0) {
1079
+ return { state: "unknown" };
1080
+ }
1081
+ if (integrity.stdout.trim() !== frozenIntegrity) {
1082
+ return { state: "unknown" };
1083
+ }
1084
+ return { state: "exists", externalId: actualVersion };
1085
+ }
1086
+ // A 404 means the package/version does not exist (absent).
1087
+ if (/E404|404 Not Found/i.test(found.stderr)) {
1088
+ return { state: "absent" };
1089
+ }
1090
+ // A non-404 failure is a transport error; fail closed.
1091
+ return { state: "unknown" };
1092
+ }
1093
+ case "controller-home": {
1094
+ // P1-2 (rr22): the identity value is a JSON envelope
1095
+ // {home, globalPrefix?} that pins the exact activation target;
1096
+ // legacy identities are bare Home path strings and re-derive the
1097
+ // prefix from the caller's environment.
1098
+ let queriedHome = deps.home;
1099
+ let pinnedPrefix;
1100
+ const parsedIdentity = parseControllerHomeIdentity(externalIdentity.value);
1101
+ if (parsedIdentity !== undefined) {
1102
+ if (parsedIdentity.globalPrefix === undefined)
1103
+ return { state: "unknown" };
1104
+ queriedHome = parsedIdentity.home;
1105
+ pinnedPrefix = parsedIdentity.globalPrefix;
1106
+ }
1107
+ else {
1108
+ queriedHome = externalIdentity.value;
1109
+ }
1110
+ // P1-3 (rr20): Query the GLOBAL install target — the same binary
1111
+ // `cli-update` activates via `npm install --global` — not the
1112
+ // current checkout module. A checkout that happens to be the target
1113
+ // version must not confirm a global install that is stale or never
1114
+ // activated. A pinned prefix is used as-is; only legacy/unpinned
1115
+ // identities re-derive via `npm prefix --global`, never PATH.
1116
+ let globalPrefix = pinnedPrefix;
1117
+ if (globalPrefix === undefined) {
1118
+ const prefixResult = await run("npm", ["prefix", "--global"]);
1119
+ if (prefixResult.code !== 0)
1120
+ return { state: "unknown" };
1121
+ globalPrefix = prefixResult.stdout.trim();
1122
+ if (globalPrefix.length === 0)
1123
+ return { state: "unknown" };
1124
+ }
1125
+ const globalYui = join(globalPrefix, "bin", "yui");
1126
+ const homeEnv = { YUI_HOME: queriedHome };
1127
+ const checked = await run(process.execPath, [globalYui, "--json", "doctor"], undefined, homeEnv);
1128
+ if (checked.code !== 0)
1129
+ return { state: "unknown" };
1130
+ const expectedVersion = step.params?.version;
1131
+ if (expectedVersion !== undefined) {
1132
+ const versioned = await run(process.execPath, [globalYui, "--version"], undefined, homeEnv);
1133
+ if (versioned.code !== 0 || versioned.stdout.trim() !== expectedVersion) {
1134
+ return { state: "unknown" };
1135
+ }
1136
+ }
1137
+ // P1-1 (rr22): binary health alone does not prove the Controller
1138
+ // lifecycle handoff completed. Require a current Controller for
1139
+ // this exact Home and, when a version is frozen, that the running
1140
+ // Controller's identity reports the expected version. A hard exit
1141
+ // during the stop/activate/verify/start window leaves no current
1142
+ // Controller (or a wrong-version one), so the query fails closed.
1143
+ const lifecycle = await queryControllerLifecycle(run, globalYui, queriedHome, expectedVersion);
1144
+ if (lifecycle !== "confirmed")
1145
+ return { state: "unknown" };
1146
+ return { state: "exists" };
1147
+ }
1148
+ default:
1149
+ return { state: "unknown" };
1150
+ }
1151
+ }
1152
+ };
1153
+ }
1154
+ /**
1155
+ * Finds the open PR for a head ref. Prefers `gh pr view --head`; older gh
1156
+ * versions that lack the flag fall back to `gh pr list --head`. A non-zero
1157
+ * exit from both commands is a failed lookup (transport error), not an
1158
+ * authoritative "no PR found". Both paths request the head object id so the
1159
+ * caller can prove the PR names the frozen source commit before reuse.
1160
+ */
1161
+ /**
1162
+ * Resolves the SHA a remote head branch currently points at, or undefined
1163
+ * when the branch cannot be resolved or the answer is not a 40-hex commit.
1164
+ * Used to prove the proposed PR head is the exact frozen source commit
1165
+ * before creating an external PR.
1166
+ */
1167
+ async function resolveRemoteHead(run, repo, head) {
1168
+ const slash = repo.indexOf("/");
1169
+ if (slash <= 0)
1170
+ return undefined;
1171
+ const owner = repo.slice(0, slash);
1172
+ const name = repo.slice(slash + 1);
1173
+ const viewed = await run("gh", [
1174
+ "api",
1175
+ `repos/${owner}/${name}/git/ref/heads/${head}`,
1176
+ "--jq", ".object.sha"
1177
+ ]);
1178
+ if (viewed.code !== 0)
1179
+ return undefined;
1180
+ const sha = viewed.stdout.trim();
1181
+ return /^[0-9a-f]{40}$/i.test(sha) ? sha : undefined;
1182
+ }
1183
+ async function findHeadPullRequest(run, head, repo) {
1184
+ const viewed = await run("gh", [
1185
+ "pr", "view", "--head", head,
1186
+ "--repo", repo,
1187
+ "--json", "number,headRefOid"
1188
+ ]);
1189
+ if (viewed.code === 0) {
1190
+ const found = parsePrHeadEntry(viewed.stdout);
1191
+ if (found !== undefined) {
1192
+ return { status: "found", prNumber: found.prNumber, headSha: found.headSha };
1193
+ }
1194
+ }
1195
+ // A non-zero exit from `gh pr view` may mean "no PR for head" (exit 1 with
1196
+ // empty stdout) or a transport failure. The list fallback disambiguates.
1197
+ const listed = await run("gh", [
1198
+ "pr", "list", "--head", head,
1199
+ "--repo", repo,
1200
+ "--state", "open",
1201
+ "--json", "number,headRefOid"
1202
+ ]);
1203
+ if (listed.code === 0) {
1204
+ const found = parsePrHeadList(listed.stdout);
1205
+ if (found !== undefined) {
1206
+ return { status: "found", prNumber: found.prNumber, headSha: found.headSha };
1207
+ }
1208
+ // Authoritative empty result: no open PR for this head.
1209
+ return { status: "not-found" };
1210
+ }
1211
+ // Both commands failed: transport error, not "no PR found".
1212
+ return {
1213
+ status: "failed",
1214
+ error: listed.stderr.trim() || viewed.stderr.trim() || "gh PR lookup failed"
1215
+ };
1216
+ }
1217
+ /** Parses `gh pr view --json number,headRefOid` output. */
1218
+ function parsePrHeadEntry(stdout) {
1219
+ try {
1220
+ return prHeadFromJson(JSON.parse(stdout.trim()));
1221
+ }
1222
+ catch {
1223
+ return undefined;
1224
+ }
1225
+ }
1226
+ /** Parses the first entry of `gh pr list --json number,headRefOid` output. */
1227
+ function parsePrHeadList(stdout) {
1228
+ try {
1229
+ const parsed = JSON.parse(stdout.trim());
1230
+ if (!Array.isArray(parsed) || parsed.length === 0)
1231
+ return undefined;
1232
+ return prHeadFromJson(parsed[0]);
1233
+ }
1234
+ catch {
1235
+ return undefined;
1236
+ }
1237
+ }
1238
+ function prHeadFromJson(value) {
1239
+ if (typeof value !== "object" || value === null)
1240
+ return undefined;
1241
+ const record = value;
1242
+ if (typeof record.number !== "number" || !Number.isFinite(record.number))
1243
+ return undefined;
1244
+ if (typeof record.headRefOid !== "string" || record.headRefOid.length === 0)
1245
+ return undefined;
1246
+ return { prNumber: String(record.number), headSha: record.headRefOid };
1247
+ }
1248
+ async function mergePullRequest(run, source, number, params) {
1249
+ if (number === undefined) {
1250
+ return { outcome: "failed", error: "merge requires params.pr or a prior PR externalId." };
1251
+ }
1252
+ // P1-4: Restrict the merge method to explicit values. "auto" and "queue"
1253
+ // would let GitHub choose the method, which could produce a merge commit
1254
+ // when the caller expected a rebase or squash.
1255
+ const method = params.method ?? "squash";
1256
+ if (method !== "merge" && method !== "rebase" && method !== "squash") {
1257
+ return {
1258
+ outcome: "failed",
1259
+ error: `merge: method ${method} is not allowed; use merge, rebase, or squash`,
1260
+ logs: ["merge: refusing to merge with an unrestricted method"]
1261
+ };
1262
+ }
1263
+ // Strip the "pr:" display prefix; the CLI expects a bare number.
1264
+ const prNumber = number.startsWith("pr:") ? number.slice(3) : number;
1265
+ // Only a canonical positive decimal PR number is accepted. Anything else
1266
+ // (flags, URLs, branch names, empty) would be passed to `gh pr merge` and
1267
+ // could inject admin flags or select the wrong PR.
1268
+ if (!/^[1-9][0-9]*$/.test(prNumber)) {
1269
+ return {
1270
+ outcome: "failed",
1271
+ error: `merge: PR number must be a positive integer: ${number}`,
1272
+ logs: ["merge: refusing to merge with an invalid PR selector"]
1273
+ };
1274
+ }
1275
+ const merged = await run("gh", [
1276
+ "pr", "merge", prNumber,
1277
+ "--repo", `${source.repository.owner}/${source.repository.name}`,
1278
+ "--match-head-commit", source.commit,
1279
+ `--${method}`,
1280
+ ...(params.admin === "true" ? ["--admin"] : [])
1281
+ ]);
1282
+ if (merged.code !== 0) {
1283
+ return { outcome: "failed", error: merged.stderr.trim() || "gh pr merge failed" };
1284
+ }
1285
+ return { outcome: "succeeded", externalId: `merge:${prNumber}`, logs: [`merge: PR #${prNumber}`] };
1286
+ }
1287
+ /**
1288
+ * A publish that failed after the upload (a transport error) is ambiguous:
1289
+ * the registry may have accepted the package. Such a failure carries an
1290
+ * externalIdentity so the engine queries the registry on resume instead of
1291
+ * re-publishing. A pre-upload failure (auth, bad tarball) is definite.
1292
+ */
1293
+ function isTransportFailure(stderr) {
1294
+ return /timeout|timed out|network|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|socket/i.test(stderr);
1295
+ }
1296
+ /** The scoped package name for a source repository: @owner/name. */
1297
+ function derivePackageName(source) {
1298
+ return `@${source.repository.owner}/${source.repository.name}`;
1299
+ }
1300
+ /**
1301
+ * The queryable identity for a succeeded effect whose idempotency record could
1302
+ * not be persisted. The engine re-queries by this identity on resume instead
1303
+ * of re-submitting the effect. Only kinds with a `queryStepEffect` case can
1304
+ * be re-queried; the rest return undefined and the caller fails closed
1305
+ * without an identity (the engine then treats the step as failed rather than
1306
+ * re-running an unrecorded success).
1307
+ */
1308
+ async function queryIdentityFor(step, params, effect, source, home, run) {
1309
+ switch (step.kind) {
1310
+ case "cli-update":
1311
+ // queryStepEffect verifies the installed version against step.params.
1312
+ // P1-2 (rr22): pin the resolved global prefix so a resume query checks
1313
+ // the same activation target instead of re-deriving it from the
1314
+ // caller's npm/PATH environment.
1315
+ return await controllerHomeIdentity(run, home);
1316
+ case "version-tag": {
1317
+ const tag = effect.externalId?.replace(/^tag:/, "") ?? params.tag;
1318
+ return tag === undefined || tag.length === 0
1319
+ ? undefined
1320
+ : { kind: "git-tag", value: tag };
1321
+ }
1322
+ case "npm-publish": {
1323
+ const version = effect.externalId;
1324
+ if (version === undefined || version.length === 0)
1325
+ return undefined;
1326
+ const pkg = params.package ?? derivePackageName(source);
1327
+ return { kind: "npm-package", value: `${pkg}@${version}` };
1328
+ }
1329
+ case "pr-create-or-reuse": {
1330
+ const pr = effect.externalId?.replace(/^pr:/, "");
1331
+ return pr === undefined || pr.length === 0
1332
+ ? undefined
1333
+ : { kind: "pull-request", value: pr };
1334
+ }
1335
+ default:
1336
+ return undefined;
1337
+ }
1338
+ }
1339
+ /**
1340
+ * Derive an authoritative query identity from the frozen step plan when no
1341
+ * identity was recorded (a hard exit between the external effect and the
1342
+ * idempotency/identity persistence). Only kinds whose identity is fully
1343
+ * determined by the immutable plan + source can be recovered this way.
1344
+ */
1345
+ function deriveIdentityFromPlan(step, source) {
1346
+ switch (step.kind) {
1347
+ case "version-tag": {
1348
+ const tag = step.params?.tag;
1349
+ if (tag === undefined || tag.length === 0)
1350
+ return undefined;
1351
+ return { kind: "git-tag", value: tag };
1352
+ }
1353
+ default:
1354
+ return undefined;
1355
+ }
1356
+ }
1357
+ const CANONICAL_REMOTE_HOST = "github.com";
1358
+ /**
1359
+ * Whether a git remote URL names the granted repository on the canonical host.
1360
+ * Accepts the common spellings (https://github.com/owner/name(.git),
1361
+ * ssh://git@github.com/owner/name(.git), git@github.com:owner/name(.git)) and
1362
+ * compares the host, owner, and name case-insensitively, matching GitHub's
1363
+ * repository identity. A URL on any other host is rejected even when its
1364
+ * owner/name path matches.
1365
+ */
1366
+ function remoteMatchesRepository(remoteUrl, repository) {
1367
+ const trimmed = remoteUrl.trim();
1368
+ if (trimmed.length === 0)
1369
+ return false;
1370
+ let rest = trimmed;
1371
+ let host;
1372
+ const schemeIndex = rest.indexOf("://");
1373
+ if (schemeIndex !== -1) {
1374
+ // scheme://[user@]host[:port]/path
1375
+ rest = rest.slice(schemeIndex + 3);
1376
+ const slashIndex = rest.indexOf("/");
1377
+ if (slashIndex === -1)
1378
+ return false;
1379
+ host = rest.slice(0, slashIndex);
1380
+ rest = rest.slice(slashIndex + 1);
1381
+ }
1382
+ else {
1383
+ // scp-like: [user@]host:path
1384
+ const colonIndex = rest.indexOf(":");
1385
+ if (colonIndex === -1)
1386
+ return false;
1387
+ host = rest.slice(0, colonIndex);
1388
+ rest = rest.slice(colonIndex + 1);
1389
+ }
1390
+ // Strip userinfo and port from the authority before comparing.
1391
+ const canonicalHost = host.split("@").pop().split(":")[0].trim().toLowerCase();
1392
+ if (canonicalHost !== CANONICAL_REMOTE_HOST)
1393
+ return false;
1394
+ rest = rest.replace(/\.git$/, "").replace(/\/+$/, "");
1395
+ const segments = rest.split("/").filter((segment) => segment.length > 0);
1396
+ if (segments.length < 2)
1397
+ return false;
1398
+ const owner = segments[segments.length - 2];
1399
+ const name = segments[segments.length - 1];
1400
+ return owner.toLowerCase() === repository.owner.toLowerCase()
1401
+ && name.toLowerCase() === repository.name.toLowerCase();
1402
+ }
1403
+ /**
1404
+ * Reads package/package.json out of an npm tarball via the injected command
1405
+ * runner. Returns undefined when the tarball cannot be read or the manifest
1406
+ * is not a package.json with a non-empty name and version, so the caller can
1407
+ * fail closed without publishing an unverified artifact.
1408
+ */
1409
+ async function readTarballManifest(run, tarball) {
1410
+ const extracted = await run("tar", ["-xOf", tarball, "package/package.json"]);
1411
+ if (extracted.code !== 0)
1412
+ return undefined;
1413
+ try {
1414
+ const manifest = JSON.parse(extracted.stdout);
1415
+ if (typeof manifest.name !== "string" || manifest.name.length === 0)
1416
+ return undefined;
1417
+ if (typeof manifest.version !== "string" || manifest.version.length === 0)
1418
+ return undefined;
1419
+ const publishConfig = isRecord(manifest.publishConfig)
1420
+ && typeof manifest.publishConfig.registry === "string"
1421
+ && manifest.publishConfig.registry.trim().length > 0
1422
+ ? { registry: manifest.publishConfig.registry.trim() }
1423
+ : undefined;
1424
+ return {
1425
+ name: manifest.name,
1426
+ version: manifest.version,
1427
+ ...(publishConfig === undefined ? {} : { publishConfig })
1428
+ };
1429
+ }
1430
+ catch {
1431
+ return undefined;
1432
+ }
1433
+ }
1434
+ /**
1435
+ * Parses the `npm publish` result line ("+ name@version") and returns the
1436
+ * published id. Takes the last such line (the publish summary); undefined
1437
+ * when the output carries no parseable confirmation.
1438
+ */
1439
+ function parsePublishConfirmation(stdout) {
1440
+ const lines = stdout.split(/\r?\n/);
1441
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1442
+ const line = lines[index].trim();
1443
+ if (!line.startsWith("+"))
1444
+ continue;
1445
+ const body = line.slice(1).trim();
1446
+ const at = body.lastIndexOf("@");
1447
+ if (at <= 0)
1448
+ continue;
1449
+ const name = body.slice(0, at);
1450
+ const version = body.slice(at + 1);
1451
+ if (name.length === 0 || version.length === 0 || /\s/.test(version))
1452
+ continue;
1453
+ return { name, version };
1454
+ }
1455
+ return undefined;
1456
+ }
1457
+ /**
1458
+ * Resolve the global npm prefix the same way the update port activates it
1459
+ * (`npm prefix --global` + `bin/yui`). Returns undefined when the prefix
1460
+ * cannot be resolved, callers must fail closed rather than persist a Home-only
1461
+ * identity that would be re-derived from a different environment.
1462
+ */
1463
+ async function resolveGlobalPrefix(run) {
1464
+ const result = await run("npm", ["prefix", "--global"]);
1465
+ if (result.code !== 0)
1466
+ return undefined;
1467
+ const prefix = result.stdout.trim();
1468
+ return prefix.length > 0 ? prefix : undefined;
1469
+ }
1470
+ /**
1471
+ * The controller-home query identity for a cli-update effect. The value is a
1472
+ * JSON envelope carrying the Home and — when resolvable — the exact global
1473
+ * prefix that was activated, so a resume query checks the same installation
1474
+ * instead of re-deriving the target from the caller's npm/PATH environment
1475
+ * (P1-2, rr22). Legacy identities are bare Home path strings; the query
1476
+ * accepts both shapes via {@link parseControllerHomeIdentity}.
1477
+ */
1478
+ async function controllerHomeIdentity(run, home) {
1479
+ const globalPrefix = await resolveGlobalPrefix(run);
1480
+ if (globalPrefix === undefined)
1481
+ return undefined;
1482
+ return {
1483
+ kind: "controller-home",
1484
+ value: JSON.stringify({
1485
+ home,
1486
+ globalPrefix
1487
+ })
1488
+ };
1489
+ }
1490
+ /**
1491
+ * The durable pre-effect record path for a cli-update attempt: the exact
1492
+ * activation target (Home + global npm prefix) keyed by the step's
1493
+ * idempotency key. Written before the irreversible effect and never deleted,
1494
+ * so a hard-exit recovery query can re-query the same installation (P1-2,
1495
+ * rr23).
1496
+ */
1497
+ function cliUpdateIdentityPath(home, idempotencyKey) {
1498
+ return join(home, "release", "cli-update-identity", `${idempotencyKey}.json`);
1499
+ }
1500
+ /**
1501
+ * Persist a cli-update's exact activation target BEFORE the irreversible
1502
+ * effect. A hard exit between this write and the engine's identity persistence
1503
+ * still leaves a durable receipt recovery can read. Best-effort by contract:
1504
+ * callers wrap this in try/catch and proceed with the update regardless.
1505
+ */
1506
+ async function persistCliUpdateIdentity(run, home, idempotencyKey) {
1507
+ const identity = await controllerHomeIdentity(run, home);
1508
+ if (identity === undefined)
1509
+ return;
1510
+ const target = cliUpdateIdentityPath(home, idempotencyKey);
1511
+ await mkdir(dirname(target), { recursive: true });
1512
+ // Write to a temp file in the same directory and rename: the rename is
1513
+ // atomic on the same filesystem, so a crash never leaves a torn record.
1514
+ const temp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
1515
+ await writeFile(temp, identity.value, { flag: "wx" });
1516
+ await rename(temp, target);
1517
+ }
1518
+ /**
1519
+ * Read the durable pre-effect activation target for a hard-exit cli-update
1520
+ * recovery query. Returns undefined when the record is absent or malformed;
1521
+ * the caller then fails closed rather than deriving the target from the resume
1522
+ * caller's environment (P1-2, rr23).
1523
+ */
1524
+ async function readPersistedCliUpdateIdentity(home, idempotencyKey) {
1525
+ let raw;
1526
+ try {
1527
+ raw = await readFile(cliUpdateIdentityPath(home, idempotencyKey), "utf8");
1528
+ }
1529
+ catch {
1530
+ return undefined;
1531
+ }
1532
+ const parsed = parseControllerHomeIdentity(raw);
1533
+ if (parsed === undefined || parsed.globalPrefix === undefined)
1534
+ return undefined;
1535
+ return { kind: "controller-home", value: JSON.stringify(parsed) };
1536
+ }
1537
+ /**
1538
+ * The durable pre-effect record path for an npm-publish attempt: the exact
1539
+ * npm executable and registry the publish ran against, keyed by the step's
1540
+ * idempotency key. Written before the irreversible effect and never deleted,
1541
+ * so a resume recovery query re-queries the same registry through the same
1542
+ * npm binary instead of trusting the resume caller's environment (P1, rr24).
1543
+ */
1544
+ function npmPublishTargetPath(home, idempotencyKey) {
1545
+ return join(home, "release", "npm-publish-target", `${idempotencyKey}.json`);
1546
+ }
1547
+ /**
1548
+ * Persist an npm-publish's exact executable and registry BEFORE the
1549
+ * irreversible effect. A hard exit between this write and the engine's
1550
+ * identity persistence still leaves a durable receipt recovery can read.
1551
+ */
1552
+ async function persistNpmPublishTarget(home, idempotencyKey, targetValue) {
1553
+ const target = npmPublishTargetPath(home, idempotencyKey);
1554
+ await mkdir(dirname(target), { recursive: true });
1555
+ // Write to a temp file in the same directory and rename: the rename is
1556
+ // atomic on the same filesystem, so a crash never leaves a torn record.
1557
+ const temp = `${target}.${randomBytes(8).toString("hex")}.tmp`;
1558
+ await writeFile(temp, JSON.stringify(targetValue), { flag: "wx" });
1559
+ await rename(temp, target);
1560
+ }
1561
+ /** Resolve the registry npm publish will use for the verified package. */
1562
+ async function resolveNpmPublishRegistry(run, packageName, publishConfigRegistry) {
1563
+ if (publishConfigRegistry !== undefined && publishConfigRegistry.trim().length > 0) {
1564
+ return publishConfigRegistry.trim();
1565
+ }
1566
+ const scope = packageName.startsWith("@")
1567
+ ? packageName.slice(0, packageName.indexOf("/"))
1568
+ : undefined;
1569
+ if (scope !== undefined) {
1570
+ const scoped = await run("npm", ["config", "get", `${scope}:registry`]);
1571
+ const scopedRegistry = normalizeNpmRegistry(scoped.stdout, scoped.code);
1572
+ if (scopedRegistry !== undefined)
1573
+ return scopedRegistry;
1574
+ }
1575
+ const defaultRegistry = await run("npm", ["config", "get", "registry"]);
1576
+ return normalizeNpmRegistry(defaultRegistry.stdout, defaultRegistry.code);
1577
+ }
1578
+ function normalizeNpmRegistry(stdout, code) {
1579
+ if (code !== 0)
1580
+ return undefined;
1581
+ const value = stdout.trim();
1582
+ return value.length === 0 || value === "undefined" ? undefined : value;
1583
+ }
1584
+ /**
1585
+ * Read the durable pre-effect npm executable and registry for an
1586
+ * npm-publish recovery query. Returns undefined when the record is absent
1587
+ * or malformed; the caller then fails closed rather than querying the
1588
+ * resume caller's npm/registry (P1, rr24).
1589
+ */
1590
+ async function readPersistedNpmPublishTarget(home, idempotencyKey) {
1591
+ let raw;
1592
+ try {
1593
+ raw = await readFile(npmPublishTargetPath(home, idempotencyKey), "utf8");
1594
+ }
1595
+ catch {
1596
+ return undefined;
1597
+ }
1598
+ try {
1599
+ const parsed = JSON.parse(raw);
1600
+ if (!isRecord(parsed)
1601
+ || typeof parsed.npmPath !== "string" || parsed.npmPath.length === 0
1602
+ || typeof parsed.registry !== "string" || parsed.registry.length === 0) {
1603
+ return undefined;
1604
+ }
1605
+ return { npmPath: parsed.npmPath, registry: parsed.registry };
1606
+ }
1607
+ catch {
1608
+ return undefined;
1609
+ }
1610
+ }
1611
+ /**
1612
+ * Parse a controller-home identity value. Accepts the JSON envelope
1613
+ * {home, globalPrefix?} written by {@link controllerHomeIdentity}; a legacy
1614
+ * bare Home path string (or any unparseable value) yields undefined so the
1615
+ * caller treats the raw value as the Home.
1616
+ */
1617
+ function parseControllerHomeIdentity(value) {
1618
+ try {
1619
+ const parsed = JSON.parse(value);
1620
+ if (!isRecord(parsed) || typeof parsed.home !== "string" || parsed.home.length === 0) {
1621
+ return undefined;
1622
+ }
1623
+ return {
1624
+ home: parsed.home,
1625
+ ...(typeof parsed.globalPrefix === "string" && parsed.globalPrefix.length > 0
1626
+ ? { globalPrefix: parsed.globalPrefix }
1627
+ : {})
1628
+ };
1629
+ }
1630
+ catch {
1631
+ return undefined;
1632
+ }
1633
+ }
1634
+ /**
1635
+ * Verify the replacement Controller's lifecycle identity for a controller-
1636
+ * home recovery query (P1-1, rr22/rr23). Binary health (`doctor`/`version`)
1637
+ * alone does not prove the Controller handoff completed: a hard exit during
1638
+ * the stop/activate/verify/start window can leave the global binary healthy
1639
+ * with no current Controller (or a wrong-version one). This requires a
1640
+ * `current` Controller resource for the exact Home and, when a version is
1641
+ * frozen, that the running Controller's authenticated identity matches the
1642
+ * activated artifact on ALL THREE production startup checks
1643
+ * ({@link assertActivatedControllerIdentity}): the Node.js executable path,
1644
+ * the exact Controller entrypoint derived from the pinned global binary, and
1645
+ * the package version. A same-version Controller launched from a foreign
1646
+ * installation — or a malformed identity — fails closed. Anything unprovable
1647
+ * returns "unknown" so the step is never confirmed.
1648
+ */
1649
+ async function queryControllerLifecycle(run, globalYui, home, expectedVersion) {
1650
+ const homeEnv = { YUI_HOME: home };
1651
+ const status = await run(process.execPath, [globalYui, "--json", "controller", "status"], undefined, homeEnv);
1652
+ if (status.code !== 0)
1653
+ return "unknown";
1654
+ let envelope;
1655
+ try {
1656
+ envelope = JSON.parse(status.stdout);
1657
+ }
1658
+ catch {
1659
+ return "unknown";
1660
+ }
1661
+ if (!isRecord(envelope) || envelope.ok !== true || !isRecord(envelope.data))
1662
+ return "unknown";
1663
+ const resources = envelope.data.resources;
1664
+ if (!Array.isArray(resources))
1665
+ return "unknown";
1666
+ const resolvedHome = resolve(home);
1667
+ const current = resources.find((resource) => (isRecord(resource)
1668
+ && resource.kind === "controller"
1669
+ && resource.state === "current"
1670
+ && typeof resource.yuiHome === "string"
1671
+ && resolve(resource.yuiHome) === resolvedHome));
1672
+ if (current === undefined)
1673
+ return "unknown";
1674
+ if (expectedVersion !== undefined) {
1675
+ const identity = await run(process.execPath, [globalYui, "--json", "controller", "identity"], undefined, homeEnv);
1676
+ if (identity.code !== 0)
1677
+ return "unknown";
1678
+ let identityEnvelope;
1679
+ try {
1680
+ identityEnvelope = JSON.parse(identity.stdout);
1681
+ }
1682
+ catch {
1683
+ return "unknown";
1684
+ }
1685
+ if (!isRecord(identityEnvelope) || identityEnvelope.ok !== true || !isRecord(identityEnvelope.data)) {
1686
+ return "unknown";
1687
+ }
1688
+ const data = identityEnvelope.data;
1689
+ if (data.version !== expectedVersion)
1690
+ return "unknown";
1691
+ if (typeof data.executablePath !== "string" || data.executablePath !== process.execPath) {
1692
+ return "unknown";
1693
+ }
1694
+ if (!Array.isArray(data.args)
1695
+ || data.args.length !== 1
1696
+ || typeof data.args[0] !== "string"
1697
+ || data.args[0] !== activatedControllerEntrypoint(globalYui)) {
1698
+ return "unknown";
1699
+ }
1700
+ }
1701
+ return "confirmed";
1702
+ }
1703
+ /**
1704
+ * Write the verified tarball bytes to a workflow-private temp file with a
1705
+ * random name and read-only permissions (P1-3, rr22). The manifest
1706
+ * inspection and `npm publish` both read this snapshot, so replacing the
1707
+ * original path after the integrity check cannot change what is published.
1708
+ * The caller removes the snapshot once publish completes.
1709
+ */
1710
+ async function writeVerifiedTarballSnapshot(bytes) {
1711
+ const snapshot = join(tmpdir(), `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
1712
+ // "wx" fails if the random name already exists, so a pre-existing file
1713
+ // (or symlink) can never be overwritten or followed.
1714
+ await writeFile(snapshot, bytes, { flag: "wx" });
1715
+ await chmod(snapshot, 0o400);
1716
+ return snapshot;
1717
+ }
1718
+ function isRecord(value) {
1719
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1720
+ }