@treeseed/sdk 0.6.9 → 0.6.11

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.
@@ -2,6 +2,25 @@ import type { TreeseedWorkflowMode } from './session.ts';
2
2
  export type TreeseedWorkflowRunCommand = 'switch' | 'save' | 'close' | 'stage' | 'release' | 'destroy';
3
3
  export type TreeseedWorkflowExecutionMode = 'execute' | 'plan';
4
4
  export type TreeseedWorkflowRunStatus = 'running' | 'failed' | 'completed';
5
+ export type TreeseedWorkflowRunClassificationState = 'resumable' | 'stale' | 'obsolete';
6
+ export type TreeseedWorkflowRunClassification = {
7
+ state: TreeseedWorkflowRunClassificationState;
8
+ reasons: string[];
9
+ classifiedAt: string;
10
+ archivedAt?: string | null;
11
+ };
12
+ export type TreeseedWorkflowGateCacheEntry = {
13
+ repo: string | null;
14
+ workflow: string;
15
+ headSha: string;
16
+ branch: string | null;
17
+ status: string;
18
+ conclusion: string | null;
19
+ runId: string | number | null;
20
+ url: string | null;
21
+ cachedAt: string;
22
+ result: Record<string, unknown>;
23
+ };
5
24
  export type TreeseedWorkflowRunStep = {
6
25
  id: string;
7
26
  description: string;
@@ -42,6 +61,8 @@ export type TreeseedWorkflowRunJournal = {
42
61
  at: string;
43
62
  };
44
63
  result: Record<string, unknown> | null;
64
+ classification?: TreeseedWorkflowRunClassification | null;
65
+ gateCache?: TreeseedWorkflowGateCacheEntry[];
45
66
  };
