rl-workspaces 0.0.0-stage → 0.5.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +703 -2
  3. package/bin/rl +54 -0
  4. package/config.example.zsh +9 -0
  5. package/dist/access.js +40 -0
  6. package/dist/activity.js +90 -0
  7. package/dist/agent-claude.js +16 -0
  8. package/dist/agent-codex.js +22 -0
  9. package/dist/agent-launch.js +74 -0
  10. package/dist/agent-presets.js +45 -0
  11. package/dist/agent-registry.js +8 -0
  12. package/dist/agent-resume.js +96 -0
  13. package/dist/agent-runner.js +366 -0
  14. package/dist/agent-workflows.js +231 -0
  15. package/dist/agents.js +73 -0
  16. package/dist/automation-config.js +242 -0
  17. package/dist/base.js +66 -0
  18. package/dist/cleanup-path.js +33 -0
  19. package/dist/cli.js +57 -0
  20. package/dist/codex-sessions.js +188 -0
  21. package/dist/command-context.js +67 -0
  22. package/dist/common.js +262 -0
  23. package/dist/delete.js +113 -0
  24. package/dist/display.js +134 -0
  25. package/dist/doctor.js +145 -0
  26. package/dist/git-safety.js +24 -0
  27. package/dist/git-workflow.js +183 -0
  28. package/dist/help.js +726 -0
  29. package/dist/hooks.js +159 -0
  30. package/dist/json.js +34 -0
  31. package/dist/mcp.js +336 -0
  32. package/dist/picker.js +182 -0
  33. package/dist/pr-discovery.js +223 -0
  34. package/dist/pr-update.js +419 -0
  35. package/dist/pr-workflow.js +161 -0
  36. package/dist/repos.js +537 -0
  37. package/dist/review-policy.js +150 -0
  38. package/dist/session-workflows.js +616 -0
  39. package/dist/session.js +12 -0
  40. package/dist/state.js +250 -0
  41. package/dist/storage.js +366 -0
  42. package/dist/store.js +379 -0
  43. package/dist/summary.js +164 -0
  44. package/dist/task-state.js +65 -0
  45. package/dist/task.js +192 -0
  46. package/dist/templates.js +4 -0
  47. package/dist/workflow-config.js +177 -0
  48. package/dist/workflow.js +211 -0
  49. package/lib/rl.zsh +330 -0
  50. package/node_modules/graceful-fs/LICENSE +15 -0
  51. package/node_modules/graceful-fs/README.md +143 -0
  52. package/node_modules/graceful-fs/clone.js +23 -0
  53. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  54. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  55. package/node_modules/graceful-fs/package.json +53 -0
  56. package/node_modules/graceful-fs/polyfills.js +355 -0
  57. package/node_modules/proper-lockfile/LICENSE +21 -0
  58. package/node_modules/proper-lockfile/README.md +183 -0
  59. package/node_modules/proper-lockfile/index.js +40 -0
  60. package/node_modules/proper-lockfile/lib/adapter.js +85 -0
  61. package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
  62. package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
  63. package/node_modules/proper-lockfile/package.json +71 -0
  64. package/node_modules/retry/.npmignore +3 -0
  65. package/node_modules/retry/.travis.yml +15 -0
  66. package/node_modules/retry/License +21 -0
  67. package/node_modules/retry/Makefile +18 -0
  68. package/node_modules/retry/README.md +227 -0
  69. package/node_modules/retry/equation.gif +0 -0
  70. package/node_modules/retry/example/dns.js +31 -0
  71. package/node_modules/retry/example/stop.js +40 -0
  72. package/node_modules/retry/index.js +1 -0
  73. package/node_modules/retry/lib/retry.js +100 -0
  74. package/node_modules/retry/lib/retry_operation.js +158 -0
  75. package/node_modules/retry/package.json +32 -0
  76. package/node_modules/retry/test/common.js +10 -0
  77. package/node_modules/retry/test/integration/test-forever.js +24 -0
  78. package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
  79. package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
  80. package/node_modules/retry/test/integration/test-timeouts.js +69 -0
  81. package/node_modules/signal-exit/LICENSE.txt +16 -0
  82. package/node_modules/signal-exit/README.md +39 -0
  83. package/node_modules/signal-exit/index.js +202 -0
  84. package/node_modules/signal-exit/package.json +38 -0
  85. package/node_modules/signal-exit/signals.js +53 -0
  86. package/package.json +57 -4
  87. package/shell/rl.zsh +11 -0
