@treeseed/sdk 0.6.9 → 0.6.10

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.
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, relative, resolve } from "node:path";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import {
5
5
  applyTreeseedEnvironmentToProcess,
@@ -23,7 +23,7 @@ import {
23
23
  setTreeseedRemoteSession,
24
24
  writeTreeseedMachineConfig
25
25
  } from "../operations/services/config-runtime.js";
26
- import { formatTreeseedDependencyFailureDetails, installTreeseedDependencies } from "../managed-dependencies.js";
26
+ import { collectTreeseedToolStatus, formatTreeseedDependencyFailureDetails, installTreeseedDependencies } from "../managed-dependencies.js";
27
27
  import { ControlPlaneClient } from "../control-plane-client.js";
28
28
  import { exportTreeseedCodebase } from "../operations/services/export-runtime.js";
29
29
  import {
@@ -46,6 +46,7 @@ import {
46
46
  assertFeatureBranch,
47
47
  branchExists,
48
48
  checkoutBranch,
49
+ checkoutDetachedOriginBranch,
49
50
  checkoutTaskBranchFromStaging,
50
51
  createDeprecatedTaskTag,
51
52
  deleteLocalBranch,
@@ -59,14 +60,13 @@ import {
59
60
  prepareReleaseBranches,
60
61
  PRODUCTION_BRANCH,
61
62
  pushBranch,
63
+ pushHeadToBranch,
62
64
  remoteBranchExists,
63
65
  STAGING_BRANCH,
64
66
  squashMergeBranchIntoStaging,
65
- syncBranchWithOrigin,
66
- waitForStagingAutomation
67
+ syncBranchWithOrigin
67
68
  } from "../operations/services/git-workflow.js";
68
69
  import { getGitHubAutomationMode, resolveGitHubRepositorySlug, waitForGitHubWorkflowCompletion } from "../operations/services/github-automation.js";
69
- import { createGitHubApiClient } from "../operations/services/github-api.js";
70
70
  import { loadCliDeployConfig, packageScriptPath, resolveWranglerBin } from "../operations/services/runtime-tools.js";
71
71
  import { runTenantDeployPreflight, runWorkspaceSavePreflight } from "../operations/services/save-deploy-preflight.js";
72
72
  import { collectCliPreflight } from "../operations/services/workspace-preflight.js";
@@ -111,8 +111,13 @@ import { resolveTreeseedWorkflowState } from "../workflow-state.js";
111
111
  import { createTreeseedReconcileRegistry, deriveTreeseedDesiredUnits, filterTreeseedDesiredUnitsByBootstrapSystems, planTreeseedReconciliation, resolveTreeseedBootstrapSelection, reconcileTreeseedTarget } from "../reconcile/index.js";
112
112
  import {
113
113
  acquireWorkflowLock,
114
+ archiveWorkflowRun,
115
+ cacheWorkflowGateResult,
116
+ classifyWorkflowRunJournal,
117
+ classifyWorkflowRunJournals,
114
118
  createWorkflowRunJournal,
115
119
  generateWorkflowRunId,
120
+ getCachedSuccessfulWorkflowGate,
116
121
  inspectWorkflowLock,
117
122
  listInterruptedWorkflowRuns,
118
123
  listWorkflowRunJournals,
@@ -129,6 +134,14 @@ import {
129
134
  classifyTreeseedBranchRole,
130
135
  resolveTreeseedWorkflowPaths
131
136
  } from "./policy.js";
137
+ import {
138
+ effectiveWorkflowWorktreeMode,
139
+ ensureManagedWorkflowWorktree,
140
+ isManagedWorkflowWorktree,
141
+ managedWorkflowWorktreeMetadata,
142
+ plannedManagedWorkflowWorktreePath,
143
+ removeManagedWorkflowWorktree
144
+ } from "./worktrees.js";
132
145
  class TreeseedWorkflowError extends Error {
133
146
  code;
134
147
  operation;
@@ -161,8 +174,64 @@ function ensureWorkflowWorkspaceLinks(root, helpers, mode = "auto") {
161
174
  if (report.created.length > 0) {
162
175
  helpers.write(`[workspace][link] Linked ${report.created.length} local workspace package paths.`);
163
176
  }
177
+ ensureWorkflowWorkspacePackageArtifacts(root, helpers);
178
+ ensureWorkflowCommandBins(root, helpers);
164
179
  return report;
165
180
  }
181
+ function readPackageScript(root, packageDir, scriptName) {
182
+ try {
183
+ const packageJson = JSON.parse(readFileSync(resolve(root, packageDir, "package.json"), "utf8"));
184
+ const scripts = packageJson.scripts && typeof packageJson.scripts === "object" && !Array.isArray(packageJson.scripts) ? packageJson.scripts : null;
185
+ const script = scripts?.[scriptName];
186
+ return typeof script === "string" && script.trim() ? script : null;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+ function ensureWorkflowWorkspacePackageArtifacts(root, helpers) {
192
+ const packages = [
193
+ { name: "@treeseed/sdk", dir: "packages/sdk", artifacts: ["dist/workflow-support.js", "dist/plugin-default.js", "dist/platform/env.yaml"] },
194
+ { name: "@treeseed/core", dir: "packages/core", artifacts: ["dist/api.js", "dist/plugin-default.js"] },
195
+ { name: "@treeseed/cli", dir: "packages/cli", artifacts: ["dist/cli/main.js"] }
196
+ ];
197
+ for (const entry of packages) {
198
+ const packageDir = resolve(root, entry.dir);
199
+ if (!existsSync(resolve(packageDir, "package.json"))) continue;
200
+ if (!readPackageScript(root, entry.dir, "build:dist")) continue;
201
+ const missing = entry.artifacts.filter((artifact) => !existsSync(resolve(packageDir, artifact)));
202
+ if (missing.length === 0) continue;
203
+ helpers.write(`[workspace][build] Building ${entry.name} artifacts for local workspace links.`);
204
+ run("npm", ["--prefix", packageDir, "run", "build:dist"], { cwd: root });
205
+ }
206
+ }
207
+ function ensureWorkflowCommandBins(root, helpers) {
208
+ const cliBin = resolve(root, "node_modules/@treeseed/cli/dist/cli/main.js");
209
+ if (!existsSync(cliBin)) return;
210
+ const binDir = resolve(root, "node_modules/.bin");
211
+ mkdirSync(binDir, { recursive: true });
212
+ for (const name of ["trsd", "treeseed"]) {
213
+ const linkPath = resolve(binDir, name);
214
+ const target = relative(dirname(linkPath), cliBin) || cliBin;
215
+ try {
216
+ const stat = lstatSync(linkPath);
217
+ if (stat.isSymbolicLink()) {
218
+ const currentTarget = readlinkSync(linkPath);
219
+ if (currentTarget === target || resolve(dirname(linkPath), currentTarget) === cliBin) {
220
+ continue;
221
+ }
222
+ rmSync(linkPath, { force: true });
223
+ } else {
224
+ continue;
225
+ }
226
+ } catch (error) {
227
+ if (error.code !== "ENOENT") {
228
+ throw error;
229
+ }
230
+ }
231
+ symlinkSync(target, linkPath);
232
+ helpers.write(`[workspace][link] Linked ${name} command shim.`);
233
+ }
234
+ }
166
235
  function unlinkWorkflowWorkspaceLinks(root, helpers, mode = "auto") {
167
236
  if (!shouldManageWorkspaceLinks(mode, helpers.context.env)) {
168
237
  return inspectWorkspaceDependencyMode(root, { mode: "off", env: helpers.context.env });
@@ -173,6 +242,163 @@ function unlinkWorkflowWorkspaceLinks(root, helpers, mode = "auto") {
173
242
  }
174
243
  return report;
175
244
  }
245
+ function normalizeCiMode(mode, operation) {
246
+ if (mode === "hosted" || mode === "off") return mode;
247
+ return operation === "save" ? "off" : "hosted";
248
+ }
249
+ function normalizeSaveVerifyMode(mode) {
250
+ switch (mode) {
251
+ case "skip":
252
+ case "fast":
253
+ case void 0:
254
+ return "skip";
255
+ case "local":
256
+ case "local-only":
257
+ return "local-only";
258
+ case "hosted":
259
+ return "skip";
260
+ case "both":
261
+ case "action-first":
262
+ return "action-first";
263
+ default:
264
+ return "skip";
265
+ }
266
+ }
267
+ function shouldUseHostedSaveCi(input) {
268
+ return normalizeCiMode(input.ciMode, "save") === "hosted" || input.verifyMode === "hosted" || input.verifyMode === "both";
269
+ }
270
+ function worktreePayload(root, requestedMode) {
271
+ const metadata = managedWorkflowWorktreeMetadata(root);
272
+ return {
273
+ worktreeMode: requestedMode ?? "auto",
274
+ managedWorktree: metadata,
275
+ worktreePath: metadata?.worktreePath ?? null,
276
+ primaryRoot: metadata?.primaryRoot ?? null
277
+ };
278
+ }
279
+ function helpersForCwd(helpers, cwd) {
280
+ return {
281
+ ...helpers,
282
+ context: {
283
+ ...helpers.context,
284
+ cwd
285
+ },
286
+ cwd: () => cwd
287
+ };
288
+ }
289
+ function shouldDispatchSwitchToManagedWorktree(root, input, env) {
290
+ return !isManagedWorkflowWorktree(root) && effectiveWorkflowWorktreeMode(input.worktreeMode, env) === "on";
291
+ }
292
+ function skippedWorkflowGate(gate, reason) {
293
+ return {
294
+ name: gate.name,
295
+ repository: gate.repository ?? null,
296
+ workflow: gate.workflow,
297
+ branch: gate.branch,
298
+ headSha: gate.headSha,
299
+ status: "skipped",
300
+ reason,
301
+ conclusion: null,
302
+ runId: null,
303
+ url: null
304
+ };
305
+ }
306
+ function workflowGateFailureMessage(gate, result) {
307
+ const repository = String(result.repository ?? gate.repository ?? gate.name);
308
+ const runId = typeof result.runId === "number" || typeof result.runId === "string" ? String(result.runId) : "";
309
+ const url = typeof result.url === "string" && result.url ? `
310
+ ${result.url}` : "";
311
+ const failedJobs = Array.isArray(result.failedJobs) ? result.failedJobs.map((job) => stringRecord(job)?.name).filter((name) => typeof name === "string" && name.length > 0) : [];
312
+ const jobLine = failedJobs.length > 0 ? `
313
+ Failed jobs: ${failedJobs.join(", ")}` : "";
314
+ const command = runId ? `
315
+ Inspect with: gh run view ${runId} --repo ${repository} --log-failed` : "";
316
+ return `${gate.name} ${gate.workflow} completed with conclusion ${String(result.conclusion ?? "unknown")} in ${repository}.${url}${jobLine}${command}`;
317
+ }
318
+ function assertHostedGitHubWorkflowAuthReady(operation, root) {
319
+ if (getGitHubAutomationMode() === "stub") {
320
+ return null;
321
+ }
322
+ const tools = collectTreeseedToolStatus({
323
+ tenantRoot: root,
324
+ env: process.env
325
+ });
326
+ const github = tools.auth.github;
327
+ if (!github.authenticated) {
328
+ const command = github.command.length > 0 ? github.command.join(" ") : "npx trsd tools --json";
329
+ workflowError(
330
+ operation,
331
+ "github_auth_unavailable",
332
+ [
333
+ "Treeseed hosted GitHub workflow gates require an authenticated managed GitHub CLI.",
334
+ github.detail,
335
+ `Managed gh check: ${command}`,
336
+ "Remediation:",
337
+ ...github.remediation.map((item) => `- ${item}`)
338
+ ].join("\n"),
339
+ {
340
+ details: {
341
+ toolsHome: tools.toolsHome,
342
+ ghConfigDir: tools.ghConfigDir,
343
+ github,
344
+ tools: tools.tools
345
+ }
346
+ }
347
+ );
348
+ }
349
+ return tools;
350
+ }
351
+ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
352
+ if (ciMode === "off" || process.env.TREESEED_STAGE_WAIT_MODE === "skip") {
353
+ return gates.map((gate) => skippedWorkflowGate(gate, ciMode === "off" ? "disabled" : "stubbed"));
354
+ }
355
+ if (gates.length === 0) {
356
+ return [];
357
+ }
358
+ assertHostedGitHubWorkflowAuthReady(operation, options.root ?? gates[0].repoPath);
359
+ const results = [];
360
+ for (const gate of gates) {
361
+ if (options.root && options.runId) {
362
+ const cached = getCachedSuccessfulWorkflowGate(options.root, options.runId, {
363
+ repository: gate.repository ?? null,
364
+ workflow: gate.workflow,
365
+ headSha: gate.headSha,
366
+ branch: gate.branch
367
+ });
368
+ if (cached) {
369
+ results.push({
370
+ ...cached.result,
371
+ name: gate.name,
372
+ cached: true
373
+ });
374
+ continue;
375
+ }
376
+ }
377
+ const result = await waitForGitHubWorkflowCompletion(gate.repoPath, {
378
+ repository: gate.repository,
379
+ workflow: gate.workflow,
380
+ headSha: gate.headSha,
381
+ branch: gate.branch
382
+ });
383
+ const normalized = {
384
+ name: gate.name,
385
+ ...result,
386
+ workflow: String(result.workflow ?? gate.workflow),
387
+ branch: String(result.branch ?? gate.branch),
388
+ headSha: String(result.headSha ?? gate.headSha)
389
+ };
390
+ if (normalized.status === "completed" && normalized.conclusion !== "success") {
391
+ workflowError(operation, "github_workflow_failed", workflowGateFailureMessage(gate, normalized), {
392
+ details: { gate, workflow: normalized }
393
+ });
394
+ }
395
+ if (options.root && options.runId && normalized.status === "completed" && normalized.conclusion === "success") {
396
+ cacheWorkflowGateResult(options.root, options.runId, normalized);
397
+ }
398
+ results.push(normalized);
399
+ }
400
+ return results;
401
+ }
176
402
  function ensureTreeseedCommandReadiness(root) {
177
403
  if (getGitHubAutomationMode() === "stub") {
178
404
  return {
@@ -295,7 +521,8 @@ function createRepoReport(name, path, branch, dirty) {
295
521
  tagName: null,
296
522
  commitSha: branch ? headCommit(path) : null,
297
523
  skippedReason: null,
298
- publishWait: null
524
+ publishWait: null,
525
+ workflowGates: []
299
526
  };
300
527
  }
301
528
  function createWorkspaceRootRepoReport(root) {
@@ -712,6 +939,10 @@ function findAutoResumableSaveRun(root, branch) {
712
939
  if (!branch) return null;
713
940
  return listInterruptedWorkflowRuns(root).find((journal) => journal.command === "save" && journal.resumable && journal.session.branchName === branch) ?? null;
714
941
  }
942
+ function findAutoResumableTaskRun(root, command, branch) {
943
+ if (!branch) return null;
944
+ return listInterruptedWorkflowRuns(root).find((journal) => journal.command === command && journal.resumable && journal.session.branchName === branch) ?? null;
945
+ }
715
946
  function stringRecord(value) {
716
947
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
717
948
  }
@@ -1033,7 +1264,7 @@ function assertReleaseGitHubAutomationReady(root, selectedPackageNames) {
1033
1264
  if (process.env.TREESEED_GITHUB_AUTOMATION_MODE === "stub") {
1034
1265
  return;
1035
1266
  }
1036
- createGitHubApiClient();
1267
+ assertHostedGitHubWorkflowAuthReady("release", root);
1037
1268
  for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1038
1269
  if (!selectedPackageNames.has(pkg.name)) continue;
1039
1270
  resolveGitHubRepositorySlug(pkg.dir);
@@ -1101,6 +1332,14 @@ function previewStateFor(tenantRoot, branchName) {
1101
1332
  target: createBranchPreviewDeployTarget(branchName)
1102
1333
  });
1103
1334
  }
1335
+ function branchPreviewInitialized(tenantRoot, branchName) {
1336
+ if (!branchName) return false;
1337
+ try {
1338
+ return previewStateFor(tenantRoot, branchName).readiness?.initialized === true;
1339
+ } catch {
1340
+ return false;
1341
+ }
1342
+ }
1104
1343
  async function deployBranchPreview(tenantRoot, branchName, context, { initialize }) {
1105
1344
  applyTreeseedEnvironmentToProcess({ tenantRoot, scope: "staging", override: true });
1106
1345
  assertTreeseedCommandEnvironment({ tenantRoot, scope: "staging", purpose: "deploy" });
@@ -1710,7 +1949,10 @@ async function workflowExport(helpers, input = {}) {
1710
1949
  return await withContextEnv(helpers.context.env, async () => {
1711
1950
  const directory = resolve(helpers.context.cwd ?? helpers.cwd(), input.directory ?? ".");
1712
1951
  const exported = await exportTreeseedCodebase({ directory });
1713
- return buildWorkflowResult("export", exported.tenantRoot, exported);
1952
+ return buildWorkflowResult("export", exported.tenantRoot, {
1953
+ ...exported,
1954
+ ...worktreePayload(exported.tenantRoot, input.worktreeMode)
1955
+ });
1714
1956
  });
1715
1957
  }
1716
1958
  async function workflowSwitch(helpers, input) {
@@ -1725,6 +1967,27 @@ async function workflowSwitch(helpers, input) {
1725
1967
  }
1726
1968
  const preview = input.preview === true;
1727
1969
  const executionMode = normalizeExecutionMode(input);
1970
+ if (executionMode !== "plan" && shouldDispatchSwitchToManagedWorktree(root, input, helpers.context.env)) {
1971
+ const managed = ensureManagedWorkflowWorktree({
1972
+ root,
1973
+ branchName,
1974
+ mode: input.worktreeMode,
1975
+ env: helpers.context.env
1976
+ });
1977
+ const result = await workflowSwitch(helpersForCwd(helpers, managed.worktreePath), {
1978
+ ...input,
1979
+ worktreeMode: "off"
1980
+ });
1981
+ return {
1982
+ ...result,
1983
+ payload: {
1984
+ ...result.payload,
1985
+ worktreeMode: input.worktreeMode ?? "auto",
1986
+ worktreePath: managed.worktreePath,
1987
+ managedWorktree: managed
1988
+ }
1989
+ };
1990
+ }
1728
1991
  const mode = session.mode;
1729
1992
  const repoDir = session.gitRoot;
1730
1993
  const rootRepo = createWorkspaceRootRepoReport(root);
@@ -1747,6 +2010,8 @@ async function workflowSwitch(helpers, input) {
1747
2010
  rootRepo,
1748
2011
  repos: packageReports,
1749
2012
  previewRequested: preview,
2013
+ worktreeMode: input.worktreeMode ?? "auto",
2014
+ worktreePath: effectiveWorkflowWorktreeMode(input.worktreeMode, helpers.context.env) === "on" ? plannedManagedWorkflowWorktreePath(root, branchName) : null,
1750
2015
  blockers: dirtyRepos.length > 0 ? [`Clean worktrees required: ${dirtyRepos.join(", ")}`] : [],
1751
2016
  plannedSteps: [
1752
2017
  { id: "switch-root", description: `Switch market repo to ${branchName}` },
@@ -1772,7 +2037,7 @@ async function workflowSwitch(helpers, input) {
1772
2037
  const workflowRun = acquireWorkflowRun(
1773
2038
  "switch",
1774
2039
  session,
1775
- { branch: branchName, preview },
2040
+ { branch: branchName, preview, worktreeMode: input.worktreeMode ?? "auto" },
1776
2041
  [
1777
2042
  { id: "switch-root", description: `Switch market repo to ${branchName}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: branchName, resumable: true },
1778
2043
  ...packageReports.map((report) => ({
@@ -1849,6 +2114,7 @@ async function workflowSwitch(helpers, input) {
1849
2114
  },
1850
2115
  previewResult,
1851
2116
  workspaceLinks,
2117
+ ...worktreePayload(root, input.worktreeMode),
1852
2118
  preconditions: {
1853
2119
  cleanWorktreeRequired: true,
1854
2120
  baseBranch: STAGING_BRANCH
@@ -1975,6 +2241,7 @@ async function workflowSave(helpers, input) {
1975
2241
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
1976
2242
  const message = String(effectiveInput.message ?? "").trim();
1977
2243
  const optionsHotfix = effectiveInput.hotfix === true;
2244
+ const previewInitialized = branchPreviewInitialized(root, branch);
1978
2245
  applyTreeseedEnvironmentToProcess({ tenantRoot: root, scope, override: true });
1979
2246
  if (!branch) {
1980
2247
  workflowError("save", "validation_failed", "Treeseed save requires an active git branch.");
@@ -2002,7 +2269,7 @@ async function workflowSave(helpers, input) {
2002
2269
  devDependencyReferenceMode: effectiveInput.devDependencyReferenceMode ?? "git-tag",
2003
2270
  gitDependencyProtocol: effectiveInput.gitDependencyProtocol ?? "preserve-origin",
2004
2271
  gitRemoteWriteMode: effectiveInput.gitRemoteWriteMode ?? "ssh-pushurl",
2005
- verifyMode: effectiveInput.verifyMode ?? (effectiveInput.verify === false ? "skip" : "action-first"),
2272
+ verifyMode: normalizeSaveVerifyMode(effectiveInput.verify === false ? "skip" : effectiveInput.verifyMode),
2006
2273
  commitMessageMode: effectiveInput.commitMessageMode ?? "auto"
2007
2274
  });
2008
2275
  const workspaceLinks = inspectWorkspaceDependencyMode(root, { mode: effectiveInput.workspaceLinks ?? "auto", env: helpers.context.env });
@@ -2024,6 +2291,9 @@ async function workflowSave(helpers, input) {
2024
2291
  failure: planAutoResumeRun.failure
2025
2292
  } : null,
2026
2293
  workspaceLinks,
2294
+ ciMode: normalizeCiMode(effectiveInput.ciMode, "save"),
2295
+ verifyMode: effectiveInput.verifyMode ?? "fast",
2296
+ ...worktreePayload(root, effectiveInput.worktreeMode),
2027
2297
  repositoryPlan,
2028
2298
  waves: repositoryPlan.waves,
2029
2299
  plannedVersions: repositoryPlan.plannedVersions,
@@ -2032,7 +2302,7 @@ async function workflowSave(helpers, input) {
2032
2302
  ...repositoryPlan.plannedSteps,
2033
2303
  { id: "lockfile-validation", description: "Validate refreshed package-lock.json files before any save commit is pushed" },
2034
2304
  { id: "workspace-link", description: "Restore local workspace links after save" },
2035
- ...beforeState.branchRole === "feature" && (effectiveInput.preview === true || beforeState.preview.enabled) ? [{ id: "preview", description: `Refresh preview deployment for ${branch}` }] : []
2305
+ ...beforeState.branchRole === "feature" && (effectiveInput.preview === true || previewInitialized) ? [{ id: "preview", description: `Refresh preview deployment for ${branch}` }] : []
2036
2306
  ]
2037
2307
  },
2038
2308
  {
@@ -2065,7 +2335,9 @@ async function workflowSave(helpers, input) {
2065
2335
  devDependencyReferenceMode: effectiveInput.devDependencyReferenceMode ?? "git-tag",
2066
2336
  gitDependencyProtocol: effectiveInput.gitDependencyProtocol ?? "preserve-origin",
2067
2337
  gitRemoteWriteMode: effectiveInput.gitRemoteWriteMode ?? "ssh-pushurl",
2068
- verifyMode: effectiveInput.verifyMode ?? (effectiveInput.verify === false ? "skip" : "action-first"),
2338
+ verifyMode: effectiveInput.verifyMode ?? (effectiveInput.verify === false ? "skip" : "fast"),
2339
+ ciMode: effectiveInput.ciMode ?? "auto",
2340
+ worktreeMode: effectiveInput.worktreeMode ?? "auto",
2069
2341
  commitMessageMode: effectiveInput.commitMessageMode ?? "auto",
2070
2342
  workspaceLinks: effectiveInput.workspaceLinks ?? "auto"
2071
2343
  },
@@ -2078,7 +2350,15 @@ async function workflowSave(helpers, input) {
2078
2350
  branch,
2079
2351
  resumable: true
2080
2352
  },
2081
- ...beforeState.branchRole === "feature" && (effectiveInput.preview === true || effectiveInput.refreshPreview !== false && beforeState.preview.enabled) ? [{
2353
+ ...shouldUseHostedSaveCi(effectiveInput) ? [{
2354
+ id: "hosted-ci",
2355
+ description: `Wait for hosted save workflows on ${branch}`,
2356
+ repoName: rootRepo.name,
2357
+ repoPath: rootRepo.path,
2358
+ branch,
2359
+ resumable: true
2360
+ }] : [],
2361
+ ...beforeState.branchRole === "feature" && (effectiveInput.preview === true || effectiveInput.refreshPreview !== false && previewInitialized) ? [{
2082
2362
  id: "preview",
2083
2363
  description: `Refresh preview ${branch}`,
2084
2364
  repoName: rootRepo.name,
@@ -2112,7 +2392,7 @@ async function workflowSave(helpers, input) {
2112
2392
  devDependencyReferenceMode: effectiveInput.devDependencyReferenceMode ?? "git-tag",
2113
2393
  gitDependencyProtocol: effectiveInput.gitDependencyProtocol ?? "preserve-origin",
2114
2394
  gitRemoteWriteMode: effectiveInput.gitRemoteWriteMode ?? "ssh-pushurl",
2115
- verifyMode: effectiveInput.verifyMode ?? (effectiveInput.verify === false ? "skip" : "action-first"),
2395
+ verifyMode: normalizeSaveVerifyMode(effectiveInput.verify === false ? "skip" : effectiveInput.verifyMode),
2116
2396
  commitMessageMode: effectiveInput.commitMessageMode ?? "auto",
2117
2397
  workflowRunId: workflowRun.runId,
2118
2398
  onProgress: (line, stream) => helpers.write(line, stream)
@@ -2139,14 +2419,30 @@ async function workflowSave(helpers, input) {
2139
2419
  lockfileValidation: repo.lockfileValidation
2140
2420
  }))
2141
2421
  };
2422
+ const saveWorkflowGates = shouldUseHostedSaveCi(effectiveInput) ? await executeJournalStep(root, workflowRun.runId, "hosted-ci", () => waitForWorkflowGates("save", [
2423
+ ...savedRootRepo.pushed && savedRootRepo.commitSha && branch ? [{
2424
+ name: savedRootRepo.name,
2425
+ repoPath: savedRootRepo.path,
2426
+ workflow: "verify.yml",
2427
+ branch,
2428
+ headSha: savedRootRepo.commitSha
2429
+ }] : [],
2430
+ ...savedPackageReports.filter((repo) => repo.pushed && repo.commitSha && repo.branch).map((repo) => ({
2431
+ name: repo.name,
2432
+ repoPath: repo.path,
2433
+ workflow: "verify.yml",
2434
+ branch: String(repo.branch),
2435
+ headSha: String(repo.commitSha)
2436
+ }))
2437
+ ], "hosted", { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates }))) : { workflowGates: [] };
2142
2438
  let previewAction = { status: "skipped" };
2143
2439
  if (beforeState.branchRole === "feature" && branch) {
2144
2440
  if (effectiveInput.preview === true) {
2145
2441
  previewAction = {
2146
- status: beforeState.preview.enabled ? "refreshed" : "created",
2147
- details: await executeJournalStep(root, workflowRun.runId, "preview", () => deployBranchPreview(root, branch, helpers.context, { initialize: !beforeState.preview.enabled }))
2442
+ status: previewInitialized ? "refreshed" : "created",
2443
+ details: await executeJournalStep(root, workflowRun.runId, "preview", () => deployBranchPreview(root, branch, helpers.context, { initialize: !previewInitialized }))
2148
2444
  };
2149
- } else if (effectiveInput.refreshPreview !== false && beforeState.preview.enabled) {
2445
+ } else if (effectiveInput.refreshPreview !== false && previewInitialized) {
2150
2446
  previewAction = {
2151
2447
  status: "refreshed",
2152
2448
  details: await executeJournalStep(root, workflowRun.runId, "preview", () => deployBranchPreview(root, branch, helpers.context, { initialize: false }))
@@ -2175,7 +2471,11 @@ async function workflowSave(helpers, input) {
2175
2471
  mergeConflict: null,
2176
2472
  workspaceLinks,
2177
2473
  commandReadiness,
2178
- lockfileValidation
2474
+ lockfileValidation,
2475
+ ciMode: normalizeCiMode(effectiveInput.ciMode, "save"),
2476
+ verifyMode: effectiveInput.verifyMode ?? "fast",
2477
+ workflowGates: saveWorkflowGates?.workflowGates ?? [],
2478
+ ...worktreePayload(root, effectiveInput.worktreeMode)
2179
2479
  };
2180
2480
  completeWorkflowRun(root, workflowRun.runId, payload);
2181
2481
  return buildWorkflowResult(
@@ -2232,9 +2532,13 @@ async function workflowClose(helpers, input) {
2232
2532
  return await withContextEnv(helpers.context.env, async () => {
2233
2533
  const tenantRoot = resolveProjectRootOrThrow("close", helpers.cwd());
2234
2534
  const root = workspaceRoot(tenantRoot);
2235
- const message = ensureMessage("close", input.message, "a close reason");
2236
2535
  const executionMode = normalizeExecutionMode(input);
2237
2536
  const session = resolveTreeseedWorkflowSession(root);
2537
+ const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
2538
+ const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableTaskRun(root, "close", session.branchName) : null;
2539
+ const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "close", session.branchName) : null;
2540
+ const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
2541
+ const message = ensureMessage("close", effectiveInput.message, "a close reason");
2238
2542
  if (executionMode === "plan") {
2239
2543
  const branchName = session.branchName;
2240
2544
  const blockers = session.branchRole !== "feature" ? ["Close only applies to task branches."] : [];
@@ -2245,18 +2549,26 @@ async function workflowClose(helpers, input) {
2245
2549
  mode: session.mode,
2246
2550
  branchName,
2247
2551
  message,
2552
+ autoResumeCandidate: planAutoResumeRun ? {
2553
+ runId: planAutoResumeRun.runId,
2554
+ branch: planAutoResumeRun.session.branchName,
2555
+ failure: planAutoResumeRun.failure
2556
+ } : null,
2557
+ ...worktreePayload(root, effectiveInput.worktreeMode),
2248
2558
  autoSaveRequired: session.rootRepo.dirty || session.packageRepos.some((repo) => repo.dirty),
2249
2559
  repos: createWorkspacePackageReports(root),
2250
2560
  rootRepo: createWorkspaceRootRepoReport(root),
2251
2561
  blockers,
2252
2562
  plannedSteps: [
2563
+ { id: "workspace-unlink", description: "Remove local workspace links before task cleanup" },
2253
2564
  { id: "preview-cleanup", description: `Destroy preview resources for ${branchName ?? "(current task)"}` },
2254
2565
  { id: "cleanup-root", description: `Archive and delete ${branchName ?? "(current task)"} in market` },
2255
2566
  ...checkedOutWorkspacePackageRepos(root).map((pkg) => ({
2256
2567
  id: `cleanup-${pkg.name}`,
2257
2568
  description: `Archive and delete ${branchName ?? "(current task)"} in ${pkg.name}`
2258
2569
  })),
2259
- { id: "workspace-link", description: "Restore local workspace links on the final branch" }
2570
+ { id: "workspace-link", description: "Restore local workspace links on the final branch" },
2571
+ ...isManagedWorkflowWorktree(root) ? [{ id: "worktree-cleanup", description: "Remove managed workflow worktree" }] : []
2260
2572
  ]
2261
2573
  },
2262
2574
  {
@@ -2267,12 +2579,10 @@ async function workflowClose(helpers, input) {
2267
2579
  }
2268
2580
  );
2269
2581
  }
2270
- unlinkWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2271
2582
  const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "close", {
2272
2583
  message,
2273
- autoSave: input.autoSave
2584
+ autoSave: effectiveInput.autoSave
2274
2585
  });
2275
- unlinkWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2276
2586
  const activeSession = resolveTreeseedWorkflowSession(root);
2277
2587
  const featureBranch = assertFeatureBranch(root);
2278
2588
  const mode = activeSession.mode;
@@ -2288,8 +2598,14 @@ async function workflowClose(helpers, input) {
2288
2598
  const workflowRun = acquireWorkflowRun(
2289
2599
  "close",
2290
2600
  activeSession,
2291
- { message, deletePreview: input.deletePreview !== false, deleteBranch: input.deleteBranch !== false },
2601
+ {
2602
+ message,
2603
+ deletePreview: effectiveInput.deletePreview !== false,
2604
+ deleteBranch: effectiveInput.deleteBranch !== false,
2605
+ worktreeMode: effectiveInput.worktreeMode ?? "auto"
2606
+ },
2292
2607
  [
2608
+ { id: "workspace-unlink", description: "Remove local workspace links", repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2293
2609
  { id: "preview-cleanup", description: `Destroy preview resources for ${featureBranch}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2294
2610
  { id: "cleanup-root", description: `Archive ${featureBranch} in market`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2295
2611
  ...packageReports.map((report) => ({
@@ -2299,23 +2615,35 @@ async function workflowClose(helpers, input) {
2299
2615
  repoPath: report.path,
2300
2616
  branch: featureBranch,
2301
2617
  resumable: true
2302
- }))
2618
+ })),
2619
+ { id: "workspace-link", description: "Restore local workspace links", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
2620
+ ...isManagedWorkflowWorktree(root) ? [{ id: "worktree-cleanup", description: "Remove managed workflow worktree", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: false }] : []
2303
2621
  ],
2304
- helpers.context
2622
+ autoResumeRun ? {
2623
+ ...helpers.context,
2624
+ workflow: {
2625
+ ...helpers.context.workflow ?? {},
2626
+ resumeRunId: autoResumeRun.runId
2627
+ }
2628
+ } : helpers.context
2305
2629
  );
2630
+ if (autoResumeRun) {
2631
+ helpers.write(`[workflow][resume] Resuming interrupted close ${autoResumeRun.runId} on ${featureBranch}.`);
2632
+ }
2306
2633
  try {
2307
- const previewCleanup = input.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
2634
+ await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"), { rerunCompleted: true });
2635
+ const previewCleanup = effectiveInput.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
2308
2636
  const rootCleanup = await executeJournalStep(root, workflowRun.runId, "cleanup-root", () => {
2309
2637
  const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `close: ${message}`);
2310
- const deletedRemote = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
2638
+ const deletedRemote = effectiveInput.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
2311
2639
  syncBranchWithOrigin(repoDir, STAGING_BRANCH);
2312
- if (input.deleteBranch !== false) {
2640
+ if (effectiveInput.deleteBranch !== false) {
2313
2641
  deleteLocalBranch(repoDir, featureBranch);
2314
2642
  }
2315
2643
  return {
2316
2644
  deprecatedTag,
2317
2645
  deletedRemote,
2318
- deletedLocal: input.deleteBranch !== false,
2646
+ deletedLocal: effectiveInput.deleteBranch !== false,
2319
2647
  branch: currentBranch(repoDir) || STAGING_BRANCH,
2320
2648
  dirty: hasMeaningfulChanges(repoDir)
2321
2649
  };
@@ -2332,12 +2660,15 @@ async function workflowClose(helpers, input) {
2332
2660
  continue;
2333
2661
  }
2334
2662
  const cleanup = await executeJournalStep(root, workflowRun.runId, `cleanup-${report.name}`, () => cleanupTaskBranchReport(report, featureBranch, `close: ${message}`, {
2335
- deleteBranch: input.deleteBranch !== false,
2663
+ deleteBranch: effectiveInput.deleteBranch !== false,
2336
2664
  targetBranch: STAGING_BRANCH
2337
2665
  }));
2338
2666
  Object.assign(report, cleanup);
2339
2667
  }
2340
- const workspaceLinks = ensureWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2668
+ const workspaceLinks = await executeJournalStep(root, workflowRun.runId, "workspace-link", () => ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
2669
+ const finalBranch = currentBranch(repoDir) || STAGING_BRANCH;
2670
+ const managedWorktree = managedWorkflowWorktreeMetadata(root);
2671
+ const worktreeCleanup = isManagedWorkflowWorktree(root) ? await executeJournalStep(root, workflowRun.runId, "worktree-cleanup", () => removeManagedWorkflowWorktree(root)) : { removed: false, reason: "not-managed" };
2341
2672
  const payload = {
2342
2673
  mode,
2343
2674
  branchName: featureBranch,
@@ -2350,8 +2681,13 @@ async function workflowClose(helpers, input) {
2350
2681
  previewCleanup,
2351
2682
  remoteDeleted: rootRepo.deletedRemote,
2352
2683
  localDeleted: rootRepo.deletedLocal,
2353
- finalBranch: currentBranch(repoDir) || STAGING_BRANCH,
2354
- workspaceLinks
2684
+ finalBranch,
2685
+ workspaceLinks,
2686
+ worktreeCleanup,
2687
+ worktreeMode: effectiveInput.worktreeMode ?? "auto",
2688
+ managedWorktree,
2689
+ worktreePath: managedWorktree?.worktreePath ?? null,
2690
+ primaryRoot: managedWorktree?.primaryRoot ?? null
2355
2691
  };
2356
2692
  completeWorkflowRun(root, workflowRun.runId, payload);
2357
2693
  return buildWorkflowResult(
@@ -2366,7 +2702,7 @@ async function workflowClose(helpers, input) {
2366
2702
  }
2367
2703
  );
2368
2704
  } catch (error) {
2369
- ensureWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2705
+ ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
2370
2706
  failWorkflowRun(root, workflowRun.runId, error, {
2371
2707
  resumable: true,
2372
2708
  runId: workflowRun.runId,
@@ -2387,11 +2723,19 @@ async function workflowStage(helpers, input) {
2387
2723
  return await withContextEnv(helpers.context.env, async () => {
2388
2724
  const tenantRoot = resolveProjectRootOrThrow("stage", helpers.cwd());
2389
2725
  const root = workspaceRoot(tenantRoot);
2390
- const message = ensureMessage("stage", input.message, "a resolution message");
2391
2726
  const executionMode = normalizeExecutionMode(input);
2392
2727
  const initialSession = resolveTreeseedWorkflowSession(root);
2728
+ const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
2729
+ const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableTaskRun(root, "stage", initialSession.branchName) : null;
2730
+ const planAutoResumeRun = executionMode === "plan" ? findAutoResumableTaskRun(root, "stage", initialSession.branchName) : null;
2731
+ const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
2732
+ const message = ensureMessage("stage", effectiveInput.message, "a resolution message");
2733
+ const ciMode = normalizeCiMode(effectiveInput.ciMode, "stage");
2393
2734
  if (executionMode === "plan") {
2394
2735
  const blockers = [];
2736
+ if (initialSession.branchRole !== "feature") {
2737
+ blockers.push("Stage only applies to task branches.");
2738
+ }
2395
2739
  try {
2396
2740
  validateStagingWorkflowContracts(root);
2397
2741
  } catch (error) {
@@ -2406,6 +2750,13 @@ async function workflowStage(helpers, input) {
2406
2750
  mergeTarget: STAGING_BRANCH,
2407
2751
  mergeStrategy: "squash",
2408
2752
  message,
2753
+ ciMode,
2754
+ autoResumeCandidate: planAutoResumeRun ? {
2755
+ runId: planAutoResumeRun.runId,
2756
+ branch: planAutoResumeRun.session.branchName,
2757
+ failure: planAutoResumeRun.failure
2758
+ } : null,
2759
+ ...worktreePayload(root, effectiveInput.worktreeMode),
2409
2760
  autoSaveRequired: initialSession.rootRepo.dirty || initialSession.packageRepos.some((repo) => repo.dirty),
2410
2761
  blockers,
2411
2762
  rootRepo: createWorkspaceRootRepoReport(root),
@@ -2418,7 +2769,7 @@ async function workflowStage(helpers, input) {
2418
2769
  { id: "workspace-unlink", description: "Remove local workspace links before staging promotion" },
2419
2770
  { id: "merge-root", description: `Squash-merge ${initialSession.branchName ?? "(current task)"} into market staging` },
2420
2771
  { id: "lockfile-validation", description: "Refresh and validate the merged root workspace lockfile before pushing staging" },
2421
- { id: "wait-staging", description: "Wait for staging automation" },
2772
+ { id: "wait-staging", description: "Wait for exact-SHA staging GitHub Actions gates" },
2422
2773
  { id: "preview-cleanup", description: "Destroy preview resources" },
2423
2774
  { id: "cleanup-root", description: "Archive and delete the task branch from market" },
2424
2775
  ...checkedOutWorkspacePackageRepos(root).map((pkg) => ({
@@ -2436,12 +2787,10 @@ async function workflowStage(helpers, input) {
2436
2787
  }
2437
2788
  );
2438
2789
  }
2439
- unlinkWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2440
2790
  const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "stage", {
2441
2791
  message,
2442
- autoSave: input.autoSave
2792
+ autoSave: effectiveInput.autoSave
2443
2793
  });
2444
- unlinkWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2445
2794
  const session = resolveTreeseedWorkflowSession(root);
2446
2795
  const featureBranch = assertFeatureBranch(root);
2447
2796
  const mode = session.mode;
@@ -2451,6 +2800,8 @@ async function workflowStage(helpers, input) {
2451
2800
  } else {
2452
2801
  assertCleanWorktree(root);
2453
2802
  }
2803
+ ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
2804
+ applyTreeseedEnvironmentToProcess({ tenantRoot: root, scope: "staging", override: true });
2454
2805
  validateStagingWorkflowContracts(root);
2455
2806
  runWorkspaceSavePreflight({ cwd: root });
2456
2807
  const repoDir = session.gitRoot;
@@ -2459,8 +2810,16 @@ async function workflowStage(helpers, input) {
2459
2810
  const workflowRun = acquireWorkflowRun(
2460
2811
  "stage",
2461
2812
  session,
2462
- { message, waitForStaging: input.waitForStaging !== false, deletePreview: input.deletePreview !== false, deleteBranch: input.deleteBranch !== false },
2813
+ {
2814
+ message,
2815
+ waitForStaging: effectiveInput.waitForStaging !== false,
2816
+ deletePreview: effectiveInput.deletePreview !== false,
2817
+ deleteBranch: effectiveInput.deleteBranch !== false,
2818
+ ciMode,
2819
+ worktreeMode: effectiveInput.worktreeMode ?? "auto"
2820
+ },
2463
2821
  [
2822
+ { id: "workspace-unlink", description: "Remove local workspace links", repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2464
2823
  ...packageReports.map((report) => ({
2465
2824
  id: `merge-${report.name}`,
2466
2825
  description: `Merge ${featureBranch} into ${report.name} staging`,
@@ -2470,7 +2829,7 @@ async function workflowStage(helpers, input) {
2470
2829
  resumable: true
2471
2830
  })),
2472
2831
  { id: "merge-root", description: `Merge ${featureBranch} into market staging`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2473
- { id: "wait-staging", description: "Wait for staging automation", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
2832
+ { id: "wait-staging", description: "Wait for exact-SHA staging GitHub Actions gates", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
2474
2833
  { id: "preview-cleanup", description: "Destroy preview resources", repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2475
2834
  { id: "cleanup-root", description: `Archive ${featureBranch} in market`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
2476
2835
  ...packageReports.map((report) => ({
@@ -2480,12 +2839,23 @@ async function workflowStage(helpers, input) {
2480
2839
  repoPath: report.path,
2481
2840
  branch: featureBranch,
2482
2841
  resumable: true
2483
- }))
2842
+ })),
2843
+ { id: "workspace-link", description: "Restore local workspace links", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
2844
+ ...isManagedWorkflowWorktree(root) ? [{ id: "worktree-cleanup", description: "Remove managed workflow worktree", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: false }] : []
2484
2845
  ],
2485
- helpers.context
2846
+ autoResumeRun ? {
2847
+ ...helpers.context,
2848
+ workflow: {
2849
+ ...helpers.context.workflow ?? {},
2850
+ resumeRunId: autoResumeRun.runId
2851
+ }
2852
+ } : helpers.context
2486
2853
  );
2854
+ if (autoResumeRun) {
2855
+ helpers.write(`[workflow][resume] Resuming interrupted stage ${autoResumeRun.runId} on ${featureBranch}.`);
2856
+ }
2487
2857
  try {
2488
- unlinkWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2858
+ await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"), { rerunCompleted: true });
2489
2859
  for (const pkg of checkedOutWorkspacePackageRepos(root)) {
2490
2860
  const report = findReportByName(packageReports, pkg.name);
2491
2861
  if (!report) {
@@ -2517,7 +2887,11 @@ async function workflowStage(helpers, input) {
2517
2887
  try {
2518
2888
  rootMerge = await executeJournalStep(root, workflowRun.runId, "merge-root", async () => {
2519
2889
  assertCleanWorktree(root);
2520
- syncBranchWithOrigin(repoDir, STAGING_BRANCH);
2890
+ if (isManagedWorkflowWorktree(root)) {
2891
+ checkoutDetachedOriginBranch(repoDir, STAGING_BRANCH);
2892
+ } else {
2893
+ syncBranchWithOrigin(repoDir, STAGING_BRANCH);
2894
+ }
2521
2895
  run("git", ["merge", "--squash", featureBranch], { cwd: repoDir });
2522
2896
  if (mode === "recursive-workspace") {
2523
2897
  syncAllCheckedOutPackageRepos(root, STAGING_BRANCH);
@@ -2532,10 +2906,14 @@ async function workflowStage(helpers, input) {
2532
2906
  run("git", ["add", "-A"], { cwd: repoDir });
2533
2907
  run("git", ["commit", "-m", message], { cwd: repoDir });
2534
2908
  }
2535
- pushBranch(repoDir, STAGING_BRANCH);
2909
+ if (isManagedWorkflowWorktree(root)) {
2910
+ pushHeadToBranch(repoDir, STAGING_BRANCH);
2911
+ } else {
2912
+ pushBranch(repoDir, STAGING_BRANCH);
2913
+ }
2536
2914
  return {
2537
2915
  commitSha: headCommit(repoDir),
2538
- branch: currentBranch(repoDir) || STAGING_BRANCH,
2916
+ branch: STAGING_BRANCH,
2539
2917
  committed: hasMeaningfulChanges(repoDir) ? false : true,
2540
2918
  lockfileValidation: lockfileSafety.lockfileValidation,
2541
2919
  lockfileInstall: lockfileSafety.install
@@ -2553,18 +2931,47 @@ async function workflowStage(helpers, input) {
2553
2931
  exitCode: 12
2554
2932
  });
2555
2933
  }
2556
- const stagingWait = input.waitForStaging === false ? (skipJournalStep(root, workflowRun.runId, "wait-staging", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "wait-staging", () => waitForStagingAutomation(repoDir));
2557
- const previewCleanup = input.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
2934
+ const stageWorkflowGateResult = effectiveInput.waitForStaging === false ? (skipJournalStep(root, workflowRun.runId, "wait-staging", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "wait-staging", () => waitForWorkflowGates("stage", [
2935
+ {
2936
+ name: rootRepo.name,
2937
+ repoPath: rootRepo.path,
2938
+ workflow: "verify.yml",
2939
+ branch: STAGING_BRANCH,
2940
+ headSha: rootRepo.commitSha
2941
+ },
2942
+ {
2943
+ name: rootRepo.name,
2944
+ repoPath: rootRepo.path,
2945
+ workflow: "deploy.yml",
2946
+ branch: STAGING_BRANCH,
2947
+ headSha: rootRepo.commitSha
2948
+ },
2949
+ ...packageReports.filter((report) => report.merged && report.commitSha).map((report) => ({
2950
+ name: report.name,
2951
+ repoPath: report.path,
2952
+ workflow: "verify.yml",
2953
+ branch: STAGING_BRANCH,
2954
+ headSha: String(report.commitSha)
2955
+ }))
2956
+ ], ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({
2957
+ status: "completed",
2958
+ workflowGates
2959
+ })));
2960
+ const stagingWait = {
2961
+ status: String(stageWorkflowGateResult?.status ?? "completed"),
2962
+ workflowGates: Array.isArray(stageWorkflowGateResult?.workflowGates) ? stageWorkflowGateResult.workflowGates : []
2963
+ };
2964
+ const previewCleanup = effectiveInput.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
2558
2965
  const rootCleanup = await executeJournalStep(root, workflowRun.runId, "cleanup-root", () => {
2559
2966
  const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `stage: ${message}`);
2560
- const deletedRemote = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
2561
- if (input.deleteBranch !== false) {
2967
+ const deletedRemote = effectiveInput.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
2968
+ if (effectiveInput.deleteBranch !== false) {
2562
2969
  deleteLocalBranch(repoDir, featureBranch);
2563
2970
  }
2564
2971
  return {
2565
2972
  deprecatedTag,
2566
2973
  deletedRemote,
2567
- deletedLocal: input.deleteBranch !== false,
2974
+ deletedLocal: effectiveInput.deleteBranch !== false,
2568
2975
  branch: currentBranch(repoDir) || STAGING_BRANCH
2569
2976
  };
2570
2977
  });
@@ -2579,12 +2986,15 @@ async function workflowStage(helpers, input) {
2579
2986
  continue;
2580
2987
  }
2581
2988
  const cleanup = await executeJournalStep(root, workflowRun.runId, `cleanup-${report.name}`, () => cleanupTaskBranchReport(report, featureBranch, `stage: ${message}`, {
2582
- deleteBranch: input.deleteBranch !== false,
2989
+ deleteBranch: effectiveInput.deleteBranch !== false,
2583
2990
  targetBranch: STAGING_BRANCH
2584
2991
  }));
2585
2992
  Object.assign(report, cleanup);
2586
2993
  }
2587
- const workspaceLinks = ensureWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
2994
+ const workspaceLinks = await executeJournalStep(root, workflowRun.runId, "workspace-link", () => ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
2995
+ const finalBranch = currentBranch(repoDir) || STAGING_BRANCH;
2996
+ const managedWorktree = managedWorkflowWorktreeMetadata(root);
2997
+ const worktreeCleanup = isManagedWorkflowWorktree(root) ? await executeJournalStep(root, workflowRun.runId, "worktree-cleanup", () => removeManagedWorkflowWorktree(root)) : { removed: false, reason: "not-managed" };
2588
2998
  const payload = {
2589
2999
  mode,
2590
3000
  branchName: featureBranch,
@@ -2602,8 +3012,15 @@ async function workflowStage(helpers, input) {
2602
3012
  lockfileInstall: rootMerge?.lockfileInstall ?? null,
2603
3013
  remoteDeleted: rootRepo.deletedRemote,
2604
3014
  localDeleted: rootRepo.deletedLocal,
2605
- finalBranch: currentBranch(repoDir) || STAGING_BRANCH,
2606
- workspaceLinks
3015
+ finalBranch,
3016
+ workspaceLinks,
3017
+ ciMode,
3018
+ workflowGates: stagingWait.workflowGates,
3019
+ worktreeCleanup,
3020
+ worktreeMode: effectiveInput.worktreeMode ?? "auto",
3021
+ managedWorktree,
3022
+ worktreePath: managedWorktree?.worktreePath ?? null,
3023
+ primaryRoot: managedWorktree?.primaryRoot ?? null
2607
3024
  };
2608
3025
  completeWorkflowRun(root, workflowRun.runId, payload);
2609
3026
  return buildWorkflowResult(
@@ -2619,7 +3036,7 @@ async function workflowStage(helpers, input) {
2619
3036
  }
2620
3037
  );
2621
3038
  } catch (error) {
2622
- ensureWorkflowWorkspaceLinks(root, helpers, input.workspaceLinks ?? "auto");
3039
+ ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
2623
3040
  failWorkflowRun(root, workflowRun.runId, error, {
2624
3041
  resumable: true,
2625
3042
  runId: workflowRun.runId,
@@ -2650,6 +3067,7 @@ async function workflowRelease(helpers, input) {
2650
3067
  const planAutoResumeRun = executionMode === "plan" ? findAutoResumableReleaseRun(root, session.branchName, rootRepo, packageReports) : null;
2651
3068
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
2652
3069
  const level = effectiveInput.bump ?? "patch";
3070
+ const ciMode = normalizeCiMode(effectiveInput.ciMode, "release");
2653
3071
  const isResume = Boolean(explicitResumeRunId || autoResumeRun);
2654
3072
  const packageSelection = session.packageSelection;
2655
3073
  const selectedPackageNames = new Set(packageSelection.selected);
@@ -2666,6 +3084,8 @@ async function workflowRelease(helpers, input) {
2666
3084
  if (executionMode === "plan") {
2667
3085
  return buildWorkflowResult("release", root, {
2668
3086
  ...plannedRelease,
3087
+ ciMode,
3088
+ ...worktreePayload(root, effectiveInput.worktreeMode),
2669
3089
  autoResumeCandidate: planAutoResumeRun ? {
2670
3090
  runId: planAutoResumeRun.runId,
2671
3091
  branch: planAutoResumeRun.session.branchName,
@@ -2691,6 +3111,8 @@ async function workflowRelease(helpers, input) {
2691
3111
  devTagCleanup: effectiveInput.devTagCleanup ?? "safe-after-release",
2692
3112
  gitDependencyProtocol: effectiveInput.gitDependencyProtocol ?? "preserve-origin",
2693
3113
  gitRemoteWriteMode: effectiveInput.gitRemoteWriteMode ?? "ssh-pushurl",
3114
+ ciMode,
3115
+ worktreeMode: effectiveInput.worktreeMode ?? "auto",
2694
3116
  workspaceLinks: effectiveInput.workspaceLinks ?? "auto"
2695
3117
  },
2696
3118
  [
@@ -2706,6 +3128,7 @@ async function workflowRelease(helpers, input) {
2706
3128
  resumable: true
2707
3129
  })),
2708
3130
  { id: "release-root", description: "Release market repo", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
3131
+ { id: "release-root-gates", description: "Wait for market release GitHub Actions gates", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
2709
3132
  ...mode === "recursive-workspace" ? [{ id: "cleanup-dev-tags", description: "Clean replaced dev package tags", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true }] : []
2710
3133
  ],
2711
3134
  autoResumeRun ? {
@@ -2732,19 +3155,22 @@ async function workflowRelease(helpers, input) {
2732
3155
  assertCleanWorktree(root);
2733
3156
  }
2734
3157
  prepareReleaseBranches(root);
3158
+ ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
2735
3159
  runWorkspaceSavePreflight({ cwd: root });
2736
- await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
3160
+ await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"), { rerunCompleted: true });
2737
3161
  if (mode === "root-only") {
2738
3162
  const rootRelease2 = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
2739
3163
  setRootPackageJsonVersion(root, rootVersion);
2740
3164
  run("git", ["checkout", STAGING_BRANCH], { cwd: gitRoot });
2741
3165
  commitAllIfChanged(gitRoot, `release: ${level} bump`);
2742
3166
  pushBranch(gitRoot, STAGING_BRANCH);
3167
+ const stagingCommit = headCommit(gitRoot);
2743
3168
  const released = mergeStagingIntoMain(root);
2744
3169
  const tag = ensureReleaseTag(gitRoot, rootVersion, released.commitSha);
2745
3170
  syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
2746
3171
  return {
2747
3172
  rootVersion,
3173
+ stagingCommit,
2748
3174
  releasedCommit: released.commitSha,
2749
3175
  tag
2750
3176
  };
@@ -2755,6 +3181,43 @@ async function workflowRelease(helpers, input) {
2755
3181
  rootRepo.branch = PRODUCTION_BRANCH;
2756
3182
  rootRepo.commitSha = String(rootRelease2?.releasedCommit ?? headCommit(gitRoot));
2757
3183
  rootRepo.tagName = String(rootRelease2?.rootVersion ?? "");
3184
+ const rootWorkflowGateResult2 = await executeJournalStep(root, workflowRun.runId, "release-root-gates", () => waitForWorkflowGates("release", [
3185
+ {
3186
+ name: rootRepo.name,
3187
+ repoPath: rootRepo.path,
3188
+ workflow: "verify.yml",
3189
+ branch: STAGING_BRANCH,
3190
+ headSha: String(rootRelease2?.stagingCommit ?? "")
3191
+ },
3192
+ {
3193
+ name: rootRepo.name,
3194
+ repoPath: rootRepo.path,
3195
+ workflow: "deploy.yml",
3196
+ branch: STAGING_BRANCH,
3197
+ headSha: String(rootRelease2?.stagingCommit ?? "")
3198
+ },
3199
+ {
3200
+ name: rootRepo.name,
3201
+ repoPath: rootRepo.path,
3202
+ workflow: "verify.yml",
3203
+ branch: rootVersion,
3204
+ headSha: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? "")
3205
+ },
3206
+ {
3207
+ name: rootRepo.name,
3208
+ repoPath: rootRepo.path,
3209
+ workflow: "verify.yml",
3210
+ branch: PRODUCTION_BRANCH,
3211
+ headSha: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? "")
3212
+ },
3213
+ {
3214
+ name: rootRepo.name,
3215
+ repoPath: rootRepo.path,
3216
+ workflow: "deploy.yml",
3217
+ branch: PRODUCTION_BRANCH,
3218
+ headSha: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? "")
3219
+ }
3220
+ ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
2758
3221
  const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
2759
3222
  const payload2 = {
2760
3223
  mode,
@@ -2775,7 +3238,10 @@ async function workflowRelease(helpers, input) {
2775
3238
  rootRepo,
2776
3239
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
2777
3240
  pushStatus: { stagingPushed: true, productionPushed: true, tagPushed: true },
2778
- workspaceLinks: workspaceLinks2
3241
+ workspaceLinks: workspaceLinks2,
3242
+ ciMode,
3243
+ workflowGates: rootWorkflowGateResult2?.workflowGates ?? [],
3244
+ ...worktreePayload(root, effectiveInput.worktreeMode)
2779
3245
  };
2780
3246
  completeWorkflowRun(root, workflowRun.runId, payload2);
2781
3247
  return buildWorkflowResult("release", root, payload2, {
@@ -2843,18 +3309,38 @@ async function workflowRelease(helpers, input) {
2843
3309
  });
2844
3310
  const tagName = String(effectiveVersions.get(pkg.name));
2845
3311
  const tag = ensureReleaseTag(pkg.dir, tagName, mergeResult.commitSha);
2846
- const publish = await waitForGitHubWorkflowCompletion(pkg.dir, {
2847
- workflow: "publish.yml",
2848
- headSha: mergeResult.commitSha,
2849
- branch: tagName
2850
- });
3312
+ const workflowGates = await waitForWorkflowGates("release", [
3313
+ {
3314
+ name: pkg.name,
3315
+ repoPath: pkg.dir,
3316
+ workflow: "publish.yml",
3317
+ headSha: mergeResult.commitSha,
3318
+ branch: tagName
3319
+ },
3320
+ {
3321
+ name: pkg.name,
3322
+ repoPath: pkg.dir,
3323
+ workflow: "verify.yml",
3324
+ headSha: mergeResult.commitSha,
3325
+ branch: tagName
3326
+ },
3327
+ {
3328
+ name: pkg.name,
3329
+ repoPath: pkg.dir,
3330
+ workflow: "verify.yml",
3331
+ headSha: mergeResult.commitSha,
3332
+ branch: PRODUCTION_BRANCH
3333
+ }
3334
+ ], ciMode, { root, runId: workflowRun.runId });
3335
+ const publish = workflowGates.find((gate) => gate.workflow === "publish.yml") ?? workflowGates[0] ?? null;
2851
3336
  assertReleaseGitHubWorkflowSucceeded(pkg.name, publish);
2852
3337
  syncBranchWithOrigin(pkg.dir, STAGING_BRANCH);
2853
3338
  return {
2854
3339
  commitSha: mergeResult.commitSha,
2855
3340
  tagName,
2856
3341
  tag,
2857
- publish
3342
+ publish,
3343
+ workflowGates
2858
3344
  };
2859
3345
  });
2860
3346
  report.committed = true;
@@ -2863,6 +3349,7 @@ async function workflowRelease(helpers, input) {
2863
3349
  report.tagName = String(releasedPackage?.tagName ?? "");
2864
3350
  report.commitSha = String(releasedPackage?.commitSha ?? report.commitSha ?? "");
2865
3351
  report.publishWait = releasedPackage?.publish ?? null;
3352
+ report.workflowGates = Array.isArray(releasedPackage?.workflowGates) ? releasedPackage.workflowGates : [];
2866
3353
  report.branch = STAGING_BRANCH;
2867
3354
  publishWait.push({
2868
3355
  name: report.name,
@@ -2875,6 +3362,7 @@ async function workflowRelease(helpers, input) {
2875
3362
  run("git", ["checkout", STAGING_BRANCH], { cwd: gitRoot });
2876
3363
  commitAllIfChanged(gitRoot, `release: ${level} bump`);
2877
3364
  pushBranch(gitRoot, STAGING_BRANCH);
3365
+ const stagingCommit = headCommit(gitRoot);
2878
3366
  const released = mergeBranchIntoTarget(root, {
2879
3367
  sourceBranch: STAGING_BRANCH,
2880
3368
  targetBranch: PRODUCTION_BRANCH,
@@ -2894,6 +3382,7 @@ async function workflowRelease(helpers, input) {
2894
3382
  syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
2895
3383
  return {
2896
3384
  rootVersion,
3385
+ stagingCommit,
2897
3386
  releasedCommit,
2898
3387
  mergeCommit: released.commitSha,
2899
3388
  tag
@@ -2905,6 +3394,43 @@ async function workflowRelease(helpers, input) {
2905
3394
  rootRepo.branch = PRODUCTION_BRANCH;
2906
3395
  rootRepo.commitSha = String(rootRelease?.releasedCommit ?? headCommit(gitRoot));
2907
3396
  rootRepo.tagName = String(rootRelease?.rootVersion ?? "");
3397
+ const rootWorkflowGateResult = await executeJournalStep(root, workflowRun.runId, "release-root-gates", () => waitForWorkflowGates("release", [
3398
+ {
3399
+ name: rootRepo.name,
3400
+ repoPath: rootRepo.path,
3401
+ workflow: "verify.yml",
3402
+ branch: STAGING_BRANCH,
3403
+ headSha: String(rootRelease?.stagingCommit ?? "")
3404
+ },
3405
+ {
3406
+ name: rootRepo.name,
3407
+ repoPath: rootRepo.path,
3408
+ workflow: "deploy.yml",
3409
+ branch: STAGING_BRANCH,
3410
+ headSha: String(rootRelease?.stagingCommit ?? "")
3411
+ },
3412
+ {
3413
+ name: rootRepo.name,
3414
+ repoPath: rootRepo.path,
3415
+ workflow: "verify.yml",
3416
+ branch: rootVersion,
3417
+ headSha: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? "")
3418
+ },
3419
+ {
3420
+ name: rootRepo.name,
3421
+ repoPath: rootRepo.path,
3422
+ workflow: "verify.yml",
3423
+ branch: PRODUCTION_BRANCH,
3424
+ headSha: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? "")
3425
+ },
3426
+ {
3427
+ name: rootRepo.name,
3428
+ repoPath: rootRepo.path,
3429
+ workflow: "deploy.yml",
3430
+ branch: PRODUCTION_BRANCH,
3431
+ headSha: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? "")
3432
+ }
3433
+ ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
2908
3434
  const devTagCleanupMode = effectiveInput.devTagCleanup ?? "safe-after-release";
2909
3435
  const devTagCleanup = devTagCleanupMode === "off" ? (skipJournalStep(root, workflowRun.runId, "cleanup-dev-tags", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "cleanup-dev-tags", () => {
2910
3436
  const activeDevTags = collectActiveDevTagReferences(root);
@@ -2956,7 +3482,13 @@ async function workflowRelease(helpers, input) {
2956
3482
  productionPushed: true,
2957
3483
  tagPushed: true
2958
3484
  },
2959
- workspaceLinks
3485
+ workspaceLinks,
3486
+ ciMode,
3487
+ workflowGates: [
3488
+ ...packageReports.flatMap((report) => report.workflowGates),
3489
+ ...Array.isArray(rootWorkflowGateResult?.workflowGates) ? rootWorkflowGateResult.workflowGates : []
3490
+ ],
3491
+ ...worktreePayload(root, effectiveInput.worktreeMode)
2960
3492
  };
2961
3493
  completeWorkflowRun(root, workflowRun.runId, payload);
2962
3494
  return buildWorkflowResult("release", root, payload, {
@@ -3006,7 +3538,21 @@ async function workflowResume(helpers, input) {
3006
3538
  details: { runId, status: journal.status }
3007
3539
  });
3008
3540
  }
3009
- const resumedHelpers = {
3541
+ const session = resolveTreeseedWorkflowSession(root);
3542
+ const currentHeads = Object.fromEntries(
3543
+ [createWorkspaceRootRepoReport(root), ...createWorkspacePackageReports(root)].map((report) => [report.name, report.commitSha ?? null])
3544
+ );
3545
+ const classification = classifyWorkflowRunJournal(journal, {
3546
+ currentBranch: session.branchName,
3547
+ currentHeads
3548
+ });
3549
+ if (classification.state !== "resumable") {
3550
+ workflowError("resume", "resume_unavailable", `Run ${runId} is ${classification.state} and is not safe to resume.`, {
3551
+ details: { runId, status: journal.status, classification }
3552
+ });
3553
+ }
3554
+ const resumeRoot = typeof journal.session?.root === "string" && existsSync(journal.session.root) ? journal.session.root : root;
3555
+ const resumedHelpers = helpersForCwd({
3010
3556
  ...helpers,
3011
3557
  context: {
3012
3558
  ...helpers.context,
@@ -3015,7 +3561,7 @@ async function workflowResume(helpers, input) {
3015
3561
  resumeRunId: runId
3016
3562
  }
3017
3563
  }
3018
- };
3564
+ }, resumeRoot);
3019
3565
  switch (journal.command) {
3020
3566
  case "switch":
3021
3567
  return workflowSwitch(resumedHelpers, journal.input);
@@ -3045,7 +3591,15 @@ async function workflowRecover(helpers, input = {}) {
3045
3591
  const root = resolveProjectRootOrThrow("recover", helpers.cwd());
3046
3592
  const lock = inspectWorkflowLock(root);
3047
3593
  const journals = listWorkflowRunJournals(root);
3048
- const interruptedRuns = listInterruptedWorkflowRuns(root).map((journal) => ({
3594
+ const session = resolveTreeseedWorkflowSession(root);
3595
+ const currentHeads = Object.fromEntries(
3596
+ [createWorkspaceRootRepoReport(root), ...createWorkspacePackageReports(root)].map((report) => [report.name, report.commitSha ?? null])
3597
+ );
3598
+ const classifiedRuns = classifyWorkflowRunJournals(root, {
3599
+ currentBranch: session.branchName,
3600
+ currentHeads
3601
+ });
3602
+ const interruptedRuns = classifiedRuns.filter((entry) => entry.classification.state === "resumable").map(({ journal }) => ({
3049
3603
  runId: journal.runId,
3050
3604
  command: journal.command,
3051
3605
  status: journal.status,
@@ -3055,6 +3609,29 @@ async function workflowRecover(helpers, input = {}) {
3055
3609
  failure: journal.failure,
3056
3610
  resumeCommand: `treeseed resume ${journal.runId}`
3057
3611
  }));
3612
+ const staleRuns = classifiedRuns.filter((entry) => entry.classification.state === "stale").map(({ journal, classification }) => ({
3613
+ runId: journal.runId,
3614
+ command: journal.command,
3615
+ status: journal.status,
3616
+ createdAt: journal.createdAt,
3617
+ updatedAt: journal.updatedAt,
3618
+ nextStep: nextPendingJournalStep(journal)?.description ?? null,
3619
+ failure: journal.failure,
3620
+ classification
3621
+ }));
3622
+ const obsoleteRuns = classifiedRuns.filter((entry) => entry.classification.state === "obsolete").map(({ journal, classification }) => ({
3623
+ runId: journal.runId,
3624
+ command: journal.command,
3625
+ status: journal.status,
3626
+ createdAt: journal.createdAt,
3627
+ updatedAt: journal.updatedAt,
3628
+ failure: journal.failure,
3629
+ classification
3630
+ }));
3631
+ const prunedRuns = input.pruneStale === true ? staleRuns.map((run2) => {
3632
+ archiveWorkflowRun(root, run2.runId, run2.classification);
3633
+ return run2;
3634
+ }) : [];
3058
3635
  const selectedRun = input.runId ? readWorkflowRunJournal(root, input.runId) : null;
3059
3636
  return buildWorkflowResult(
3060
3637
  "recover",
@@ -3062,13 +3639,16 @@ async function workflowRecover(helpers, input = {}) {
3062
3639
  {
3063
3640
  lock,
3064
3641
  interruptedRuns,
3642
+ staleRuns,
3643
+ obsoleteRuns,
3644
+ prunedRuns,
3065
3645
  selectedRun,
3066
3646
  runCount: journals.length
3067
3647
  },
3068
3648
  {
3069
3649
  includeFinalState: false,
3070
3650
  nextSteps: createNextSteps([
3071
- ...interruptedRuns.length > 0 ? [{ operation: "resume", reason: "Resume the most recent interrupted workflow run.", input: { runId: interruptedRuns[0].runId } }] : [{ operation: "status", reason: "No interrupted runs were found; inspect current workflow state instead." }]
3651
+ ...interruptedRuns.length > 0 ? [{ operation: "resume", reason: "Resume the most recent interrupted workflow run.", input: { runId: interruptedRuns[0].runId } }] : staleRuns.length > 0 && input.pruneStale !== true ? [{ operation: "recover", reason: "Archive stale interrupted runs that no longer match current heads.", input: { pruneStale: true } }] : [{ operation: "status", reason: "No interrupted runs were found; inspect current workflow state instead." }]
3072
3652
  ])
3073
3653
  }
3074
3654
  );