46
67
  export type TreeseedWorkflowLockRecord = {
47
68
  schemaVersion: 1;
@@ -78,6 +99,23 @@ export declare function releaseWorkflowLock(root: string, runId: string): boolea
78
99
  export declare function writeWorkflowRunJournal(root: string, journal: TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal;
79
100
  export declare function readWorkflowRunJournal(root: string, runId: string): TreeseedWorkflowRunJournal | null;
80
101
  export declare function updateWorkflowRunJournal(root: string, runId: string, updater: (journal: TreeseedWorkflowRunJournal) => TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal | null;
102
+ export declare function classifyWorkflowRunJournal(journal: TreeseedWorkflowRunJournal, options?: {
103
+ currentBranch?: string | null;
104
+ currentHeads?: Record<string, string | null | undefined>;
105
+ now?: string;
106
+ }): TreeseedWorkflowRunClassification;
107
+ export declare function classifyWorkflowRunJournals(root: string, options?: Parameters<typeof classifyWorkflowRunJournal>[1]): {
108
+ journal: TreeseedWorkflowRunJournal;
109
+ classification: TreeseedWorkflowRunClassification;
110
+ }[];
111
+ export declare function archiveWorkflowRun(root: string, runId: string, classification: TreeseedWorkflowRunClassification): TreeseedWorkflowRunJournal | null;
112
+ export declare function getCachedSuccessfulWorkflowGate(root: string, runId: string, gate: {
113
+ repository?: string | null;
114
+ workflow: string;
115
+ headSha: string;
116
+ branch?: string | null;
117
+ }): TreeseedWorkflowGateCacheEntry | null;
118
+ export declare function cacheWorkflowGateResult(root: string, runId: string, result: Record<string, unknown>): TreeseedWorkflowGateCacheEntry | null;
81
119
  export declare function createWorkflowRunJournal(root: string, options: {
82
120
  runId: string;
83
121
  command: TreeseedWorkflowRunCommand;
@@ -1,23 +1,45 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { hostname } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { dirname, resolve } from "node:path";
4
4
  const WORKFLOW_CONTROL_DIR = ".treeseed/workflow";
5
5
  const WORKFLOW_RUNS_DIR = `${WORKFLOW_CONTROL_DIR}/runs`;
6
+ const WORKTREE_METADATA_PATH = ".treeseed/worktree.json";
6
7
  const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1e3;
8
+ const WORKFLOW_RUN_STORAGE_ROOTS = /* @__PURE__ */ new Map();
7
9
  function nowIso() {
8
10
  return (/* @__PURE__ */ new Date()).toISOString();
9
11
  }
10
- function workflowControlRoot(root) {
11
- return resolve(root, WORKFLOW_CONTROL_DIR);
12
+ function managedWorktreePrimaryRoot(root) {
13
+ const metadataPath = resolve(root, WORKTREE_METADATA_PATH);
14
+ if (!existsSync(metadataPath)) return null;
15
+ try {
16
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
17
+ return metadata.kind === "treeseed.workflow.worktree" && typeof metadata.primaryRoot === "string" ? metadata.primaryRoot : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ function workflowStorageRoot(root, runId) {
23
+ if (runId && WORKFLOW_RUN_STORAGE_ROOTS.has(runId)) {
24
+ return WORKFLOW_RUN_STORAGE_ROOTS.get(runId);
25
+ }
26
+ const storageRoot = managedWorktreePrimaryRoot(root) ?? root;
27
+ if (runId) {
28
+ WORKFLOW_RUN_STORAGE_ROOTS.set(runId, storageRoot);
29
+ }
30
+ return storageRoot;
31
+ }
32
+ function workflowControlRoot(root, runId) {
33
+ return resolve(workflowStorageRoot(root, runId), WORKFLOW_CONTROL_DIR);
12
34
  }
13
- function workflowRunsRoot(root) {
14
- return resolve(root, WORKFLOW_RUNS_DIR);
35
+ function workflowRunsRoot(root, runId) {
36
+ return resolve(workflowStorageRoot(root, runId), WORKFLOW_RUNS_DIR);
15
37
  }
16
- function workflowLockPath(root) {
17
- return resolve(root, WORKFLOW_CONTROL_DIR, "lock.json");
38
+ function workflowLockPath(root, runId) {
39
+ return resolve(workflowStorageRoot(root, runId), WORKFLOW_CONTROL_DIR, "lock.json");
18
40
  }
19
41
  function workflowRunPath(root, runId) {
20
- return resolve(root, WORKFLOW_RUNS_DIR, `${runId}.json`);
42
+ return resolve(workflowStorageRoot(root, runId), WORKFLOW_RUNS_DIR, `${runId}.json`);
21
43
  }
22
44
  function resolveGitDir(root) {
23
45
  const gitPath = resolve(root, ".git");
@@ -46,12 +68,13 @@ function ensureWorkflowExcludeRule(root) {
46
68
  if (current.includes(pattern)) {
47
69
  return;
48
70
  }
71
+ mkdirSync(dirname(excludePath), { recursive: true });
49
72
  writeFileSync(excludePath, `${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${pattern}
50
73
  `, "utf8");
51
74
  }
52
- function ensureWorkflowControlDirs(root) {
53
- const controlDir = workflowControlRoot(root);
54
- const runsDir = workflowRunsRoot(root);
75
+ function ensureWorkflowControlDirs(root, runId) {
76
+ const controlDir = workflowControlRoot(root, runId);
77
+ const runsDir = workflowRunsRoot(root, runId);
55
78
  mkdirSync(runsDir, { recursive: true });
56
79
  ensureWorkflowExcludeRule(root);
57
80
  writeFileSync(resolve(controlDir, ".gitignore"), "*\n!.gitignore\n!runs/\nruns/*\n!runs/.gitignore\n", "utf8");
@@ -114,7 +137,7 @@ function inspectWorkflowLock(root) {
114
137
  };
115
138
  }
116
139
  function acquireWorkflowLock(root, command, runId) {
117
- const dirs = ensureWorkflowControlDirs(root);
140
+ const dirs = ensureWorkflowControlDirs(root, runId);
118
141
  const inspection = inspectWorkflowLock(root);
119
142
  if (inspection.active && inspection.lock && inspection.lock.runId !== runId) {
120
143
  return {
@@ -148,7 +171,7 @@ function acquireWorkflowLock(root, command, runId) {
148
171
  };
149
172
  }
150
173
  function refreshWorkflowLock(root, runId) {
151
- const path = workflowLockPath(root);
174
+ const path = workflowLockPath(root, runId);
152
175
  const lock = safeJsonParse(path);
153
176
  if (!lock || lock.runId !== runId) {
154
177
  return null;
@@ -164,7 +187,7 @@ function refreshWorkflowLock(root, runId) {
164
187
  return updated;
165
188
  }
166
189
  function releaseWorkflowLock(root, runId) {
167
- const path = workflowLockPath(root);
190
+ const path = workflowLockPath(root, runId);
168
191
  const lock = safeJsonParse(path);
169
192
  if (!lock || lock.runId !== runId) {
170
193
  return false;
@@ -173,7 +196,7 @@ function releaseWorkflowLock(root, runId) {
173
196
  return true;
174
197
  }
175
198
  function writeWorkflowRunJournal(root, journal) {
176
- ensureWorkflowControlDirs(root);
199
+ ensureWorkflowControlDirs(root, journal.runId);
177
200
  writeFileSync(workflowRunPath(root, journal.runId), `${JSON.stringify(journal, null, 2)}
178
201
  `, "utf8");
179
202
  return journal;
@@ -193,6 +216,145 @@ function updateWorkflowRunJournal(root, runId, updater) {
193
216
  });
194
217
  return readWorkflowRunJournal(root, runId);
195
218
  }
219
+ function stringRecord(value) {
220
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
221
+ }
222
+ function journalReleasePlanHead(plan, repoName) {
223
+ if (repoName === "@treeseed/market") {
224
+ const rootRepo = stringRecord(plan.rootRepo);
225
+ return typeof rootRepo?.commitSha === "string" ? rootRepo.commitSha : null;
226
+ }
227
+ const repos = Array.isArray(plan.repos) ? plan.repos : [];
228
+ for (const repo of repos) {
229
+ const record = stringRecord(repo);
230
+ if (record?.name === repoName) {
231
+ return typeof record.commitSha === "string" ? record.commitSha : null;
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+ function selectedReleasePackageNames(plan) {
237
+ const selection = stringRecord(plan.packageSelection);
238
+ const selected = Array.isArray(selection?.selected) ? selection.selected.filter((name) => typeof name === "string") : [];
239
+ return selected;
240
+ }
241
+ function classifyWorkflowRunJournal(journal, options = {}) {
242
+ const reasons = [];
243
+ const now = options.now ?? nowIso();
244
+ if (journal.classification?.archivedAt) {
245
+ return {
246
+ state: "obsolete",
247
+ reasons: journal.classification.reasons.length > 0 ? journal.classification.reasons : ["workflow run was archived"],
248
+ classifiedAt: now,
249
+ archivedAt: journal.classification.archivedAt
250
+ };
251
+ }
252
+ if (journal.status !== "failed") {
253
+ return {
254
+ state: "obsolete",
255
+ reasons: [`workflow run status is ${journal.status}`],
256
+ classifiedAt: now
257
+ };
258
+ }
259
+ if (!journal.resumable) {
260
+ return {
261
+ state: "obsolete",
262
+ reasons: ["workflow run is not marked resumable"],
263
+ classifiedAt: now
264
+ };
265
+ }
266
+ if (journal.command === "switch" && journal.steps.every((step) => step.status === "pending")) {
267
+ return {
268
+ state: "obsolete",
269
+ reasons: ["switch failed before completing any checkout step; rerun switch instead"],
270
+ classifiedAt: now
271
+ };
272
+ }
273
+ if (options.currentBranch && journal.session.branchName && options.currentBranch !== journal.session.branchName) {
274
+ reasons.push(`current branch ${options.currentBranch} does not match journal branch ${journal.session.branchName}`);
275
+ }
276
+ if (journal.command === "release" && options.currentHeads) {
277
+ const releasePlan = stringRecord(journal.steps.find((step) => step.id === "release-plan")?.data);
278
+ if (releasePlan) {
279
+ const rootHead = options.currentHeads["@treeseed/market"];
280
+ const plannedRootHead = journalReleasePlanHead(releasePlan, "@treeseed/market");
281
+ if (rootHead && plannedRootHead && rootHead !== plannedRootHead) {
282
+ reasons.push(`market head changed from ${plannedRootHead} to ${rootHead}`);
283
+ }
284
+ for (const name of selectedReleasePackageNames(releasePlan)) {
285
+ const currentHead = options.currentHeads[name];
286
+ const plannedHead = journalReleasePlanHead(releasePlan, name);
287
+ if (currentHead && plannedHead && currentHead !== plannedHead) {
288
+ reasons.push(`${name} head changed from ${plannedHead} to ${currentHead}`);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return {
294
+ state: reasons.length > 0 ? "stale" : "resumable",
295
+ reasons: reasons.length > 0 ? reasons : ["workflow run can be resumed"],
296
+ classifiedAt: now
297
+ };
298
+ }
299
+ function classifyWorkflowRunJournals(root, options = {}) {
300
+ return listWorkflowRunJournals(root).map((journal) => ({
301
+ journal,
302
+ classification: classifyWorkflowRunJournal(journal, options)
303
+ }));
304
+ }
305
+ function archiveWorkflowRun(root, runId, classification) {
306
+ const archivedAt = nowIso();
307
+ return updateWorkflowRunJournal(root, runId, (journal) => ({
308
+ ...journal,
309
+ resumable: false,
310
+ classification: {
311
+ ...classification,
312
+ state: classification.state === "resumable" ? "stale" : classification.state,
313
+ archivedAt,
314
+ classifiedAt: archivedAt
315
+ }
316
+ }));
317
+ }
318
+ function gateCacheMatches(entry, gate) {
319
+ return entry.workflow === gate.workflow && entry.headSha === gate.headSha && (gate.repository == null || entry.repo === gate.repository) && (gate.branch == null || entry.branch === gate.branch);
320
+ }
321
+ function getCachedSuccessfulWorkflowGate(root, runId, gate) {
322
+ const journal = readWorkflowRunJournal(root, runId);
323
+ const cache = journal?.gateCache ?? [];
324
+ return cache.find((entry) => gateCacheMatches(entry, gate) && entry.status === "completed" && entry.conclusion === "success") ?? null;
325
+ }
326
+ function cacheWorkflowGateResult(root, runId, result) {
327
+ const workflow = typeof result.workflow === "string" ? result.workflow : null;
328
+ const headSha = typeof result.headSha === "string" ? result.headSha : null;
329
+ if (!workflow || !headSha) {
330
+ return null;
331
+ }
332
+ const entry = {
333
+ repo: typeof result.repository === "string" ? result.repository : null,
334
+ workflow,
335
+ headSha,
336
+ branch: typeof result.branch === "string" ? result.branch : null,
337
+ status: typeof result.status === "string" ? result.status : "unknown",
338
+ conclusion: typeof result.conclusion === "string" ? result.conclusion : null,
339
+ runId: typeof result.runId === "string" || typeof result.runId === "number" ? result.runId : null,
340
+ url: typeof result.url === "string" ? result.url : null,
341
+ cachedAt: nowIso(),
342
+ result
343
+ };
344
+ updateWorkflowRunJournal(root, runId, (journal) => ({
345
+ ...journal,
346
+ gateCache: [
347
+ ...(journal.gateCache ?? []).filter((candidate) => !gateCacheMatches(candidate, {
348
+ repository: entry.repo,
349
+ workflow: entry.workflow,
350
+ headSha: entry.headSha,
351
+ branch: entry.branch
352
+ })),
353
+ entry
354
+ ]
355
+ }));
356
+ return entry;
357
+ }
196
358
  function createWorkflowRunJournal(root, options) {
197
359
  const timestamp = nowIso();
198
360
  return writeWorkflowRunJournal(root, {
@@ -229,8 +391,13 @@ function listInterruptedWorkflowRuns(root) {
229
391
  }
230
392
  export {
231
393
  acquireWorkflowLock,
394
+ archiveWorkflowRun,
395
+ cacheWorkflowGateResult,
396
+ classifyWorkflowRunJournal,
397
+ classifyWorkflowRunJournals,
232
398
  createWorkflowRunJournal,
233
399
  generateWorkflowRunId,
400
+ getCachedSuccessfulWorkflowGate,
234
401
  inspectWorkflowLock,
235
402
  listInterruptedWorkflowRuns,
236
403
  listWorkflowRunJournals,
@@ -0,0 +1,39 @@
1
+ import type { TreeseedWorkflowWorktreeMode } from '../workflow.ts';
2
+ export type ManagedWorkflowWorktreeMetadata = {
3
+ schemaVersion: 1;
4
+ kind: 'treeseed.workflow.worktree';
5
+ branch: string;
6
+ worktreePath: string;
7
+ primaryRoot: string;
8
+ ownerMode: 'agent' | 'human' | 'explicit';
9
+ createdAt: string;
10
+ lastUsedAt: string;
11
+ };
12
+ export type ManagedWorkflowWorktreeResult = ManagedWorkflowWorktreeMetadata & {
13
+ created: boolean;
14
+ resumed: boolean;
15
+ };
16
+ export declare function effectiveWorkflowWorktreeMode(mode: TreeseedWorkflowWorktreeMode | undefined, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): "off" | "on";
17
+ export declare function isManagedWorkflowWorktree(root: string): boolean;
18
+ export declare function managedWorkflowWorktreeMetadata(root: string): ManagedWorkflowWorktreeMetadata | null;
19
+ export declare function plannedManagedWorkflowWorktreePath(root: string, branchName: string): string;
20
+ export declare function primaryWorkspaceRoot(root: string): any;
21
+ export declare function ensureManagedWorkflowWorktree({ root, branchName, mode, env, }: {
22
+ root: string;
23
+ branchName: string;
24
+ mode?: TreeseedWorkflowWorktreeMode;
25
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
26
+ }): ManagedWorkflowWorktreeResult;
27
+ export declare function removeManagedWorkflowWorktree(root: string): {
28
+ removed: boolean;
29
+ reason: string;
30
+ worktreePath?: undefined;
31
+ primaryRoot?: undefined;
32
+ branch?: undefined;
33
+ } | {
34
+ removed: boolean;
35
+ worktreePath: string;
36
+ primaryRoot: string;
37
+ branch: string;
38
+ reason?: undefined;
39
+ };
@@ -0,0 +1,224 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { sortWorkspacePackages, workspacePackages, workspaceRoot } from "../operations/services/workspace-tools.js";
6
+ import { repoRoot } from "../operations/services/workspace-save.js";
7
+ const WORKTREE_METADATA_PATH = ".treeseed/worktree.json";
8
+ const WORKTREE_ROOT = ".treeseed/worktrees";
9
+ function nowIso() {
10
+ return (/* @__PURE__ */ new Date()).toISOString();
11
+ }
12
+ function runGit(args, { cwd, capture = true, allowFailure = false }) {
13
+ const result = spawnSync("git", args, {
14
+ cwd,
15
+ stdio: capture ? "pipe" : "inherit",
16
+ encoding: "utf8"
17
+ });
18
+ if (result.status !== 0 && !allowFailure) {
19
+ throw new Error(result.stderr?.trim() || result.stdout?.trim() || `git ${args.join(" ")} failed`);
20
+ }
21
+ return result;
22
+ }
23
+ function slugifyBranch(branchName) {
24
+ return branchName.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "branch";
25
+ }
26
+ function worktreeDirectoryName(branchName) {
27
+ const hash = createHash("sha256").update(branchName).digest("hex").slice(0, 10);
28
+ return `${slugifyBranch(branchName)}-${hash}`;
29
+ }
30
+ function parseWorktreeList(output) {
31
+ const entries = [];
32
+ let current = null;
33
+ for (const line of output.split(/\r?\n/u)) {
34
+ if (line.startsWith("worktree ")) {
35
+ if (current) entries.push(current);
36
+ current = { worktree: line.slice("worktree ".length).trim(), branch: null };
37
+ continue;
38
+ }
39
+ if (current && line.startsWith("branch ")) {
40
+ current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//u, "");
41
+ }
42
+ }
43
+ if (current) entries.push(current);
44
+ return entries;
45
+ }
46
+ function worktreeList(repoDir) {
47
+ return parseWorktreeList(runGit(["worktree", "list", "--porcelain"], { cwd: repoDir }).stdout ?? "");
48
+ }
49
+ function currentBranchName(repoDir) {
50
+ return runGit(["branch", "--show-current"], { cwd: repoDir, allowFailure: true }).stdout?.trim() || null;
51
+ }
52
+ function localBranchExists(repoDir, branchName) {
53
+ return runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: repoDir, allowFailure: true }).status === 0;
54
+ }
55
+ function remoteBranchExists(repoDir, branchName) {
56
+ return Boolean(runGit(["ls-remote", "--heads", "origin", branchName], { cwd: repoDir, allowFailure: true }).stdout?.trim());
57
+ }
58
+ function checkoutManagedPackageBranch(repoDir, branchName) {
59
+ runGit(["fetch", "origin"], { cwd: repoDir, allowFailure: true });
60
+ const baseBranch = remoteBranchExists(repoDir, "staging") ? "staging" : remoteBranchExists(repoDir, "main") ? "main" : null;
61
+ if (localBranchExists(repoDir, branchName)) {
62
+ runGit(["checkout", branchName], { cwd: repoDir });
63
+ } else if (remoteBranchExists(repoDir, branchName)) {
64
+ runGit(["checkout", "-b", branchName, `origin/${branchName}`], { cwd: repoDir });
65
+ } else if (baseBranch) {
66
+ runGit(["checkout", "-b", branchName, `origin/${baseBranch}`], { cwd: repoDir });
67
+ } else {
68
+ return;
69
+ }
70
+ if (remoteBranchExists(repoDir, branchName)) {
71
+ runGit(["merge", "--ff-only", `origin/${branchName}`], { cwd: repoDir, allowFailure: true });
72
+ }
73
+ }
74
+ function checkoutManagedPackageBranches(worktreePath, branchName) {
75
+ for (const pkg of sortWorkspacePackages(workspacePackages(worktreePath))) {
76
+ if (!pkg.name?.startsWith("@treeseed/")) continue;
77
+ if (!existsSync(resolve(pkg.dir, ".git"))) continue;
78
+ checkoutManagedPackageBranch(pkg.dir, branchName);
79
+ }
80
+ }
81
+ function metadataPath(root) {
82
+ return resolve(root, WORKTREE_METADATA_PATH);
83
+ }
84
+ function readMetadata(root) {
85
+ const filePath = metadataPath(root);
86
+ if (!existsSync(filePath)) return null;
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
89
+ return parsed?.kind === "treeseed.workflow.worktree" ? parsed : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ function writeMetadata(root, metadata) {
95
+ const filePath = metadataPath(root);
96
+ mkdirSync(dirname(filePath), { recursive: true });
97
+ writeFileSync(filePath, `${JSON.stringify(metadata, null, 2)}
98
+ `, "utf8");
99
+ }
100
+ function ensureManagedWorktreeExclude(root) {
101
+ const commonGitDir = runGit(["rev-parse", "--git-common-dir"], { cwd: root }).stdout?.trim();
102
+ if (!commonGitDir) return;
103
+ const absolutePath = resolve(root, commonGitDir, "info", "exclude");
104
+ const current = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : "";
105
+ const patterns = ["/.treeseed/worktree.json", "/.treeseed/workflow/", "/.treeseed/worktrees/"];
106
+ const missing = patterns.filter((pattern) => !current.includes(pattern));
107
+ if (missing.length === 0) return;
108
+ mkdirSync(dirname(absolutePath), { recursive: true });
109
+ writeFileSync(
110
+ absolutePath,
111
+ `${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${missing.join("\n")}
112
+ `,
113
+ "utf8"
114
+ );
115
+ }
116
+ function effectiveWorkflowWorktreeMode(mode, env = process.env) {
117
+ const envMode = String(env.TREESEED_WORKTREE_MODE ?? "").trim().toLowerCase();
118
+ if (mode === "on" || envMode === "on" || envMode === "true" || envMode === "1") return "on";
119
+ if (mode === "off" || envMode === "off" || envMode === "false" || envMode === "0") return "off";
120
+ const agentMarkers = [
121
+ env.TREESEED_WORKFLOW_ACTOR === "agent",
122
+ env.TREESEED_AGENT_ID,
123
+ env.TREESEED_AGENT_RUN_ID,
124
+ env.CODEX_AGENT_ID,
125
+ env.CODEX_TASK_ID
126
+ ].some(Boolean);
127
+ return agentMarkers ? "on" : "off";
128
+ }
129
+ function isManagedWorkflowWorktree(root) {
130
+ return readMetadata(root) != null;
131
+ }
132
+ function managedWorkflowWorktreeMetadata(root) {
133
+ return readMetadata(root);
134
+ }
135
+ function plannedManagedWorkflowWorktreePath(root, branchName) {
136
+ const primaryRoot = primaryWorkspaceRoot(root);
137
+ return resolve(primaryRoot, WORKTREE_ROOT, worktreeDirectoryName(branchName));
138
+ }
139
+ function primaryWorkspaceRoot(root) {
140
+ const gitRoot = repoRoot(workspaceRoot(root));
141
+ const entries = worktreeList(gitRoot);
142
+ const first = entries[0]?.worktree;
143
+ return first ? workspaceRoot(first) : workspaceRoot(root);
144
+ }
145
+ function ensureManagedWorkflowWorktree({
146
+ root,
147
+ branchName,
148
+ mode = "auto",
149
+ env = process.env
150
+ }) {
151
+ const effectiveMode = effectiveWorkflowWorktreeMode(mode, env);
152
+ if (effectiveMode !== "on") {
153
+ throw new Error("Managed workflow worktree mode is disabled.");
154
+ }
155
+ const primaryRoot = primaryWorkspaceRoot(root);
156
+ const primaryGitRoot = repoRoot(primaryRoot);
157
+ const worktreePath = plannedManagedWorkflowWorktreePath(primaryRoot, branchName);
158
+ const entries = worktreeList(primaryGitRoot);
159
+ const existingEntry = entries.find((entry) => entry.worktree === worktreePath);
160
+ const duplicate = entries.find((entry) => entry.branch === branchName && entry.worktree !== worktreePath);
161
+ if (duplicate) {
162
+ throw new Error(`Branch ${branchName} is already checked out in ${duplicate.worktree}.`);
163
+ }
164
+ const created = !existingEntry && !existsSync(worktreePath);
165
+ runGit(["fetch", "origin"], { cwd: primaryGitRoot });
166
+ const branchExists = remoteBranchExists(primaryGitRoot, branchName);
167
+ const baseRef = branchExists ? `origin/${branchName}` : "origin/staging";
168
+ if (created) {
169
+ mkdirSync(dirname(worktreePath), { recursive: true });
170
+ runGit(["worktree", "add", "--detach", worktreePath, baseRef], { cwd: primaryGitRoot });
171
+ } else if (!existingEntry) {
172
+ runGit(["worktree", "prune"], { cwd: primaryGitRoot, allowFailure: true });
173
+ } else if (!currentBranchName(worktreePath)) {
174
+ runGit(["fetch", "origin"], { cwd: worktreePath, allowFailure: true });
175
+ runGit(["reset", "--hard", baseRef], { cwd: worktreePath });
176
+ }
177
+ runGit(["submodule", "update", "--init", "--recursive"], { cwd: worktreePath });
178
+ checkoutManagedPackageBranches(worktreePath, branchName);
179
+ ensureManagedWorktreeExclude(worktreePath);
180
+ const timestamp = nowIso();
181
+ const previous = readMetadata(worktreePath);
182
+ const metadata = {
183
+ schemaVersion: 1,
184
+ kind: "treeseed.workflow.worktree",
185
+ branch: branchName,
186
+ worktreePath,
187
+ primaryRoot,
188
+ ownerMode: mode === "on" ? "explicit" : "agent",
189
+ createdAt: previous?.createdAt ?? timestamp,
190
+ lastUsedAt: timestamp
191
+ };
192
+ writeMetadata(worktreePath, metadata);
193
+ return {
194
+ ...metadata,
195
+ created,
196
+ resumed: !created
197
+ };
198
+ }
199
+ function removeManagedWorkflowWorktree(root) {
200
+ const metadata = readMetadata(root);
201
+ if (!metadata) {
202
+ return { removed: false, reason: "not-managed" };
203
+ }
204
+ const primaryRoot = metadata.primaryRoot;
205
+ const primaryGitRoot = repoRoot(primaryRoot);
206
+ process.chdir(primaryRoot);
207
+ runGit(["worktree", "remove", "--force", metadata.worktreePath], { cwd: primaryGitRoot });
208
+ rmSync(metadata.worktreePath, { recursive: true, force: true });
209
+ return {
210
+ removed: true,
211
+ worktreePath: metadata.worktreePath,
212
+ primaryRoot,
213
+ branch: metadata.branch
214
+ };
215
+ }
216
+ export {
217
+ effectiveWorkflowWorktreeMode,
218
+ ensureManagedWorkflowWorktree,
219
+ isManagedWorkflowWorktree,
220
+ managedWorkflowWorktreeMetadata,
221
+ plannedManagedWorkflowWorktreePath,
222
+ primaryWorkspaceRoot,
223
+ removeManagedWorkflowWorktree
224
+ };
@@ -62,6 +62,19 @@ export type TreeseedWorkflowState = {
62
62
  updatedAt: string;
63
63
  nextStep: string | null;
64
64
  }>;
65
+ staleRuns: Array<{
66
+ runId: string;
67
+ command: string;
68
+ updatedAt: string;
69
+ nextStep: string | null;
70
+ reasons: string[];
71
+ }>;
72
+ obsoleteRuns: Array<{
73
+ runId: string;
74
+ command: string;
75
+ updatedAt: string;
76
+ reasons: string[];
77
+ }>;
65
78
  blockers: string[];
66
79
  };
67
80
  packageSync: {
@@ -24,7 +24,7 @@ import { collectCliPreflight } from "./operations/services/workspace-preflight.j
24
24
  import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
25
25
  import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
26
26
  import { inspectWorkspaceDependencyMode } from "./operations/services/workspace-dependency-mode.js";
27
- import { inspectWorkflowLock, listInterruptedWorkflowRuns } from "./workflow/runs.js";
27
+ import { classifyWorkflowRunJournals, inspectWorkflowLock } from "./workflow/runs.js";
28
28
  import { createTreeseedManagedToolEnv, resolveTreeseedToolCommand } from "./managed-dependencies.js";
29
29
  import {
30
30
  resolveTreeseedWorkflowPaths,
@@ -248,6 +248,13 @@ function knownRemoteTrackingBranchExists(repoDir, branchName) {
248
248
  return false;
249
249
  }
250
250
  }
251
+ function safeHeadCommit(repoDir) {
252
+ try {
253
+ return run("git", ["rev-parse", "HEAD"], { cwd: repoDir, capture: true }).trim();
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
251
258
  function resolveLocalStatusUrl(deployConfig) {
252
259
  return deployConfig.surfaces?.web?.localBaseUrl ?? deployConfig.surfaces?.api?.localBaseUrl ?? Object.values(deployConfig.services ?? {}).find((service) => service?.enabled !== false && service.environments?.local?.baseUrl)?.environments?.local?.baseUrl ?? null;
253
260
  }
@@ -312,12 +319,36 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
312
319
  const runnerHostId = typeof marketSettings?.runnerHostId === "string" && marketSettings.runnerHostId.trim() ? marketSettings.runnerHostId.trim() : typeof marketSettings?.projectId === "string" && marketSettings.projectId.trim() ? `market-runner:${marketSettings.projectId.trim()}` : null;
313
320
  const runnerSession = runnerHostId ? safeResolveRemoteSession(effectiveCwd, runnerHostId) : null;
314
321
  const workflowLock = inspectWorkflowLock(effectiveCwd);
315
- const interruptedRuns = listInterruptedWorkflowRuns(effectiveCwd).map((journal) => ({
322
+ const workflowRunHeads = {};
323
+ if (root) {
324
+ workflowRunHeads["@treeseed/market"] = safeHeadCommit(root);
325
+ }
326
+ for (const pkg of completePackageCheckout ? workspacePackages(effectiveCwd).filter((entry) => entry.name?.startsWith("@treeseed/")) : []) {
327
+ workflowRunHeads[pkg.name] = safeHeadCommit(pkg.dir);
328
+ }
329
+ const classifiedRuns = classifyWorkflowRunJournals(effectiveCwd, {
330
+ currentBranch: branchName,
331
+ currentHeads: workflowRunHeads
332
+ });
333
+ const interruptedRuns = classifiedRuns.filter((entry) => entry.classification.state === "resumable").map(({ journal }) => ({
316
334
  runId: journal.runId,
317
335
  command: journal.command,
318
336
  updatedAt: journal.updatedAt,
319
337
  nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null
320
338
  }));
339
+ const staleRuns = classifiedRuns.filter((entry) => entry.classification.state === "stale").map(({ journal, classification }) => ({
340
+ runId: journal.runId,
341
+ command: journal.command,
342
+ updatedAt: journal.updatedAt,
343
+ nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null,
344
+ reasons: classification.reasons
345
+ }));
346
+ const obsoleteRuns = classifiedRuns.filter((entry) => entry.classification.state === "obsolete").map(({ journal, classification }) => ({
347
+ runId: journal.runId,
348
+ command: journal.command,
349
+ updatedAt: journal.updatedAt,
350
+ reasons: classification.reasons
351
+ }));
321
352
  const workflowBlockers = [];
322
353
  if (workflowLock.active && workflowLock.lock) {
323
354
  workflowBlockers.push(`Workflow lock active for ${workflowLock.lock.command} (${workflowLock.lock.runId}).`);
@@ -348,6 +379,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
348
379
  staleReason: workflowLock.staleReason
349
380
  },
350
381
  interruptedRuns,
382
+ staleRuns,
383
+ obsoleteRuns,
351
384
  blockers: workflowBlockers
352
385
  },
353
386
  packageSync: {