@ulysses-ai/create-workspace 0.16.0-beta.0 → 0.17.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,23 @@
11
11
  // Workspace-first removal silently deletes the nested project worktrees'
12
12
  // .git files and leaves orphan worktree records in the project repos.
13
13
  // The safe order keeps both sides of the relationship in sync.
14
+ //
15
+ // State discovery is defensive: the session tracker (session.md) may have
16
+ // been stripped by /complete-work Step 7 before this script runs, leaving
17
+ // no `repos:` or `branch:` to read. When that happens we discover repos
18
+ // from the work-sessions/{name}/workspace/repos/ directory listing and the
19
+ // branch from `git branch --show-current` on the workspace worktree —
20
+ // BEFORE removing anything. Without that, the per-repo loops silently
21
+ // no-op and the script reports success while leaving orphans behind
22
+ // (gh:119).
23
+ //
24
+ // `success: true` means VERIFIED: every project worktree record is gone
25
+ // (no prunable entries left over), every local branch is deleted, and the
26
+ // session folder is removed. The script post-verifies all of these and
27
+ // surfaces any leftover state as an error rather than swallowing it.
14
28
  import '../lib/require-node.mjs';
15
29
  import { execSync } from 'child_process';
16
- import { existsSync } from 'fs';
30
+ import { existsSync, readdirSync, statSync } from 'fs';
17
31
  import { join } from 'path';
