@treeseed/sdk 0.4.11 → 0.4.13

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.
@@ -0,0 +1,90 @@
1
+ import type { TreeseedWorkflowMode } from './session.ts';
2
+ export type TreeseedWorkflowRunCommand = 'switch' | 'save' | 'close' | 'stage' | 'release' | 'destroy';
3
+ export type TreeseedWorkflowExecutionMode = 'execute' | 'plan';
4
+ export type TreeseedWorkflowRunStatus = 'running' | 'failed' | 'completed';
5
+ export type TreeseedWorkflowRunStep = {
6
+ id: string;
7
+ description: string;
8
+ repoName: string | null;
9
+ repoPath: string | null;
10
+ branch: string | null;
11
+ resumable: boolean;
12
+ status: 'pending' | 'completed' | 'skipped';
13
+ completedAt: string | null;
14
+ data: Record<string, unknown> | null;
15
+ };
16
+ export type TreeseedWorkflowRunJournal = {
17
+ schemaVersion: 1;
18
+ kind: 'treeseed.workflow.run';
19
+ runId: string;
20
+ command: TreeseedWorkflowRunCommand;
21
+ executionMode: TreeseedWorkflowExecutionMode;
22
+ status: TreeseedWorkflowRunStatus;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ resumable: boolean;
26
+ input: Record<string, unknown>;
27
+ session: {
28
+ root: string;
29
+ mode: TreeseedWorkflowMode;
30
+ branchName: string | null;
31
+ repos: Array<{
32
+ name: string;
33
+ path: string;
34
+ branchName: string | null;
35
+ }>;
36
+ };
37
+ steps: TreeseedWorkflowRunStep[];
38
+ failure: null | {
39
+ code: string;
40
+ message: string;
41
+ details: Record<string, unknown> | null;
42
+ at: string;
43
+ };
44
+ result: Record<string, unknown> | null;
45
+ };
46
+ export type TreeseedWorkflowLockRecord = {
47
+ schemaVersion: 1;
48
+ kind: 'treeseed.workflow.lock';
49
+ runId: string;
50
+ command: TreeseedWorkflowRunCommand;
51
+ root: string;
52
+ host: string;
53
+ pid: number | null;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ stale: boolean;
57
+ staleReason: string | null;
58
+ };
59
+ export type TreeseedWorkflowLockInspection = {
60
+ lock: TreeseedWorkflowLockRecord | null;
61
+ active: boolean;
62
+ stale: boolean;
63
+ staleReason: string | null;
64
+ };
65
+ export declare function generateWorkflowRunId(command: TreeseedWorkflowRunCommand): string;
66
+ export declare function inspectWorkflowLock(root: string): TreeseedWorkflowLockInspection;
67
+ export declare function acquireWorkflowLock(root: string, command: TreeseedWorkflowRunCommand, runId: string): {
68
+ readonly acquired: false;
69
+ readonly lock: TreeseedWorkflowLockRecord;
70
+ readonly replacedStale?: undefined;
71
+ } | {
72
+ readonly acquired: true;
73
+ readonly lock: TreeseedWorkflowLockRecord;
74
+ readonly replacedStale: boolean;
75
+ };
76
+ export declare function refreshWorkflowLock(root: string, runId: string): TreeseedWorkflowLockRecord | null;
77
+ export declare function releaseWorkflowLock(root: string, runId: string): boolean;
78
+ export declare function writeWorkflowRunJournal(root: string, journal: TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal;
79
+ export declare function readWorkflowRunJournal(root: string, runId: string): TreeseedWorkflowRunJournal | null;
80
+ export declare function updateWorkflowRunJournal(root: string, runId: string, updater: (journal: TreeseedWorkflowRunJournal) => TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal | null;
81
+ export declare function createWorkflowRunJournal(root: string, options: {
82
+ runId: string;
83
+ command: TreeseedWorkflowRunCommand;
84
+ executionMode?: TreeseedWorkflowExecutionMode;
85
+ input: Record<string, unknown>;
86
+ session: TreeseedWorkflowRunJournal['session'];
87
+ steps: Omit<TreeseedWorkflowRunStep, 'status' | 'completedAt' | 'data'>[];
88
+ }): TreeseedWorkflowRunJournal;
89
+ export declare function listWorkflowRunJournals(root: string): TreeseedWorkflowRunJournal[];
90
+ export declare function listInterruptedWorkflowRuns(root: string): TreeseedWorkflowRunJournal[];
@@ -0,0 +1,242 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { resolve } from "node:path";
4
+ const WORKFLOW_CONTROL_DIR = ".treeseed/workflow";
5
+ const WORKFLOW_RUNS_DIR = `${WORKFLOW_CONTROL_DIR}/runs`;
6
+ const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1e3;
7
+ function nowIso() {
8
+ return (/* @__PURE__ */ new Date()).toISOString();
9
+ }
10
+ function workflowControlRoot(root) {
11
+ return resolve(root, WORKFLOW_CONTROL_DIR);
12
+ }
13
+ function workflowRunsRoot(root) {
14
+ return resolve(root, WORKFLOW_RUNS_DIR);
15
+ }
16
+ function workflowLockPath(root) {
17
+ return resolve(root, WORKFLOW_CONTROL_DIR, "lock.json");
18
+ }
19
+ function workflowRunPath(root, runId) {
20
+ return resolve(root, WORKFLOW_RUNS_DIR, `${runId}.json`);
21
+ }
22
+ function resolveGitDir(root) {
23
+ const gitPath = resolve(root, ".git");
24
+ if (existsSync(resolve(gitPath, "info"))) {
25
+ return gitPath;
26
+ }
27
+ if (!existsSync(gitPath)) {
28
+ return null;
29
+ }
30
+ try {
31
+ const gitFile = readFileSync(gitPath, "utf8").trim();
32
+ const match = gitFile.match(/^gitdir:\s*(.+)$/u);
33
+ return match ? resolve(root, match[1]) : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ function ensureWorkflowExcludeRule(root) {
39
+ const gitDir = resolveGitDir(root);
40
+ if (!gitDir) {
41
+ return;
42
+ }
43
+ const excludePath = resolve(gitDir, "info", "exclude");
44
+ const pattern = "/.treeseed/workflow/";
45
+ const current = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
46
+ if (current.includes(pattern)) {
47
+ return;
48
+ }
49
+ writeFileSync(excludePath, `${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${pattern}
50
+ `, "utf8");
51
+ }
52
+ function ensureWorkflowControlDirs(root) {
53
+ const controlDir = workflowControlRoot(root);
54
+ const runsDir = workflowRunsRoot(root);
55
+ mkdirSync(runsDir, { recursive: true });
56
+ ensureWorkflowExcludeRule(root);
57
+ writeFileSync(resolve(controlDir, ".gitignore"), "*\n!.gitignore\n!runs/\nruns/*\n!runs/.gitignore\n", "utf8");
58
+ writeFileSync(resolve(runsDir, ".gitignore"), "*\n!.gitignore\n", "utf8");
59
+ return {
60
+ controlDir,
61
+ runsDir,
62
+ lockPath: workflowLockPath(root)
63
+ };
64
+ }
65
+ function safeJsonParse(filePath) {
66
+ if (!existsSync(filePath)) {
67
+ return null;
68
+ }
69
+ try {
70
+ return JSON.parse(readFileSync(filePath, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ function pidIsAlive(pid) {
76
+ if (!Number.isInteger(pid) || (pid ?? 0) <= 0) {
77
+ return false;
78
+ }
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ function generateWorkflowRunId(command) {
87
+ return `${command}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
88
+ }
89
+ function inspectWorkflowLock(root) {
90
+ const lock = safeJsonParse(workflowLockPath(root));
91
+ if (!lock) {
92
+ return {
93
+ lock: null,
94
+ active: false,
95
+ stale: false,
96
+ staleReason: null
97
+ };
98
+ }
99
+ let staleReason = null;
100
+ if (lock.host === hostname() && lock.pid != null && !pidIsAlive(lock.pid)) {
101
+ staleReason = `process ${lock.pid} is no longer running`;
102
+ } else if (Date.now() - Date.parse(lock.updatedAt) > LOCK_STALE_AFTER_MS) {
103
+ staleReason = "lock heartbeat expired";
104
+ }
105
+ return {
106
+ lock: {
107
+ ...lock,
108
+ stale: staleReason != null,
109
+ staleReason
110
+ },
111
+ active: staleReason == null,
112
+ stale: staleReason != null,
113
+ staleReason
114
+ };
115
+ }
116
+ function acquireWorkflowLock(root, command, runId) {
117
+ const dirs = ensureWorkflowControlDirs(root);
118
+ const inspection = inspectWorkflowLock(root);
119
+ if (inspection.active && inspection.lock && inspection.lock.runId !== runId) {
120
+ return {
121
+ acquired: false,
122
+ lock: inspection.lock
123
+ };
124
+ }
125
+ if (inspection.stale) {
126
+ rmSync(dirs.lockPath, { force: true });
127
+ }
128
+ const timestamp = nowIso();
129
+ const lock = {
130
+ schemaVersion: 1,
131
+ kind: "treeseed.workflow.lock",
132
+ runId,
133
+ command,
134
+ root,
135
+ host: hostname(),
136
+ pid: process.pid,
137
+ createdAt: inspection.lock?.runId === runId ? inspection.lock.createdAt : timestamp,
138
+ updatedAt: timestamp,
139
+ stale: false,
140
+ staleReason: null
141
+ };
142
+ writeFileSync(dirs.lockPath, `${JSON.stringify(lock, null, 2)}
143
+ `, "utf8");
144
+ return {
145
+ acquired: true,
146
+ lock,
147
+ replacedStale: inspection.stale
148
+ };
149
+ }
150
+ function refreshWorkflowLock(root, runId) {
151
+ const path = workflowLockPath(root);
152
+ const lock = safeJsonParse(path);
153
+ if (!lock || lock.runId !== runId) {
154
+ return null;
155
+ }
156
+ const updated = {
157
+ ...lock,
158
+ updatedAt: nowIso(),
159
+ stale: false,
160
+ staleReason: null
161
+ };
162
+ writeFileSync(path, `${JSON.stringify(updated, null, 2)}
163
+ `, "utf8");
164
+ return updated;
165
+ }
166
+ function releaseWorkflowLock(root, runId) {
167
+ const path = workflowLockPath(root);
168
+ const lock = safeJsonParse(path);
169
+ if (!lock || lock.runId !== runId) {
170
+ return false;
171
+ }
172
+ rmSync(path, { force: true });
173
+ return true;
174
+ }
175
+ function writeWorkflowRunJournal(root, journal) {
176
+ ensureWorkflowControlDirs(root);
177
+ writeFileSync(workflowRunPath(root, journal.runId), `${JSON.stringify(journal, null, 2)}
178
+ `, "utf8");
179
+ return journal;
180
+ }
181
+ function readWorkflowRunJournal(root, runId) {
182
+ return safeJsonParse(workflowRunPath(root, runId));
183
+ }
184
+ function updateWorkflowRunJournal(root, runId, updater) {
185
+ const current = readWorkflowRunJournal(root, runId);
186
+ if (!current) {
187
+ return null;
188
+ }
189
+ const updated = updater(current);
190
+ writeWorkflowRunJournal(root, {
191
+ ...updated,
192
+ updatedAt: nowIso()
193
+ });
194
+ return readWorkflowRunJournal(root, runId);
195
+ }
196
+ function createWorkflowRunJournal(root, options) {
197
+ const timestamp = nowIso();
198
+ return writeWorkflowRunJournal(root, {
199
+ schemaVersion: 1,
200
+ kind: "treeseed.workflow.run",
201
+ runId: options.runId,
202
+ command: options.command,
203
+ executionMode: options.executionMode ?? "execute",
204
+ status: "running",
205
+ createdAt: timestamp,
206
+ updatedAt: timestamp,
207
+ resumable: options.steps.every((step) => step.resumable),
208
+ input: options.input,
209
+ session: options.session,
210
+ steps: options.steps.map((step) => ({
211
+ ...step,
212
+ status: "pending",
213
+ completedAt: null,
214
+ data: null
215
+ })),
216
+ failure: null,
217
+ result: null
218
+ });
219
+ }
220
+ function listWorkflowRunJournals(root) {
221
+ const runsDir = workflowRunsRoot(root);
222
+ if (!existsSync(runsDir)) {
223
+ return [];
224
+ }
225
+ return readdirSync(runsDir).filter((entry) => entry.endsWith(".json")).map((entry) => safeJsonParse(resolve(runsDir, entry))).filter((entry) => entry != null).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
226
+ }
227
+ function listInterruptedWorkflowRuns(root) {
228
+ return listWorkflowRunJournals(root).filter((journal) => journal.status === "failed" && journal.resumable);
229
+ }
230
+ export {
231
+ acquireWorkflowLock,
232
+ createWorkflowRunJournal,
233
+ generateWorkflowRunId,
234
+ inspectWorkflowLock,
235
+ listInterruptedWorkflowRuns,
236
+ listWorkflowRunJournals,
237
+ readWorkflowRunJournal,
238
+ refreshWorkflowLock,
239
+ releaseWorkflowLock,
240
+ updateWorkflowRunJournal,
241
+ writeWorkflowRunJournal
242
+ };
@@ -0,0 +1,31 @@
1
+ import { type TreeseedWorkflowBranchRole } from './policy.ts';
2
+ export type TreeseedWorkflowMode = 'root-only' | 'recursive-workspace';
3
+ export type TreeseedWorkflowSessionRepo = {
4
+ name: string;
5
+ path: string;
6
+ relativePath: string;
7
+ branchName: string | null;
8
+ branchRole: TreeseedWorkflowBranchRole;
9
+ dirty: boolean;
10
+ detached: boolean;
11
+ hasOriginRemote: boolean;
12
+ };
13
+ export type TreeseedWorkflowPackageSelection = {
14
+ changed: string[];
15
+ dependents: string[];
16
+ selected: string[];
17
+ };
18
+ export type TreeseedWorkflowSession = {
19
+ root: string;
20
+ gitRoot: string;
21
+ mode: TreeseedWorkflowMode;
22
+ branchName: string | null;
23
+ branchRole: TreeseedWorkflowBranchRole;
24
+ rootRepo: TreeseedWorkflowSessionRepo;
25
+ packageRepos: TreeseedWorkflowSessionRepo[];
26
+ packageSelection: TreeseedWorkflowPackageSelection;
27
+ };
28
+ export declare function checkedOutWorkspacePackageRepos(root: string): any[];
29
+ export declare function workflowModeForRoot(root: string): TreeseedWorkflowMode;
30
+ export declare function collectReleasePackageSelection(root: string): TreeseedWorkflowPackageSelection;
31
+ export declare function resolveTreeseedWorkflowSession(cwd: string): TreeseedWorkflowSession;
@@ -0,0 +1,97 @@
1
+ import { relative } from "node:path";
2
+ import {
3
+ currentBranch,
4
+ gitStatusPorcelain,
5
+ originRemoteUrl,
6
+ repoRoot
7
+ } from "../operations/services/workspace-save.js";
8
+ import {
9
+ changedWorkspacePackages,
10
+ hasCompleteTreeseedPackageCheckout,
11
+ publishableWorkspacePackages,
12
+ sortWorkspacePackages,
13
+ workspacePackages,
14
+ workspaceRoot
15
+ } from "../operations/services/workspace-tools.js";
16
+ import {
17
+ classifyTreeseedBranchRole,
18
+ resolveTreeseedWorkflowPaths
19
+ } from "./policy.js";
20
+ function hasOriginRemote(repoDir) {
21
+ try {
22
+ originRemoteUrl(repoDir);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ function repoState(root, name, repoDir) {
29
+ const branchName = currentBranch(repoDir) || null;
30
+ return {
31
+ name,
32
+ path: repoDir,
33
+ relativePath: relative(root, repoDir).replaceAll("\\", "/") || ".",
34
+ branchName,
35
+ branchRole: classifyTreeseedBranchRole(branchName, repoDir),
36
+ dirty: gitStatusPorcelain(repoDir).length > 0,
37
+ detached: branchName == null,
38
+ hasOriginRemote: hasOriginRemote(repoDir)
39
+ };
40
+ }
41
+ function checkedOutWorkspacePackageRepos(root) {
42
+ if (!hasCompleteTreeseedPackageCheckout(root)) {
43
+ return [];
44
+ }
45
+ return sortWorkspacePackages(
46
+ workspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
47
+ );
48
+ }
49
+ function workflowModeForRoot(root) {
50
+ return hasCompleteTreeseedPackageCheckout(root) ? "recursive-workspace" : "root-only";
51
+ }
52
+ function collectReleasePackageSelection(root) {
53
+ const publishable = sortWorkspacePackages(
54
+ publishableWorkspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
55
+ );
56
+ const changed = changedWorkspacePackages({
57
+ root,
58
+ baseRef: "main",
59
+ includeDependents: false,
60
+ packages: publishable
61
+ });
62
+ const selected = changedWorkspacePackages({
63
+ root,
64
+ baseRef: "main",
65
+ includeDependents: true,
66
+ packages: publishable
67
+ });
68
+ const changedNames = changed.map((pkg) => pkg.name);
69
+ return {
70
+ changed: changedNames,
71
+ dependents: selected.filter((pkg) => !changedNames.includes(pkg.name)).map((pkg) => pkg.name),
72
+ selected: selected.map((pkg) => pkg.name)
73
+ };
74
+ }
75
+ function resolveTreeseedWorkflowSession(cwd) {
76
+ const resolved = resolveTreeseedWorkflowPaths(cwd);
77
+ const root = workspaceRoot(resolved.cwd);
78
+ const gitRoot = repoRoot(root);
79
+ const mode = workflowModeForRoot(root);
80
+ const packageRepos = checkedOutWorkspacePackageRepos(root).map((pkg) => repoState(root, pkg.name, pkg.dir));
81
+ return {
82
+ root,
83
+ gitRoot,
84
+ mode,
85
+ branchName: currentBranch(gitRoot) || null,
86
+ branchRole: classifyTreeseedBranchRole(currentBranch(gitRoot) || null, gitRoot),
87
+ rootRepo: repoState(root, "@treeseed/market", gitRoot),
88
+ packageRepos,
89
+ packageSelection: collectReleasePackageSelection(root)
90
+ };
91
+ }
92
+ export {
93
+ checkedOutWorkspacePackageRepos,
94
+ collectReleasePackageSelection,
95
+ resolveTreeseedWorkflowSession,
96
+ workflowModeForRoot
97
+ };
@@ -12,6 +12,40 @@ export type TreeseedWorkflowState = {
12
12
  branchRole: TreeseedBranchRole;
13
13
  environment: 'local' | 'staging' | 'prod' | 'none';
14
14
  dirtyWorktree: boolean;
15
+ workflowControl: {
16
+ lock: {
17
+ active: boolean;
18
+ stale: boolean;
19
+ runId: string | null;
20
+ command: string | null;
21
+ updatedAt: string | null;
22
+ staleReason: string | null;
23
+ };
24
+ interruptedRuns: Array<{
25
+ runId: string;
26
+ command: string;
27
+ updatedAt: string;
28
+ nextStep: string | null;
29
+ }>;
30
+ blockers: string[];
31
+ };
32
+ packageSync: {
33
+ mode: 'root-only' | 'recursive-workspace';
34
+ completeCheckout: boolean;
35
+ expectedBranch: string | null;
36
+ aligned: boolean;
37
+ dirty: boolean;
38
+ repos: Array<{
39
+ name: string;
40
+ path: string;
41
+ branchName: string | null;
42
+ dirty: boolean;
43
+ aligned: boolean;
44
+ localBranch: boolean;
45
+ remoteBranch: boolean;
46
+ }>;
47
+ blockers: string[];
48
+ };
15
49
  preview: {
16
50
  enabled: boolean;
17
51
  url: string | null;
@@ -8,8 +8,9 @@ import {
8
8
  } from "./operations/services/deploy.js";
9
9
  import { loadCliDeployConfig } from "./operations/services/runtime-tools.js";
10
10
  import { collectCliPreflight } from "./operations/services/workspace-preflight.js";
11
- import { gitStatusPorcelain } from "./operations/services/workspace-save.js";
12
- import { isWorkspaceRoot } from "./operations/services/workspace-tools.js";
11
+ import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
12
+ import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
13
+ import { inspectWorkflowLock, listInterruptedWorkflowRuns } from "./workflow/runs.js";
13
14
  import {
14
15
  resolveTreeseedWorkflowPaths,
15
16
  workflowEnvironmentForBranchRole
@@ -48,6 +49,14 @@ function readinessForEnvironment(state, scope) {
48
49
  warnings
49
50
  };
50
51
  }
52
+ function knownRemoteTrackingBranchExists(repoDir, branchName) {
53
+ try {
54
+ run("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`], { cwd: repoDir, capture: true });
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
51
60
  function resolveTreeseedWorkflowState(cwd) {
52
61
  const resolved = resolveTreeseedWorkflowPaths(cwd);
53
62
  const effectiveCwd = resolved.cwd;
@@ -58,8 +67,67 @@ function resolveTreeseedWorkflowState(cwd) {
58
67
  const branchName = resolved.branchName;
59
68
  const branchRole = resolved.branchRole;
60
69
  const dirtyWorktree = root ? gitStatusPorcelain(root).length > 0 : false;
70
+ const completePackageCheckout = hasCompleteTreeseedPackageCheckout(effectiveCwd);
71
+ const packageSyncRepos = completePackageCheckout ? workspacePackages(effectiveCwd).filter((pkg) => pkg.name?.startsWith("@treeseed/")).map((pkg) => {
72
+ const repoBranch = currentBranch(pkg.dir) || null;
73
+ const dirty = gitStatusPorcelain(pkg.dir).length > 0;
74
+ const expectedBranch = branchName;
75
+ let localBranch = false;
76
+ if (expectedBranch) {
77
+ if (repoBranch === expectedBranch) {
78
+ localBranch = true;
79
+ } else {
80
+ try {
81
+ run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${expectedBranch}`], { cwd: pkg.dir, capture: true });
82
+ localBranch = true;
83
+ } catch {
84
+ localBranch = false;
85
+ }
86
+ }
87
+ }
88
+ const remoteBranch = Boolean(expectedBranch) ? knownRemoteTrackingBranchExists(pkg.dir, expectedBranch) : false;
89
+ return {
90
+ name: pkg.name,
91
+ path: pkg.relativeDir,
92
+ branchName: repoBranch,
93
+ dirty,
94
+ aligned: expectedBranch ? repoBranch === expectedBranch : true,
95
+ localBranch,
96
+ remoteBranch
97
+ };
98
+ }) : [];
99
+ const packageSyncBlockers = [];
100
+ for (const repo of packageSyncRepos) {
101
+ if (repo.dirty) {
102
+ packageSyncBlockers.push(`${repo.name} has uncommitted changes.`);
103
+ }
104
+ if (branchName && !repo.localBranch && !repo.remoteBranch) {
105
+ packageSyncBlockers.push(`${repo.name} is missing branch ${branchName}.`);
106
+ continue;
107
+ }
108
+ if (branchName && !repo.aligned) {
109
+ packageSyncBlockers.push(`${repo.name} is on ${repo.branchName ?? "(detached)"} instead of ${branchName}.`);
110
+ }
111
+ }
61
112
  const preflight = collectCliPreflight({ cwd: effectiveCwd, requireAuth: false });
62
113
  const { configPath, keyPath } = getTreeseedMachineConfigPaths(effectiveCwd);
114
+ const workflowLock = inspectWorkflowLock(effectiveCwd);
115
+ const interruptedRuns = listInterruptedWorkflowRuns(effectiveCwd).map((journal) => ({
116
+ runId: journal.runId,
117
+ command: journal.command,
118
+ updatedAt: journal.updatedAt,
119
+ nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null
120
+ }));
121
+ const workflowBlockers = [];
122
+ if (workflowLock.active && workflowLock.lock) {
123
+ workflowBlockers.push(`Workflow lock active for ${workflowLock.lock.command} (${workflowLock.lock.runId}).`);
124
+ }
125
+ if (workflowLock.stale && workflowLock.lock) {
126
+ workflowBlockers.push(`Workflow lock is stale: ${workflowLock.staleReason}.`);
127
+ }
128
+ if (interruptedRuns.length > 0) {
129
+ workflowBlockers.push(`Interrupted workflow runs detected: ${interruptedRuns.map((run2) => run2.runId).join(", ")}.`);
130
+ }
63
131
  const state = {
64
132
  cwd: effectiveCwd,
65
133
  workspaceRoot,
@@ -70,6 +138,27 @@ function resolveTreeseedWorkflowState(cwd) {
70
138
  branchRole,
71
139
  environment: workflowEnvironmentForBranchRole(branchRole),
72
140
  dirtyWorktree,
141
+ workflowControl: {
142
+ lock: {
143
+ active: workflowLock.active,
144
+ stale: workflowLock.stale,
145
+ runId: workflowLock.lock?.runId ?? null,
146
+ command: workflowLock.lock?.command ?? null,
147
+ updatedAt: workflowLock.lock?.updatedAt ?? null,
148
+ staleReason: workflowLock.staleReason
149
+ },
150
+ interruptedRuns,
151
+ blockers: workflowBlockers
152
+ },
153
+ packageSync: {
154
+ mode: completePackageCheckout ? "recursive-workspace" : "root-only",
155
+ completeCheckout: completePackageCheckout,
156
+ expectedBranch: branchName,
157
+ aligned: packageSyncRepos.every((repo) => repo.aligned),
158
+ dirty: packageSyncRepos.some((repo) => repo.dirty),
159
+ repos: packageSyncRepos,
160
+ blockers: packageSyncBlockers
161
+ },
73
162
  preview: {
74
163
  enabled: false,
75
164
  url: null,
@@ -171,7 +260,27 @@ function recommendTreeseedNextSteps(state) {
171
260
  recommendations.push({ operation: "config", reason: "Bootstrap the local machine config and local environment files." });
172
261
  return recommendations;
173
262
  }
263
+ if (state.workflowControl.interruptedRuns.length > 0) {
264
+ recommendations.push({
265
+ operation: "resume",
266
+ reason: "Resume the most recent interrupted workflow run before making new branch changes.",
267
+ input: { runId: state.workflowControl.interruptedRuns[0].runId }
268
+ });
269
+ recommendations.push({ operation: "recover", reason: "Inspect active workflow locks and interrupted runs." });
270
+ return recommendations.slice(0, 3);
271
+ }
272
+ if (state.workflowControl.lock.active && state.workflowControl.lock.runId) {
273
+ recommendations.push({ operation: "recover", reason: "Inspect the active workflow lock before starting another mutating command." });
274
+ return recommendations.slice(0, 3);
275
+ }
174
276
  if (state.branchRole === "feature") {
277
+ if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
278
+ recommendations.push({
279
+ operation: "switch",
280
+ reason: "Realign the checked-out package repos to the current task branch before continuing.",
281
+ input: { branch: state.branchName }
282
+ });
283
+ }
175
284
  recommendations.push({ operation: "stage", reason: "Merge this task branch into staging and clean up branch artifacts.", input: { message: "describe the resolution" } });
176
285
  recommendations.push({ operation: "save", reason: "Persist, verify, and push the current task branch before or independently of staging it.", input: { message: "describe your change" } });
177
286
  if (state.preview.enabled && state.branchName) {
@@ -183,6 +292,13 @@ function recommendTreeseedNextSteps(state) {
183
292
  return recommendations.slice(0, 3);
184
293
  }
185
294
  if (state.branchRole === "staging") {
295
+ if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
296
+ recommendations.push({
297
+ operation: "switch",
298
+ reason: "Realign the checked-out package repos to staging before releasing.",
299
+ input: { branch: state.branchName }
300
+ });
301
+ }
186
302
  if (!state.persistentEnvironments.staging.initialized) {
187
303
  recommendations.push({ operation: "config", reason: "Initialize the staging environment before releasing.", input: { environment: ["staging"] } });
188
304
  } else {