package/dist/task.js ADDED
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { args, arity, canonical, json, now, object, timestamp, } from "./common.js";
5
+ import { agentAdapter } from "./agent-registry.js";
6
+ import { resumeAgent } from "./agent-resume.js";
7
+ import { currentSessionId } from "./session.js";
8
+ import { commandContext, contextOptions, resolveWorkspace, writeWorkspaceOutput, } from "./command-context.js";
9
+ import { HANDOFF_FIELDS, handoffNotes, handoffText, } from "./task-state.js";
10
+ export const PAUSE_FIELDS = [...HANDOFF_FIELDS, "agent", "sessionId"];
11
+ export async function pauseTask(store, input, cwd = process.cwd()) {
12
+ if (!object(input) ||
13
+ Object.keys(input).some((key) => !PAUSE_FIELDS.includes(key)))
14
+ throw new Error("Unexpected task handoff argument");
15
+ const notes = handoffNotes(input);
16
+ if (input.agent !== undefined && typeof input.agent !== "string")
17
+ throw new Error("agent must be codex or claude");
18
+ const agent = agentAdapter(input.agent ?? "codex").name;
19
+ const sid = currentSessionId(agent, input.sessionId);
20
+ return store.locked(() => {
21
+ const [file, data] = store.current(cwd);
22
+ const stamp = now();
23
+ const handoff = {
24
+ ...notes,
25
+ id: randomUUID(),
26
+ agent,
27
+ sessionId: sid,
28
+ savedAt: stamp,
29
+ };
30
+ store.associateSession(file, data, agent, sid, stamp);
31
+ const task = data.task ?? { state: "paused", handoffs: [] };
32
+ task.handoffs.push(handoff);
33
+ task.state = "paused";
34
+ delete task.continuedAt;
35
+ data.task = task;
36
+ store.touch(file, data, stamp);
37
+ return handoff;
38
+ });
39
+ }
40
+ function selectTask(context, selector) {
41
+ let records = context.store
42
+ .records()
43
+ .filter(([, data]) => data.status === "active" && data.task);
44
+ if (selector !== undefined) {
45
+ records = records.filter(([, data]) => data.id === selector ||
46
+ data.branch === selector ||
47
+ path.relative(context.managedDir, data.worktree) === selector ||
48
+ (path.isAbsolute(selector) && canonical(selector) === data.worktree));
49
+ if (records.length !== 1)
50
+ throw new Error(records.length
51
+ ? "Ambiguous task; use the full branch or workspace path"
52
+ : `No saved handoff for workspace ${selector}`);
53
+ }
54
+ else {
55
+ records.sort(([, a], [, b]) => {
56
+ const paused = Number(b.task.state === "paused") - Number(a.task.state === "paused");
57
+ if (paused)
58
+ return paused;
59
+ const at = timestamp(a.task.continuedAt ?? a.task.handoffs.at(-1).savedAt);
60
+ const bt = timestamp(b.task.continuedAt ?? b.task.handoffs.at(-1).savedAt);
61
+ return at > bt ? -1 : at < bt ? 1 : a.key.localeCompare(b.key);
62
+ });
63
+ }
64
+ const selected = records[0];
65
+ if (!selected)
66
+ throw new Error("No saved task handoffs in this repository. Ask your agent to pause the task first");
67
+ // Fail visibly on a missing or switched workspace instead of silently choosing another task.
68
+ const current = context.store.current(selected[1].worktree);
69
+ if (current[0] !== selected[0])
70
+ throw new Error("Task workspace instance changed");
71
+ return current;
72
+ }
73
+ export async function continueTask(context, options = {}) {
74
+ const [file, data, handoff, previousState, previousTime, stamp] = await context.store.locked(() => {
75
+ const [file, data] = selectTask(context, options.workspace);
76
+ const handoff = data.task.handoffs.at(-1);
77
+ if (!data.sessions[handoff.agent].some((item) => item.id === handoff.sessionId))
78
+ throw new Error("Handoff session is missing from workspace history");
79
+ const stamp = now();
80
+ const previousState = data.task.state;
81
+ const previousTime = data.task.continuedAt;
82
+ // Validate shell output before changing task state.
83
+ writeWorkspaceOutput(data.worktree, options.output);
84
+ data.task.state = "active";
85
+ data.task.continuedAt = stamp;
86
+ context.store.touch(file, data, stamp);
87
+ return [file, data, handoff, previousState, previousTime, stamp];
88
+ });
89
+ const prompt = [
90
+ `Continue RL task ${data.id} on branch ${data.branch}.`,
91
+ handoffText(handoff),
92
+ `Read the full shared task documents before continuing:\n${path.join(path.dirname(file), "context.md")}\n${path.join(path.dirname(file), "progress.md")}`,
93
+ "Recheck the current code and Git state. The handoff records what the previous agent reported; it does not establish that checks still pass. Follow the next steps and record new milestones. When asked to pause again, save a new handoff with rl_task_pause or rl pause.",
94
+ ].join("\n\n");
95
+ console.error(handoffText(handoff));
96
+ if (options.noAgent)
97
+ return 0;
98
+ const restore = () => context.store.locked(() => {
99
+ const [currentFile, current] = context.store.current(data.worktree);
100
+ const task = current.task;
101
+ if (currentFile !== file ||
102
+ task?.handoffs.at(-1)?.id !== handoff.id ||
103
+ task.state !== "active" ||
104
+ task.continuedAt !== stamp)
105
+ return;
106
+ task.state = previousState;
107
+ if (previousTime === undefined)
108
+ delete task.continuedAt;
109
+ else
110
+ task.continuedAt = previousTime;
111
+ context.store.save(file, current);
112
+ });
113
+ try {
114
+ const code = await resumeAgent(context.store, handoff.agent, {
115
+ cwd: data.worktree,
116
+ sessionId: handoff.sessionId,
117
+ prompt,
118
+ expectedKey: data.key,
119
+ });
120
+ if (code !== 0)
121
+ await restore();
122
+ return code;
123
+ }
124
+ catch (error) {
125
+ // Retain a newer pause written by the running agent or another process.
126
+ try {
127
+ await restore();
128
+ }
129
+ catch {
130
+ /* Preserve the original launch error. */
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+ export async function main(argv) {
136
+ const { values, positionals } = args(argv, [
137
+ ...contextOptions,
138
+ ...HANDOFF_FIELDS,
139
+ "agent",
140
+ "session",
141
+ "workspace",
142
+ "file",
143
+ "output-path",
144
+ ], ["json", "no-agent"]);
145
+ const context = commandContext(values);
146
+ const [command, ...rest] = positionals;
147
+ const supplied = Object.keys(values).filter((key) => !contextOptions.includes(key));
148
+ if (command === "pause") {
149
+ arity(rest, 0);
150
+ if (supplied.some((key) => ![
151
+ ...HANDOFF_FIELDS,
152
+ "agent",
153
+ "session",
154
+ "workspace",
155
+ "file",
156
+ "json",
157
+ ].includes(key)))
158
+ throw new Error("Unsupported pause option");
159
+ if (values.file !== undefined &&
160
+ supplied.some((key) => [...HANDOFF_FIELDS, "agent", "session"].includes(key)))
161
+ throw new Error("Use --file or handoff fields, not both");
162
+ const target = resolveWorkspace(context, typeof values.workspace === "string" ? values.workspace : undefined);
163
+ const input = typeof values.file === "string"
164
+ ? JSON.parse(fs.readFileSync(values.file === "-" ? 0 : values.file, "utf8"))
165
+ : Object.fromEntries([...HANDOFF_FIELDS, "agent", "session"]
166
+ .filter((key) => values[key] !== undefined)
167
+ .map((key) => [
168
+ key === "session" ? "sessionId" : key,
169
+ values[key],
170
+ ]));
171
+ const handoff = await pauseTask(context.store, input, target.path);
172
+ if (values.json)
173
+ json(handoff);
174
+ else
175
+ console.log(`Paused ${target.branch}. Handoff saved; return with rl continue.`);
176
+ return 0;
177
+ }
178
+ if (command !== "continue")
179
+ throw new Error("Usage: rl pause | rl continue [workspace] [--no-agent]");
180
+ arity(rest, 0, 1);
181
+ if (supplied.some((key) => !["workspace", "no-agent", "output-path"].includes(key)) ||
182
+ (rest.length && values.workspace !== undefined))
183
+ throw new Error("Usage: rl continue [workspace] [--no-agent]");
184
+ return continueTask(context, {
185
+ workspace: rest[0] ??
186
+ (typeof values.workspace === "string" ? values.workspace : undefined),
187
+ noAgent: !!values["no-agent"],
188
+ output: typeof values["output-path"] === "string"
189
+ ? values["output-path"]
190
+ : undefined,
191
+ });
192
+ }
@@ -0,0 +1,4 @@
1
+ export const NOTE_TEMPLATES = {
2
+ "context.md": "# Feature and domain documentation\n\n## Purpose and scope\nDescribe the user problem, intended behavior, and what is outside this feature.\n\n## Domain rules and acceptance criteria\nRecord terminology, constraints, examples, and observable completion criteria.\n\n## Design decisions\nRecord decisions and their rationale, including alternatives when relevant.\n\n## References\nLink specifications, issues, relevant code, and related documentation.\n",
3
+ "progress.md": "# Implementation documentation\n\n## Completed\nRecord implemented behavior with concrete file or symbol references.\n\n## Remaining work\nKeep an actionable checklist; distinguish planned work from completed work.\n\n## Implementation details\nExplain the flow, important modules, APIs/data structures, and integration points.\n\n## Validation\nRecord checks actually run, their results, and gaps that remain unverified.\n\n## Blockers and open questions\nRecord unresolved decisions, known issues, and dependencies.\n\n## Next steps\nDescribe where the next agent or developer should continue.\n\n## Progress log\nAppend dated milestone updates here when several agents are working concurrently.\n",
4
+ };
@@ -0,0 +1,177 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { execute, git, gitEnv, inside } from "./common.js";
5
+ import { isJsonObject, readJsonObject } from "./json.js";
6
+ import { validateCleanupPath } from "./cleanup-path.js";
7
+ export const WORKFLOW_FILE = ".rl/workflows.json";
8
+ const RESERVED_DIRECTORIES = new Set([".git", ".rl", ".codex", ".claude"]);
9
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+ const ENV_PREFIX = /^(APP_|PUBLIC_|VITE_|NEXT_PUBLIC_|REACT_APP_)/;
11
+ const ENV_NAMES = new Set(["PORT", "NODE_ENV", "DATABASE_URL", "REDIS_URL"]);
12
+ const PORT_START = 10000;
13
+ const PORT_COUNT = 40000;
14
+ const CONFIG_KEYS = new Set(["copy", "clean", "env", "onCreate", "onOpen"]);
15
+ export const EMPTY_WORKFLOW = {
16
+ copy: [],
17
+ clean: [],
18
+ env: {},
19
+ onCreate: [],
20
+ onOpen: [],
21
+ };
22
+ export function readWorkflowConfig(repo) {
23
+ const file = path.join(repo, WORKFLOW_FILE);
24
+ if (fs
25
+ .lstatSync(path.dirname(file), { throwIfNoEntry: false })
26
+ ?.isSymbolicLink() ||
27
+ fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink())
28
+ throw new Error("Workflow configuration must not be a symlink");
29
+ if (!fs.existsSync(file))
30
+ return { ...EMPTY_WORKFLOW };
31
+ const data = readJsonObject(file);
32
+ for (const key of Object.keys(data))
33
+ if (!CONFIG_KEYS.has(key))
34
+ throw new Error(`Unknown workflow setting: ${key}`);
35
+ const copy = data.copy ?? [];
36
+ if (!Array.isArray(copy) ||
37
+ !copy.every((item) => typeof item === "string" && item.length > 0))
38
+ throw new Error("Workflow copy must be an array of relative paths");
39
+ const env = {};
40
+ if (data.env !== undefined) {
41
+ if (!isJsonObject(data.env))
42
+ throw new Error("Workflow env must be an object of strings");
43
+ for (const [key, value] of Object.entries(data.env)) {
44
+ if (!ENV_NAME.test(key) ||
45
+ (!ENV_PREFIX.test(key) && !ENV_NAMES.has(key)) ||
46
+ typeof value !== "string" ||
47
+ value.includes("\0"))
48
+ throw new Error(`Unsupported workflow environment variable: ${key}. Use APP_, PUBLIC_, VITE_, NEXT_PUBLIC_, REACT_APP_ prefixes or PORT, NODE_ENV, DATABASE_URL, REDIS_URL`);
49
+ env[key] = value;
50
+ }
51
+ }
52
+ const cleanup = data.clean ?? [];
53
+ if (!Array.isArray(cleanup))
54
+ throw new Error("Workflow clean must be an array of relative directory paths");
55
+ const clean = cleanup.map((item) => {
56
+ validateCleanupPath(item);
57
+ return item;
58
+ });
59
+ const result = { copy, clean, env, onCreate: [], onOpen: [] };
60
+ for (const event of ["onCreate", "onOpen"]) {
61
+ const commands = data[event] ?? [];
62
+ if (!Array.isArray(commands))
63
+ throw new Error(`Workflow ${event} must be an array of argv arrays`);
64
+ for (const command of commands) {
65
+ if (!Array.isArray(command) ||
66
+ !command.length ||
67
+ !command.every((argument) => typeof argument === "string" && !argument.includes("\0")) ||
68
+ !command[0])
69
+ throw new Error(`Workflow ${event} commands must be nonempty argv arrays`);
70
+ result[event].push(command);
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ export function workflowEnvironment(repo, workspace, config) {
76
+ const hash = createHash("sha256")
77
+ .update(repo)
78
+ .update("\0")
79
+ .update(workspace)
80
+ .digest();
81
+ const inherited = gitEnv();
82
+ delete inherited.RL_SHELL_OUTPUT;
83
+ delete inherited.RL_INTERNAL_SHELL;
84
+ return {
85
+ ...inherited,
86
+ ...config.env,
87
+ RL_REPO: "",
88
+ RL_REPO_ROOT: repo,
89
+ RL_WORKSPACE: workspace,
90
+ RL_PORT: String(PORT_START + (hash.readUInt32BE(0) % PORT_COUNT)),
91
+ };
92
+ }
93
+ export function containedPath(root, relative) {
94
+ const components = relative.split(/[\\/]/);
95
+ if (path.isAbsolute(relative) ||
96
+ components.some((part) => !part ||
97
+ part === "." ||
98
+ part === ".." ||
99
+ RESERVED_DIRECTORIES.has(part.toLowerCase())) ||
100
+ relative.includes("\0"))
101
+ throw new Error(`Unsafe workflow copy path: ${relative}`);
102
+ const target = path.resolve(root, relative);
103
+ if (!inside(target, root))
104
+ throw new Error(`Copy path escapes workspace: ${relative}`);
105
+ let current = root;
106
+ for (const component of components) {
107
+ current = path.join(current, component);
108
+ if (fs.lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink())
109
+ throw new Error(`Workflow copy does not support symlinks: ${current}`);
110
+ }
111
+ return target;
112
+ }
113
+ function collectCopies(repo, workspace, relative, files, stateDir) {
114
+ const source = containedPath(repo, relative);
115
+ const destination = containedPath(workspace, relative);
116
+ if (stateDir &&
117
+ [source, destination].some((file) => file === stateDir || inside(file, stateDir)))
118
+ throw new Error(`Workflow copy cannot include RL state: ${relative}`);
119
+ const stat = fs.lstatSync(source);
120
+ if (stat.isDirectory()) {
121
+ if (fs.existsSync(destination) && !fs.lstatSync(destination).isDirectory())
122
+ throw new Error(`Copy destination is not a directory: ${destination}`);
123
+ for (const child of fs.readdirSync(source).sort())
124
+ collectCopies(repo, workspace, path.join(relative, child), files, stateDir);
125
+ return;
126
+ }
127
+ if (!stat.isFile())
128
+ throw new Error(`Copy supports only regular files and directories: ${relative}`);
129
+ for (const root of [repo, workspace]) {
130
+ if (git(root, "--literal-pathspecs", "ls-files", "--", relative))
131
+ throw new Error(`Workflow copy path is tracked: ${relative}`);
132
+ try {
133
+ git(root, "check-ignore", "--no-index", "--", relative);
134
+ }
135
+ catch {
136
+ throw new Error(`Workflow copy path must be ignored in source and destination: ${relative}`);
137
+ }
138
+ }
139
+ if (fs.existsSync(destination)) {
140
+ if (!fs.lstatSync(destination).isFile())
141
+ throw new Error(`Copy destination is not a regular file: ${destination}`);
142
+ return;
143
+ }
144
+ if (!files.some((file) => file.destination === destination))
145
+ files.push({ source, destination, relative });
146
+ }
147
+ export async function setupWorkspace(repo, workspace, options = {}) {
148
+ const config = readWorkflowConfig(repo);
149
+ const files = [];
150
+ for (const relative of new Set(config.copy))
151
+ collectCopies(repo, workspace, relative, files, options.stateDir);
152
+ const commands = [
153
+ ...(options.create ? config.onCreate : []),
154
+ ...(options.open ? config.onOpen : []),
155
+ ];
156
+ for (const file of files)
157
+ console.error(`${options.dryRun ? "Would copy" : "Copy"} ${file.relative}`);
158
+ for (const command of commands)
159
+ console.error(`${options.dryRun ? "Would run" : "Run"} ${JSON.stringify(command)}`);
160
+ if (options.dryRun)
161
+ return 0;
162
+ for (const file of files) {
163
+ containedPath(repo, file.relative);
164
+ containedPath(workspace, file.relative);
165
+ fs.mkdirSync(path.dirname(file.destination), { recursive: true });
166
+ fs.copyFileSync(file.source, file.destination, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE);
167
+ }
168
+ for (const command of commands) {
169
+ const result = await execute(command, workspace, {
170
+ interactive: true,
171
+ env: workflowEnvironment(repo, workspace, config),
172
+ });
173
+ if (result.code !== 0)
174
+ return result.code;
175
+ }
176
+ return 0;
177
+ }
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { args, arity, canonical, execute, git, inside, json, locked, } from "./common.js";
4
+ import { commandContext, contextOptions, resolveWorkspace, writeWorkspaceOutput, } from "./command-context.js";
5
+ import { resolveBase } from "./base.js";
6
+ import { worktrees } from "./repos.js";
7
+ import { launchAgent } from "./agent-launch.js";
8
+ import { EMPTY_WORKFLOW, WORKFLOW_FILE, readWorkflowConfig, setupWorkspace, workflowEnvironment, } from "./workflow-config.js";
9
+ const CREATE_LOCK = ".workspace-create";
10
+ const SETUP_LOCK = ".workspace-setup";
11
+ const LOCK_FILE = ".lock";
12
+ const SUPPORTED_AGENTS = new Set(["codex", "claude"]);
13
+ const COMMAND_OPTIONS = {
14
+ path: [],
15
+ config: [],
16
+ exec: ["workspace"],
17
+ setup: ["workspace", "dry-run"],
18
+ switch: ["create", "base", "no-fetch", "setup", "agent", "output-path"],
19
+ new: ["base", "no-fetch", "setup", "output-path"],
20
+ open: ["branch", "base", "no-fetch", "setup", "output-path"],
21
+ };
22
+ async function createWorkspace(context, name, base, noFetch, options = {}) {
23
+ if (name.startsWith("-") ||
24
+ name.startsWith("@") ||
25
+ name
26
+ .split(/[\\/]/)
27
+ .some((part) => !part || part === "." || part === "..") ||
28
+ path.isAbsolute(name))
29
+ throw new Error(`Invalid workspace name: ${name}`);
30
+ git(context.repo, "check-ref-format", "--branch", name);
31
+ return locked(path.join(context.store.root, CREATE_LOCK), LOCK_FILE, async () => {
32
+ const directoryName = options.directoryName ?? name;
33
+ const destination = path.resolve(context.managedDir, directoryName);
34
+ if (!inside(destination, context.managedDir) ||
35
+ canonical(destination) !== destination)
36
+ throw new Error("Workspace path must stay inside the managed directory without symlinks");
37
+ const registered = worktrees(context.repo).find((item) => item.branch === `refs/heads/${name}`);
38
+ if (registered) {
39
+ if (!options.reuseExisting)
40
+ throw new Error(`Branch already checked out at ${registered.worktree}; use rl switch without --create`);
41
+ const [root, branch] = context.store.worktree(registered.worktree);
42
+ if (branch !== name)
43
+ throw new Error("Workspace branch changed during selection; retry");
44
+ return { path: root, name: directoryName, branch, created: false };
45
+ }
46
+ if (fs.existsSync(destination))
47
+ throw new Error(`Workspace path already exists: ${destination}`);
48
+ if (!noFetch) {
49
+ const remotes = git(context.repo, "remote").split("\n");
50
+ if (remotes.includes("origin")) {
51
+ const result = await execute(["git", "fetch", "origin"], context.repo, { terminalError: true });
52
+ if (result.code !== 0)
53
+ throw new Error("Fetch failed; retry or use --no-fetch");
54
+ }
55
+ }
56
+ const exists = git(context.repo, "for-each-ref", "--format=%(refname)", `refs/heads/${name}`)
57
+ .split("\n")
58
+ .includes(`refs/heads/${name}`);
59
+ const remoteBranch = `refs/remotes/origin/${name}`;
60
+ const remoteExists = options.requireBranch &&
61
+ git(context.repo, "for-each-ref", "--format=%(refname)", remoteBranch)
62
+ .split("\n")
63
+ .includes(remoteBranch);
64
+ if (options.requireBranch && !exists && !remoteExists)
65
+ throw new Error(`Branch does not exist locally or on origin: ${name}`);
66
+ const resolved = exists
67
+ ? null
68
+ : resolveBase(context.repo, options.requireBranch ? `origin/${name}` : base);
69
+ if (!exists && !resolved)
70
+ throw new Error(`Base branch does not exist: ${base}`);
71
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
72
+ const creationArgs = exists
73
+ ? [destination, name]
74
+ : resolved
75
+ ? [
76
+ ...(options.requireBranch ? ["--track"] : []),
77
+ "-b",
78
+ name,
79
+ destination,
80
+ options.requireBranch ? `origin/${name}` : resolved.oid,
81
+ ]
82
+ : [];
83
+ git(context.repo, "worktree", "add", ...creationArgs);
84
+ return {
85
+ path: destination,
86
+ name: directoryName,
87
+ branch: name,
88
+ created: true,
89
+ };
90
+ });
91
+ }
92
+ export async function main(argv) {
93
+ const separator = argv.indexOf("--");
94
+ const forwarded = separator < 0 ? [] : argv.slice(separator + 1);
95
+ const { values, positionals } = args(separator < 0 ? argv : argv.slice(0, separator), [...contextOptions, "base", "branch", "workspace", "agent", "output-path"], ["create", "no-fetch", "setup", "dry-run"], { create: "c", base: "b" });
96
+ const context = commandContext(values);
97
+ const [command, ...names] = positionals;
98
+ const allowedOptions = COMMAND_OPTIONS[command];
99
+ if (!allowedOptions)
100
+ throw new Error("Unknown workspace workflow command");
101
+ for (const option of Object.keys(values))
102
+ if (!contextOptions.includes(option) && !allowedOptions.includes(option))
103
+ throw new Error(`--${option} is not supported by rl ${command}`);
104
+ if (command === "switch" &&
105
+ !values.create &&
106
+ (values.base || values["no-fetch"]))
107
+ throw new Error("--base and --no-fetch require --create with rl switch");
108
+ const selector = typeof values.workspace === "string" ? values.workspace : undefined;
109
+ if (forwarded.length &&
110
+ command !== "exec" &&
111
+ !(command === "switch" && values.agent))
112
+ throw new Error("Arguments after -- require rl exec or rl switch --agent");
113
+ if (command === "path") {
114
+ arity(names, 0, 1);
115
+ console.log(resolveWorkspace(context, names[0]).path);
116
+ return 0;
117
+ }
118
+ if (command === "config") {
119
+ arity(names, 1);
120
+ if (names[0] === "show")
121
+ json(readWorkflowConfig(context.repo));
122
+ else if (names[0] === "init") {
123
+ const file = path.join(context.repo, WORKFLOW_FILE);
124
+ if (fs
125
+ .lstatSync(path.dirname(file), { throwIfNoEntry: false })
126
+ ?.isSymbolicLink())
127
+ throw new Error("Workflow config directory must not be a symlink");
128
+ fs.mkdirSync(path.dirname(file), { recursive: true });
129
+ fs.writeFileSync(file, JSON.stringify(EMPTY_WORKFLOW, null, 2) + "\n", {
130
+ flag: "wx",
131
+ });
132
+ console.log(file);
133
+ }
134
+ else
135
+ throw new Error("Usage: rl config show|init");
136
+ return 0;
137
+ }
138
+ if (command === "exec") {
139
+ arity(names, 0);
140
+ if (!forwarded.length)
141
+ throw new Error("Usage: rl exec [--workspace name] -- <command> [args...]");
142
+ const target = resolveWorkspace(context, selector);
143
+ return (await execute(forwarded, target.path, {
144
+ interactive: true,
145
+ env: workflowEnvironment(context.repo, target.path, readWorkflowConfig(context.repo)),
146
+ })).code;
147
+ }
148
+ if (command === "setup") {
149
+ arity(names, 0);
150
+ const target = resolveWorkspace(context, selector);
151
+ return locked(path.join(context.store.root, SETUP_LOCK), LOCK_FILE, () => {
152
+ if (context.store.worktree(target.path)[1] !== target.branch)
153
+ throw new Error("Workspace branch changed before setup; retry");
154
+ return setupWorkspace(context.repo, target.path, {
155
+ dryRun: !!values["dry-run"],
156
+ create: true,
157
+ open: true,
158
+ stateDir: context.stateDir,
159
+ });
160
+ });
161
+ }
162
+ if (!["switch", "new", "open"].includes(command))
163
+ throw new Error("Unknown workspace workflow command");
164
+ arity(names, command === "open" ? 0 : 1);
165
+ const selectedBranch = values.branch ?? (command === "open" ? values.base : undefined);
166
+ if (command === "open" && typeof selectedBranch !== "string")
167
+ throw new Error("Usage: rl open --branch <branch>");
168
+ const agent = typeof values.agent === "string" ? values.agent : undefined;
169
+ if (agent && !SUPPORTED_AGENTS.has(agent))
170
+ throw new Error("Agent must be codex or claude");
171
+ const base = command !== "open" && typeof values.base === "string"
172
+ ? values.base
173
+ : context.defaultBase;
174
+ const creating = !!values.create || command === "new" || command === "open";
175
+ const branch = typeof selectedBranch === "string"
176
+ ? selectedBranch.replace(/^origin\//, "")
177
+ : names[0];
178
+ const target = creating
179
+ ? await createWorkspace(context, branch, base, !!values["no-fetch"], command === "switch"
180
+ ? {}
181
+ : {
182
+ reuseExisting: true,
183
+ ...(command === "open"
184
+ ? {
185
+ directoryName: branch.replaceAll("/", "-"),
186
+ requireBranch: true,
187
+ }
188
+ : {}),
189
+ })
190
+ : resolveWorkspace(context, names[0]);
191
+ await context.store.adopt(target.path, base);
192
+ if (values.setup) {
193
+ const code = await locked(path.join(context.store.root, SETUP_LOCK), LOCK_FILE, () => {
194
+ if (context.store.worktree(target.path)[1] !== target.branch)
195
+ throw new Error("Workspace branch changed before setup; retry");
196
+ return setupWorkspace(context.repo, target.path, {
197
+ create: "created" in target && target.created === true,
198
+ open: true,
199
+ stateDir: context.stateDir,
200
+ });
201
+ });
202
+ if (code !== 0)
203
+ return code;
204
+ }
205
+ writeWorkspaceOutput(target.path, typeof values["output-path"] === "string"
206
+ ? values["output-path"]
207
+ : undefined);
208
+ return agent
209
+ ? launchAgent(context.store, agent, forwarded, { cwd: target.path })
210
+ : 0;
211
+ }