18
32
  import {
19
33
  getWorkspaceRoot,
@@ -36,74 +50,198 @@ if (!sessionName) {
36
50
  }
37
51
 
38
52
  const root = getWorkspaceRoot(import.meta.url);
39
- const tracker = readSessionTracker(root, sessionName);
40
- const repos = normalizeRepos(tracker?.repos);
41
- const branch = tracker?.branch;
42
53
  const reposDir = join(root, 'repos');
43
54
  const sessionFolder = sessionFolderPath(root, sessionName);
44
55
  const wsWorktree = join(sessionFolder, 'workspace');
45
56
 
46
57
  const removed = [];
58
+ const skipped = [];
47
59
  const errors = [];
48
60
 
49
- // Step 1: Remove each project worktree FIRST, from its project repo
50
- for (const repo of repos) {
51
- const projWorktree = join(wsWorktree, 'repos', repo);
52
- const repoDir = join(reposDir, repo);
53
- if (existsSync(projWorktree)) {
61
+ // === Discovery: where session.md is silent or missing, fall back to disk ===
62
+ //
63
+ // The tracker may have been stripped before this script runs. Read whatever
64
+ // is still there, then fill gaps from the live worktree state.
65
+
66
+ const tracker = readSessionTracker(root, sessionName);
67
+ let repos = normalizeRepos(tracker?.repos);
68
+ let branch = tracker?.branch || null;
69
+
70
+ // If repos is empty, discover from work-sessions/{name}/workspace/repos/.
71
+ // That directory is the workspace worktree's nested-project-worktrees dir;
72
+ // each entry is one project repo this session checked out.
73
+ if (repos.length === 0) {
74
+ const nestedReposDir = join(wsWorktree, 'repos');
75
+ if (existsSync(nestedReposDir)) {
54
76
  try {
55
- execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
56
- removed.push(`project worktree ${repo}`);
77
+ const discovered = readdirSync(nestedReposDir).filter((entry) => {
78
+ try {
79
+ return statSync(join(nestedReposDir, entry)).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ });
84
+ if (discovered.length > 0) {
85
+ repos = discovered;
86
+ skipped.push({
87
+ step: 'discovery',
88
+ reason: `Tracker missing repos; discovered ${discovered.length} from ${nestedReposDir}: ${discovered.join(', ')}`,
89
+ });
90
+ }
57
91
  } catch (err) {
58
- errors.push(`Failed to remove ${repo} worktree: ${err.message}`);
92
+ skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
59
93
  }
60
94
  }
61
95
  }
62
96
 
63
- // Step 2: Remove the workspace worktree AFTER project worktrees are gone
97
+ // If branch is missing, ask the workspace worktree itself.
98
+ if (!branch && existsSync(wsWorktree)) {
99
+ try {
100
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: wsWorktree, stdio: 'pipe', encoding: 'utf-8' }).trim();
101
+ if (branch) {
102
+ skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
103
+ }
104
+ } catch (err) {
105
+ skipped.push({ step: 'discovery', reason: `Failed to read branch from ${wsWorktree}: ${err.message}` });
106
+ }
107
+ }
108
+
109
+ // === Step 1: Remove each project worktree FIRST, from its project repo ===
110
+ for (const repo of repos) {
111
+ const projWorktree = join(wsWorktree, 'repos', repo);
112
+ const repoDir = join(reposDir, repo);
113
+ if (!existsSync(projWorktree)) {
114
+ skipped.push({ step: 'remove-project-worktree', repo, reason: `${projWorktree} does not exist` });
115
+ continue;
116
+ }
117
+ if (!existsSync(repoDir)) {
118
+ errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
119
+ continue;
120
+ }
121
+ try {
122
+ execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
123
+ removed.push(`project worktree ${repo}`);
124
+ } catch (err) {
125
+ errors.push(`Failed to remove ${repo} worktree: ${err.message.trim()}`);
126
+ }
127
+ }
128
+
129
+ // === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
64
130
  if (existsSync(wsWorktree)) {
65
131
  try {
66
132
  execSync(`git worktree remove "${wsWorktree}" --force`, { cwd: root, stdio: 'pipe' });
67
133
  removed.push('workspace worktree');
68
134
  } catch (err) {
69
- errors.push(`Failed to remove workspace worktree: ${err.message}`);
135
+ errors.push(`Failed to remove workspace worktree: ${err.message.trim()}`);
70
136
  }
137
+ } else {
138
+ skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
71
139
  }
72
140
 
73
- // Step 3: Prune each project repo to mop up orphans from any prior misuses
141
+ // === Step 3: Prune each project repo to mop up orphans ===
74
142
  for (const repo of repos) {
75
143
  const repoDir = join(reposDir, repo);
144
+ if (!existsSync(repoDir)) continue;
76
145
  try {
77
146
  execSync('git worktree prune', { cwd: repoDir, stdio: 'pipe' });
78
- } catch {
79
- // Non-fatal — prune is a safety net
147
+ } catch (err) {
148
+ // Prune is a safety net, but if it fails on a repo we touched, surface
149
+ // it — verification below will catch leftover orphans either way.
150
+ errors.push(`Prune failed in ${repo}: ${err.message.trim()}`);
80
151
  }
81
152
  }
82
153
 
83
- // Step 4: Delete local branches
154
+ // === Step 4: Delete local branches ===
84
155
  if (branch) {
85
156
  for (const repo of repos) {
86
157
  const repoDir = join(reposDir, repo);
158
+ if (!existsSync(repoDir)) continue;
87
159
  try {
88
- execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
160
+ execSync(`git branch --list "${branch}"`, { cwd: repoDir, stdio: 'pipe', encoding: 'utf-8' });
89
161
  } catch {
90
- // Non-fatal branch may already be gone or refuse to delete
162
+ continue; // Repo broken; verification will catch downstream impact.
163
+ }
164
+ const exists = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
165
+ if (!exists) continue; // Already gone (e.g., gh pr merge --delete-branch did it).
166
+ try {
167
+ execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
168
+ } catch (err) {
169
+ errors.push(`Failed to delete branch ${branch} in ${repo}: ${err.message.trim()}`);
91
170
  }
92
171
  }
172
+ // Same for the workspace repo (root).
93
173
  try {
94
- execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
174
+ const exists = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
175
+ if (exists) {
176
+ try {
177
+ execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
178
+ } catch (err) {
179
+ errors.push(`Failed to delete branch ${branch} in workspace repo: ${err.message.trim()}`);
180
+ }
181
+ }
95
182
  } catch {
96
- // Non-fatal
183
+ // workspace root not a git repo — unusual, skip.
97
184
  }
98
185
  }
99
186
 
100
- // Step 5: Delete the whole work-sessions/{name}/ folder. The session.md,
101
- // specs, plans, and any local-only artifacts vanish. Their content was
102
- // archived into release notes by /complete-work before this script ran.
103
- deleteSessionFolder(root, sessionName);
187
+ // === Step 5: Delete the whole work-sessions/{name}/ folder ===
188
+ try {
189
+ deleteSessionFolder(root, sessionName);
190
+ } catch (err) {
191
+ errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
192
+ }
193
+
194
+ // === Post-verification: success means VERIFIED, not "no try/catch threw" ===
195
+ //
196
+ // Without these checks, an empty repos list (the gh:119 root cause) lets
197
+ // every silent skip add up to a "success" output while leaving orphans
198
+ // behind. The verification turns silent skips into honest errors.
199
+
200
+ if (existsSync(sessionFolder)) {
201
+ errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
202
+ }
203
+
204
+ const wsPath = wsWorktree; // canonical path the worktree had
205
+ for (const repo of repos) {
206
+ const repoDir = join(reposDir, repo);
207
+ if (!existsSync(repoDir)) continue;
208
+ let wtList = '';
209
+ try {
210
+ wtList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf-8' });
211
+ } catch (err) {
212
+ errors.push(`Could not list worktrees in ${repo}: ${err.message.trim()}`);
213
+ continue;
214
+ }
215
+ if (wtList.includes('prunable')) {
216
+ errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
217
+ }
218
+ if (wtList.includes(wsPath)) {
219
+ errors.push(`${repo} still has a worktree record referencing the session path`);
220
+ }
221
+ if (branch) {
222
+ const branchStill = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
223
+ if (branchStill) {
224
+ errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
225
+ }
226
+ }
227
+ }
228
+
229
+ if (branch) {
230
+ try {
231
+ const branchStill = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
232
+ if (branchStill) {
233
+ errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
234
+ }
235
+ } catch {
236
+ // not a git repo — already noted
237
+ }
238
+ }
104
239
 
105
240
  console.log(JSON.stringify({
106
241
  success: errors.length === 0,
107
242
  removed,
243
+ skipped: skipped.length > 0 ? skipped : undefined,
108
244
  errors: errors.length > 0 ? errors : undefined,
109
245
  }));
246
+
247
+ if (errors.length > 0) process.exit(1);
@@ -0,0 +1,210 @@
1
+ // GitHub forge adapter. Wraps the `gh` CLI via an injectable spawnFn so
2
+ // tests can mock without spawning a real subprocess (mirrors the pattern
3
+ // in trackers/github-issues.mjs).
4
+ //
5
+ // All operations target a single default `repo` resolved at construction
6
+ // time from `config.repo` (e.g. `"owner/name"`) or from the local git
7
+ // origin remote when `config.repo` is unset or `"auto"`. Per-call `repo`
8
+ // overrides allow targeting a different repo when needed.
9
+
10
+ import '../../lib/require-node.mjs';
11
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
12
+ import {
13
+ PrNotFound,
14
+ ReleaseNotFound,
15
+ WorkflowNotFound,
16
+ MergeRejected,
17
+ } from './interface.mjs';
18
+
19
+ const PR_VIEW_FIELDS = 'number,url,state,title,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,isDraft,mergedAt';
20
+
21
+ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
22
+ const defaultRepo = resolveRepo(config, spawnFn);
23
+
24
+ function gh(args, { input } = {}) {
25
+ const result = spawnFn('gh', args, {
26
+ input,
27
+ encoding: 'utf-8',
28
+ stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
29
+ });
30
+ return result;
31
+ }
32
+
33
+ function ghOrThrow(args, opts) {
34
+ const result = gh(args, opts);
35
+ if (result.status !== 0) {
36
+ throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
37
+ }
38
+ return result.stdout || '';
39
+ }
40
+
41
+ function repoFor(override) {
42
+ return override || defaultRepo;
43
+ }
44
+
45
+ async function prCreate({ title, body = '', draft = false, base, head, repo }) {
46
+ if (!title) throw new Error('prCreate: title is required');
47
+ const args = ['pr', 'create', '--repo', repoFor(repo), '--title', title, '--body-file', '-'];
48
+ if (draft) args.push('--draft');
49
+ if (base) args.push('--base', base);
50
+ if (head) args.push('--head', head);
51
+ const stdout = ghOrThrow(args, { input: body }).trim();
52
+ // gh pr create prints the PR URL on success; sometimes preceded by warnings.
53
+ const url = stdout.split('\n').filter(Boolean).pop();
54
+ const m = url.match(/\/pull\/(\d+)/);
55
+ if (!m) throw new Error(`Could not parse PR number from gh output: ${stdout}`);
56
+ const number = parseInt(m[1], 10);
57
+ return { id: `${repoFor(repo)}#${number}`, url, number };
58
+ }
59
+
60
+ async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
61
+ if (!id) throw new Error('prMerge: id is required');
62
+ const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
63
+ const args = ['pr', 'merge', String(number), '--repo', parsedRepo];
64
+ switch (strategy) {
65
+ case 'merge': args.push('--merge'); break;
66
+ case 'squash': args.push('--squash'); break;
67
+ case 'rebase': args.push('--rebase'); break;
68
+ default: throw new Error(`prMerge: unknown strategy: ${strategy}`);
69
+ }
70
+ if (deleteBranch) args.push('--delete-branch');
71
+ const result = gh(args);
72
+ if (result.status !== 0) {
73
+ const stderr = (result.stderr || '').trim();
74
+ // Distinguish "not found" from "rejected" so callers can react.
75
+ if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
76
+ throw new PrNotFound(id);
77
+ }
78
+ throw new MergeRejected(id, stderr || 'gh pr merge exited non-zero');
79
+ }
80
+ return { merged: true, url: `https://github.com/${parsedRepo}/pull/${number}` };
81
+ }
82
+
83
+ async function prView({ id, repo, json }) {
84
+ if (!id) throw new Error('prView: id is required');
85
+ const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
86
+ const fields = json || PR_VIEW_FIELDS;
87
+ const result = gh(['pr', 'view', String(number), '--repo', parsedRepo, '--json', fields]);
88
+ if (result.status !== 0) {
89
+ const stderr = (result.stderr || '').trim();
90
+ if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
91
+ throw new PrNotFound(id);
92
+ }
93
+ throw new Error(`gh pr view failed: ${stderr}`);
94
+ }
95
+ const raw = JSON.parse(result.stdout);
96
+ return {
97
+ id,
98
+ number: raw.number,
99
+ url: raw.url,
100
+ state: raw.state,
101
+ title: raw.title,
102
+ mergeable: raw.mergeable,
103
+ mergeStateStatus: raw.mergeStateStatus,
104
+ reviewDecision: raw.reviewDecision,
105
+ headRefName: raw.headRefName,
106
+ baseRefName: raw.baseRefName,
107
+ isDraft: raw.isDraft,
108
+ mergedAt: raw.mergedAt,
109
+ _raw: raw,
110
+ };
111
+ }
112
+
113
+ async function releaseView({ tag, repo }) {
114
+ if (!tag) throw new Error('releaseView: tag is required');
115
+ const target = repoFor(repo);
116
+ const result = gh(['release', 'view', tag, '--repo', target, '--json', 'name,tagName,url,publishedAt,isDraft,isPrerelease']);
117
+ if (result.status !== 0) {
118
+ const stderr = (result.stderr || '').trim();
119
+ if (/release\s+not\s+found|not\s+found/i.test(stderr)) {
120
+ throw new ReleaseNotFound(tag);
121
+ }
122
+ throw new Error(`gh release view failed: ${stderr}`);
123
+ }
124
+ const raw = JSON.parse(result.stdout);
125
+ return {
126
+ tag: raw.tagName,
127
+ name: raw.name,
128
+ url: raw.url,
129
+ publishedAt: raw.publishedAt,
130
+ isDraft: raw.isDraft,
131
+ isPrerelease: raw.isPrerelease,
132
+ };
133
+ }
134
+
135
+ async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
136
+ if (!workflow) throw new Error('workflowRunFind: workflow is required');
137
+ const target = repoFor(repo);
138
+ const args = [
139
+ 'run', 'list',
140
+ '--repo', target,
141
+ '--workflow', workflow,
142
+ '--limit', String(limit),
143
+ '--json', 'databaseId,status,conclusion,url,headBranch,createdAt',
144
+ ];
145
+ if (branch) args.push('--branch', branch);
146
+ const result = gh(args);
147
+ if (result.status !== 0) {
148
+ throw new Error(`gh run list failed: ${(result.stderr || '').trim()}`);
149
+ }
150
+ const runs = JSON.parse(result.stdout);
151
+ if (runs.length === 0) return null;
152
+ const first = runs[0];
153
+ return {
154
+ runId: String(first.databaseId),
155
+ status: first.status,
156
+ conclusion: first.conclusion,
157
+ url: first.url,
158
+ branch: first.headBranch,
159
+ createdAt: first.createdAt,
160
+ };
161
+ }
162
+
163
+ async function workflowRunWatch({ runId, repo, exitStatus = false }) {
164
+ if (!runId) throw new Error('workflowRunWatch: runId is required');
165
+ const target = repoFor(repo);
166
+ const args = ['run', 'watch', String(runId), '--repo', target];
167
+ if (exitStatus) args.push('--exit-status');
168
+ const result = gh(args);
169
+ if (result.status === 0) return { exitCode: 0 };
170
+ // Distinguish "couldn't find" from "ran but failed".
171
+ const stderr = (result.stderr || '').trim();
172
+ if (/not\s+found|could\s+not\s+find/i.test(stderr)) {
173
+ throw new WorkflowNotFound({ runId });
174
+ }
175
+ // `--exit-status` makes gh exit non-zero on workflow failure; surface
176
+ // that without throwing so callers can record the failure URL.
177
+ return { exitCode: result.status, stderr };
178
+ }
179
+
180
+ return {
181
+ prCreate,
182
+ prMerge,
183
+ prView,
184
+ releaseView,
185
+ workflowRunFind,
186
+ workflowRunWatch,
187
+ get identity() { return `github:${defaultRepo}`; },
188
+ };
189
+ }
190
+
191
+ // "owner/name#NUMBER" or just NUMBER (defaulting to the adapter's repo).
192
+ function parsePrId(id, fallbackRepo) {
193
+ if (typeof id === 'number') return { number: id, repo: fallbackRepo };
194
+ const m1 = String(id).match(/^(?<repo>[^#\s]+\/[^#\s]+)#(?<number>\d+)$/);
195
+ if (m1) return { number: parseInt(m1.groups.number, 10), repo: m1.groups.repo };
196
+ const m2 = String(id).match(/^#?(\d+)$/);
197
+ if (m2) return { number: parseInt(m2[1], 10), repo: fallbackRepo };
198
+ throw new Error(`Unparseable PR id: ${id}`);
199
+ }
200
+
201
+ function resolveRepo(config, spawnFn) {
202
+ if (config?.repo && config.repo !== 'auto') return config.repo;
203
+ const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
204
+ if (result.status !== 0) {
205
+ throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
206
+ }
207
+ const m = result.stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
208
+ if (!m) throw new Error(`Cannot parse GitHub remote: ${result.stdout.trim()}`);
209
+ return `${m[1]}/${m[2]}`;
210
+ }
@@ -0,0 +1,19 @@
1
+ // GitLab forge adapter — stub. Reserved as the next sibling of github.mjs
2
+ // so the discovery pattern stays uniform: workspaces opting into GitLab
3
+ // set `workspace.forge.type: gitlab` in workspace.json and get a clear
4
+ // "not implemented" error pointing at the gap, rather than silently
5
+ // routing through GitHub.
6
+ //
7
+ // When implemented, this adapter wraps the `glab` CLI the same way
8
+ // github.mjs wraps `gh`: same method surface (prCreate, prMerge, prView,
9
+ // releaseView, workflowRunFind, workflowRunWatch), same spawnFn-injectable
10
+ // shape for testability, same error types from interface.mjs.
11
+
12
+ import { ForgeError } from './interface.mjs';
13
+
14
+ export function createGitlabAdapter(config /* , options */) {
15
+ throw new ForgeError(
16
+ 'GitLab forge adapter is not implemented yet. Set workspace.forge.type to "github", or contribute a glab-based adapter at .claude/scripts/forges/gitlab.mjs following the shape of github.mjs.',
17
+ 'NOT_IMPLEMENTED',
18
+ );
19
+ }
@@ -0,0 +1,113 @@
1
+ // Forge adapter interface. Skills import only from this module.
2
+ //
3
+ // Where `trackers/` covers issue lifecycle (issues, comments, labels,
4
+ // milestones), `forges/` covers cross-cutting repo-host operations:
5
+ // pull requests, releases, and workflow runs. The two abstractions are
6
+ // intentionally separate — a workspace could in principle mix
7
+ // (e.g. `github-issues` tracker + `gitlab` forge), though the common
8
+ // case is forge = same host as tracker.
9
+ //
10
+ // Method contracts:
11
+ //
12
+ // prCreate({ title, body, draft = false, base, head, repo? })
13
+ // → { id, url, number }
14
+ // prMerge({ id, strategy = 'merge', deleteBranch = false, repo? })
15
+ // → { merged: true, url }
16
+ // strategy: 'merge' | 'squash' | 'rebase'
17
+ // prView({ id, repo?, json? })
18
+ // → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title }
19
+ // json may name additional fields to pass through
20
+ // releaseView({ tag, repo? })
21
+ // → { tag, url, name, publishedAt }
22
+ // throws ReleaseNotFound if the tag has no release
23
+ // workflowRunFind({ workflow, branch, repo?, limit = 1 })
24
+ // → { runId, status, conclusion, url } | null
25
+ // workflowRunWatch({ runId, repo?, exitStatus = false })
26
+ // → { exitCode }
27
+ // exitStatus: when true, the underlying command exits non-zero on
28
+ // workflow failure; the adapter still returns the exit code rather
29
+ // than throwing — callers decide how to handle a failed run.
30
+ //
31
+ // `repo` defaults: each adapter resolves a default repo at construction
32
+ // time (e.g. from `workspace.forge.repo` or the local git origin remote);
33
+ // callers pass `repo` only when targeting a different one.
34
+ //
35
+ // All methods are async and may throw `ForgeError` subclasses on
36
+ // adapter-detectable failures. Raw spawn failures throw `Error`.
37
+
38
+ import '../../lib/require-node.mjs';
39
+ import { createGithubAdapter } from './github.mjs';
40
+ import { createGitlabAdapter } from './gitlab.mjs';
41
+
42
+ export class ForgeError extends Error {
43
+ constructor(message, code) {
44
+ super(message);
45
+ this.name = 'ForgeError';
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ export class PrNotFound extends ForgeError {
51
+ constructor(id) {
52
+ super(`Pull request not found: ${id}`, 'PR_NOT_FOUND');
53
+ this.name = 'PrNotFound';
54
+ this.id = id;
55
+ }
56
+ }
57
+
58
+ export class ReleaseNotFound extends ForgeError {
59
+ constructor(tag) {
60
+ super(`Release not found for tag: ${tag}`, 'RELEASE_NOT_FOUND');
61
+ this.name = 'ReleaseNotFound';
62
+ this.tag = tag;
63
+ }
64
+ }
65
+
66
+ export class WorkflowNotFound extends ForgeError {
67
+ constructor(query) {
68
+ super(`Workflow run not found: ${JSON.stringify(query)}`, 'WORKFLOW_NOT_FOUND');
69
+ this.name = 'WorkflowNotFound';
70
+ this.query = query;
71
+ }
72
+ }
73
+
74
+ export class MergeRejected extends ForgeError {
75
+ constructor(id, reason) {
76
+ super(`Merge rejected for ${id}: ${reason}`, 'MERGE_REJECTED');
77
+ this.name = 'MergeRejected';
78
+ this.id = id;
79
+ this.reason = reason;
80
+ }
81
+ }
82
+
83
+ // createForge takes the `workspace.forge` config block. If the block is
84
+ // absent (`undefined`/`null`), default to GitHub — this matches the
85
+ // migration story documented in `.claude/rules/forge-operations.md`:
86
+ // existing workspaces predate the field, so an unset value means
87
+ // "behave as you always have." A workspace that wants to opt out of
88
+ // forge operations entirely should set `workspace.forge: false`;
89
+ // callers passing `false` will get a no-op throw on every method.
90
+ export function createForge(config, options = {}) {
91
+ if (config === false) {
92
+ throw new ForgeError(
93
+ 'Forge operations disabled — set workspace.forge in workspace.json to enable.',
94
+ 'FORGE_DISABLED',
95
+ );
96
+ }
97
+ const resolved = config ?? { type: 'github' };
98
+ if (typeof resolved !== 'object') {
99
+ throw new ForgeError(
100
+ `Invalid workspace.forge config: expected object, got ${typeof resolved}`,
101
+ 'INVALID_CONFIG',
102
+ );
103
+ }
104
+ const type = resolved.type ?? 'github';
105
+ switch (type) {
106
+ case 'github':
107
+ return createGithubAdapter(resolved, options);
108
+ case 'gitlab':
109
+ return createGitlabAdapter(resolved, options);
110
+ default:
111
+ throw new ForgeError(`Unknown forge type: ${type}`, 'UNKNOWN_TYPE');
112
+ }
113
+ }
@@ -92,25 +92,17 @@
92
92
  }
93
93
  ]
94
94
  }
95
- ],
96
- "WorktreeCreate": [
97
- {
98
- "hooks": [
99
- {
100
- "type": "command",
101
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/worktree-create.mjs",
102
- "timeout": 5000,
103
- "statusMessage": "Checking for stale worktrees..."
104
- }
105
- ]
106
- }
107
95
  ]
108
96
  },
97
+ "worktree": {
98
+ "baseRef": "head"
99
+ },
109
100
  "permissions": {
110
101
  "allow": [
111
102
  "Bash(git:*)",
112
103
  "Bash(ls:*)"
113
- ]
104
+ ],
105
+ "deny": []
114
106
  },
115
107
  "enabledPlugins": {
116
108
  "playwright@claude-plugins-official": false