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/common.js ADDED
@@ -0,0 +1,262 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { spawnSync, spawn } from "node:child_process";
6
+ import { parseArgs } from "node:util";
7
+ import lockfile from "proper-lockfile";
8
+ import { readJsonObject } from "./json.js";
9
+ export const now = () => new Date().toISOString();
10
+ export const message = (error) => error instanceof Error ? error.message : String(error);
11
+ export const json = (data) => console.log(JSON.stringify(data, null, 2));
12
+ export function canonical(value) {
13
+ const absolute = path.resolve(value);
14
+ try {
15
+ return fs.realpathSync(absolute);
16
+ }
17
+ catch (error) {
18
+ if (!["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
19
+ throw error;
20
+ const parent = path.dirname(absolute);
21
+ return parent === absolute
22
+ ? absolute
23
+ : path.join(canonical(parent), path.basename(absolute));
24
+ }
25
+ }
26
+ export const expandHome = (value) => value === "~"
27
+ ? os.homedir()
28
+ : value.startsWith("~/")
29
+ ? path.join(os.homedir(), value.slice(2))
30
+ : value;
31
+ export function inside(child, parent) {
32
+ const relative = path.relative(parent, child);
33
+ return (!!relative &&
34
+ relative !== ".." &&
35
+ !relative.startsWith(`..${path.sep}`) &&
36
+ !path.isAbsolute(relative));
37
+ }
38
+ export function gitEnv() {
39
+ const env = { ...process.env };
40
+ for (const key of [
41
+ "GIT_DIR",
42
+ "GIT_WORK_TREE",
43
+ "GIT_COMMON_DIR",
44
+ "GIT_INDEX_FILE",
45
+ ])
46
+ delete env[key];
47
+ return env;
48
+ }
49
+ export function run(argv, cwd, raw = false) {
50
+ const result = spawnSync(argv[0], argv.slice(1), {
51
+ cwd,
52
+ encoding: "utf8",
53
+ env: argv[0] === "git" ? gitEnv() : process.env,
54
+ maxBuffer: 32 * 1024 * 1024,
55
+ });
56
+ if (result.error)
57
+ throw result.error;
58
+ if (result.status !== 0)
59
+ throw new Error(result.stderr.trim() || `Command failed: ${argv.join(" ")}`);
60
+ return raw ? result.stdout : result.stdout.trim();
61
+ }
62
+ export const git = (cwd, ...args) => run(["git", "-C", cwd, ...args], cwd);
63
+ export const gitRaw = (cwd, ...args) => run(["git", "-C", cwd, ...args], cwd, true);
64
+ export async function execute(argv, cwd, options = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn(argv[0], argv.slice(1), {
67
+ cwd,
68
+ timeout: options.timeoutMs,
69
+ env: options.env ?? (argv[0] === "git" ? gitEnv() : process.env),
70
+ detached: options.processGroup,
71
+ stdio: options.interactive
72
+ ? "inherit"
73
+ : ["pipe", "pipe", options.terminalError ? "inherit" : "pipe"],
74
+ });
75
+ let stdout = "", stderr = "";
76
+ let outputBytes = 0;
77
+ let overflow = false;
78
+ let forceKill;
79
+ const kill = (signal) => {
80
+ if (child.exitCode !== null || child.signalCode !== null)
81
+ return;
82
+ try {
83
+ if (options.processGroup && child.pid)
84
+ process.kill(-child.pid, signal);
85
+ else
86
+ child.kill(signal);
87
+ }
88
+ catch (error) {
89
+ const code = error.code;
90
+ if (code === "EPERM" && options.processGroup)
91
+ child.kill(signal);
92
+ else if (code !== "ESRCH")
93
+ throw error;
94
+ }
95
+ };
96
+ const abort = () => {
97
+ kill("SIGTERM");
98
+ forceKill ??= setTimeout(() => kill("SIGKILL"), 1000);
99
+ forceKill.unref();
100
+ };
101
+ const append = (chunk, target) => {
102
+ outputBytes += chunk.length;
103
+ if (options.maxOutputBytes && outputBytes > options.maxOutputBytes) {
104
+ overflow = true;
105
+ abort();
106
+ }
107
+ else if (target === "stdout")
108
+ stdout += chunk;
109
+ else
110
+ stderr += chunk;
111
+ };
112
+ child.stdout?.on("data", (chunk) => {
113
+ append(chunk, "stdout");
114
+ });
115
+ child.stderr?.on("data", (chunk) => {
116
+ append(chunk, "stderr");
117
+ });
118
+ child.stdin?.on("error", () => { });
119
+ child.stdin?.end(options.input);
120
+ const interrupt = () => child.kill("SIGINT");
121
+ const terminate = () => child.kill("SIGTERM");
122
+ process.on("SIGINT", interrupt);
123
+ process.on("SIGTERM", terminate);
124
+ const cleanup = () => {
125
+ if (forceKill)
126
+ clearTimeout(forceKill);
127
+ options.signal?.removeEventListener("abort", abort);
128
+ process.off("SIGINT", interrupt);
129
+ process.off("SIGTERM", terminate);
130
+ };
131
+ child.on("error", (error) => {
132
+ cleanup();
133
+ reject(error);
134
+ });
135
+ child.on("close", (code, signal) => {
136
+ cleanup();
137
+ if (overflow) {
138
+ reject(new Error("Command output exceeded the configured size limit"));
139
+ return;
140
+ }
141
+ resolve({
142
+ code: code ?? 128 + (os.constants.signals[signal] ?? 1),
143
+ stdout,
144
+ stderr,
145
+ });
146
+ });
147
+ options.signal?.addEventListener("abort", abort, { once: true });
148
+ if (options.signal?.aborted)
149
+ abort();
150
+ });
151
+ }
152
+ export function object(value) {
153
+ return value !== null && typeof value === "object" && !Array.isArray(value);
154
+ }
155
+ export function readJson(file) {
156
+ return readJsonObject(file);
157
+ }
158
+ export function atomicWrite(file, text) {
159
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
160
+ const temporary = path.join(path.dirname(file), `.rl-${randomUUID()}`);
161
+ try {
162
+ const fd = fs.openSync(temporary, "wx", 0o600);
163
+ try {
164
+ fs.writeFileSync(fd, text);
165
+ fs.fsyncSync(fd);
166
+ }
167
+ finally {
168
+ fs.closeSync(fd);
169
+ }
170
+ fs.renameSync(temporary, file);
171
+ }
172
+ finally {
173
+ if (fs.existsSync(temporary))
174
+ fs.unlinkSync(temporary);
175
+ }
176
+ }
177
+ export const writeJson = (file, data) => atomicWrite(file, JSON.stringify(data, null, 2) + "\n");
178
+ export async function locked(directory, name, action) {
179
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
180
+ const release = await lockfile.lock(directory, {
181
+ realpath: false,
182
+ lockfilePath: path.join(directory, name),
183
+ stale: 120000,
184
+ retries: { retries: 300, factor: 1, minTimeout: 25, maxTimeout: 100 },
185
+ });
186
+ try {
187
+ return await action();
188
+ }
189
+ finally {
190
+ await release();
191
+ }
192
+ }
193
+ export function shellQuote(value) {
194
+ return /^[\w@%+=:,./-]+$/.test(value)
195
+ ? value
196
+ : "'" + value.replaceAll("'", "'\"'\"'") + "'";
197
+ }
198
+ export function sessionId(value) {
199
+ if (typeof value !== "string" ||
200
+ !/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value))
201
+ throw new Error("Expected the agent's full session UUID, not a name or prefix");
202
+ return value.toLowerCase();
203
+ }
204
+ // Normalize timezone offsets while retaining Python's microsecond timestamp precision.
205
+ export function timestamp(value) {
206
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
207
+ !Number.isFinite(Date.parse(value)))
208
+ throw new Error("Invalid timestamp");
209
+ const fraction = /\.(\d+)/.exec(value)?.[1] ?? "";
210
+ return (BigInt(Date.parse(value)) * 1000000n +
211
+ BigInt(fraction.slice(3, 9).padEnd(6, "0")));
212
+ }
213
+ export function args(argv, strings, booleans = [], short = {}) {
214
+ const options = {};
215
+ for (const name of strings)
216
+ options[name] = {
217
+ type: "string",
218
+ ...(short[name] ? { short: short[name] } : {}),
219
+ };
220
+ for (const name of booleans)
221
+ options[name] = {
222
+ type: "boolean",
223
+ ...(short[name] ? { short: short[name] } : {}),
224
+ };
225
+ return parseArgs({
226
+ args: argv,
227
+ options,
228
+ allowPositionals: true,
229
+ strict: true,
230
+ });
231
+ }
232
+ export function required(values, key) {
233
+ const value = values[key];
234
+ if (typeof value !== "string" || !value)
235
+ throw new Error(`Missing --${key}`);
236
+ return value;
237
+ }
238
+ export function arity(values, min, max = min) {
239
+ if (values.length < min || values.length > max)
240
+ throw new Error("Invalid command arguments; run rl --help");
241
+ }
242
+ export async function pooled(items, limit, action) {
243
+ const results = new Array(items.length);
244
+ let next = 0;
245
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
246
+ while (next < items.length) {
247
+ const index = next++;
248
+ results[index] = await action(items[index]);
249
+ }
250
+ }));
251
+ return results;
252
+ }
253
+ export const isSymlink = (file) => {
254
+ try {
255
+ return fs.lstatSync(file).isSymbolicLink();
256
+ }
257
+ catch (error) {
258
+ if (error.code === "ENOENT")
259
+ return false;
260
+ throw error;
261
+ }
262
+ };
package/dist/delete.js ADDED
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+ import { args, canonical, execute, git, inside, locked, message, pooled, required, } from "./common.js";
3
+ import { Store } from "./store.js";
4
+ import { worktrees } from "./repos.js";
5
+ import { candidates, select } from "./picker.js";
6
+ import { assertReady, GIT_WORKFLOW_DIRECTORY, GIT_WORKFLOW_LOCK, } from "./git-safety.js";
7
+ export async function main(argv) {
8
+ const { values, positionals } = args(argv, ["repo", "state-dir", "base-dir", "jobs", "default-base"], ["dry-run", "force"]);
9
+ const jobs = Number(values.jobs ?? 4);
10
+ if (!Number.isInteger(jobs) || jobs < 1 || jobs > 32)
11
+ throw new Error("jobs must be an integer from 1 to 32");
12
+ const repo = canonical(required(values, "repo")), base = canonical(required(values, "base-dir")), registered = worktrees(repo), items = new Map();
13
+ for (const item of candidates(repo, base))
14
+ items.set(item.name, item);
15
+ let names = positionals;
16
+ if (!names.length)
17
+ names = (await select(new Store(repo, required(values, "state-dir")), [...items.values()], String(values["default-base"] ?? "origin/test"), true)).map((item) => item.name);
18
+ names = [...new Set(names)];
19
+ if (!names.length)
20
+ return 0;
21
+ const unknown = names.filter((name) => !items.has(name));
22
+ if (unknown.length)
23
+ throw new Error("Not registered managed worktrees: " + unknown.join(", "));
24
+ const selected = names.map((name) => items.get(name));
25
+ for (const item of selected)
26
+ if (registered.some((other) => inside(canonical(other.worktree), item.path)))
27
+ throw new Error(`${item.name} contains another registered worktree; delete its children first`);
28
+ for (const item of selected)
29
+ console.log(`${values["dry-run"] ? "Would delete" : "Selected"}: ${item.name} [branch: ${item.branch || "(detached)"}]${item.locked ? " [locked: will be skipped]" : ""}`);
30
+ if (values["dry-run"])
31
+ return 0;
32
+ const store = new Store(repo, required(values, "state-dir"));
33
+ return locked(path.join(store.root, GIT_WORKFLOW_DIRECTORY), GIT_WORKFLOW_LOCK, async () => {
34
+ await store.locked(() => store.records());
35
+ process.chdir(repo);
36
+ console.log(`Removing ${selected.length} workspace(s), up to ${jobs} at a time...`);
37
+ const removed = [], failed = [];
38
+ await pooled(selected, jobs, async (item) => {
39
+ try {
40
+ const current = worktrees(repo).find((entry) => canonical(entry.worktree) === item.path);
41
+ if (!current ||
42
+ (current.branch ?? "").replace(/^refs\/heads\//, "") !== item.branch)
43
+ throw new Error("Workspace branch changed; inspect it and retry");
44
+ if (!values.force && !item.branch)
45
+ throw new Error("Detached workspace may contain unmerged commits; inspect it or use --force");
46
+ if (!values.force)
47
+ assertReady(item.path, true, item.branch);
48
+ if (!values.force && item.branch) {
49
+ const upstream = git(repo, "for-each-ref", "--format=%(upstream)", `refs/heads/${item.branch}`);
50
+ const merged = await execute([
51
+ "git",
52
+ "-C",
53
+ repo,
54
+ "merge-base",
55
+ "--is-ancestor",
56
+ `refs/heads/${item.branch}`,
57
+ upstream || "HEAD",
58
+ ], repo);
59
+ if (merged.code)
60
+ throw new Error("Branch has unmerged commits; merge it first or use --force");
61
+ }
62
+ const result = await execute([
63
+ "git",
64
+ "-C",
65
+ repo,
66
+ "worktree",
67
+ "remove",
68
+ ...(values.force ? ["--force"] : []),
69
+ item.path,
70
+ ], repo);
71
+ if (result.code)
72
+ throw new Error(result.stderr.trim());
73
+ removed.push(item);
74
+ console.log(`Removed worktree: ${item.name}`);
75
+ }
76
+ catch (error) {
77
+ failed.push(item);
78
+ console.error(`Failed: ${item.name}: ${message(error)}`);
79
+ }
80
+ });
81
+ await store.locked(() => store.archiveWorktrees(removed.map((item) => item.path)));
82
+ let cleanupFailed = false;
83
+ const branches = [
84
+ ...new Set(removed.map((item) => item.branch).filter(Boolean)),
85
+ ];
86
+ if (branches.length) {
87
+ const result = await execute([
88
+ "git",
89
+ "-C",
90
+ repo,
91
+ "branch",
92
+ values.force ? "-D" : "-d",
93
+ "--",
94
+ ...branches,
95
+ ], repo);
96
+ process.stdout.write(result.stdout);
97
+ if (result.code) {
98
+ cleanupFailed = true;
99
+ console.error("Some worktrees were removed but local branch cleanup failed:\n" +
100
+ result.stderr.trim());
101
+ }
102
+ }
103
+ if (removed.length) {
104
+ const result = await execute(["git", "-C", repo, "worktree", "prune"], repo);
105
+ if (result.code) {
106
+ cleanupFailed = true;
107
+ console.error("Worktree pruning failed: " + result.stderr.trim());
108
+ }
109
+ }
110
+ console.log(`Removed ${removed.length} worktree(s); failed ${failed.length}. Saved documentation and session history retained.`);
111
+ return failed.length || cleanupFailed ? 1 : 0;
112
+ });
113
+ }
@@ -0,0 +1,134 @@
1
+ import { activityView } from "./activity.js";
2
+ import { accessLabel } from "./access.js";
3
+ import { stripVTControlCharacters } from "node:util";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ // Keep names and remote titles on one terminal line without control sequences.
7
+ const text = (value) => stripVTControlCharacters(String(value ?? ""))
8
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
9
+ .trim();
10
+ export function prText(pr) {
11
+ if (!pr)
12
+ return "PR: none saved. Run rl pr sync to refresh.";
13
+ const number = typeof pr.number === "number" ? ` #${pr.number}` : "";
14
+ const state = [pr.isDraft ? "draft" : "", text(pr.state).toLowerCase()]
15
+ .filter(Boolean)
16
+ .join(" · ");
17
+ return [
18
+ `PR${number}${pr.title ? `: ${text(pr.title)}` : ""}${state ? ` [${state}]` : ""}`,
19
+ pr.headRefName && pr.baseRefName
20
+ ? ` ${text(pr.headRefName)} → ${text(pr.baseRefName)}`
21
+ : "",
22
+ ` ${text(pr.url)}`,
23
+ pr.syncedAt ? ` Cached ${text(pr.syncedAt)}` : "",
24
+ ]
25
+ .filter(Boolean)
26
+ .join("\n");
27
+ }
28
+ export function statusText(data, git, workflow = null) {
29
+ const color = process.env.NO_COLOR === undefined &&
30
+ process.env.FORCE_COLOR !== "0" &&
31
+ process.env.TERM !== "dumb" &&
32
+ (!!process.stdout.isTTY || !!process.env.FORCE_COLOR);
33
+ const paint = (value, code) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
34
+ const muted = (value) => paint(value, 90);
35
+ const row = (label, value) => ` ${muted(label.padEnd(10))}${value}`;
36
+ const files = (count, state, code) => count ? paint(`${count} ${state} file${count === 1 ? "" : "s"}`, code) : "";
37
+ const changes = [
38
+ files(git.staged, "staged", 32),
39
+ files(git.unstaged, "modified", 33),
40
+ files(git.untracked, "untracked", 33),
41
+ files(git.conflicts, "conflicted", 31),
42
+ ].filter(Boolean);
43
+ const home = homedir();
44
+ const directory = data.worktree === home
45
+ ? "~"
46
+ : data.worktree.startsWith(home + path.sep)
47
+ ? "~" + data.worktree.slice(home.length)
48
+ : data.worktree;
49
+ const lines = [
50
+ "",
51
+ ` ${paint(text(data.id), 1)}`,
52
+ "",
53
+ ...(data.branch !== data.id ? [row("Branch", text(data.branch))] : []),
54
+ row("Path", muted(text(directory))),
55
+ ...(data.baseBranch ? [row("Base", text(data.baseBranch))] : []),
56
+ row("Git", git.availability === "ok"
57
+ ? changes.join(" · ") || paint("clean", 32)
58
+ : paint(text(git.error ?? git.availability), 31)),
59
+ ];
60
+ if (git.upstream) {
61
+ const sync = git.ahead === 0 && git.behind === 0
62
+ ? paint("up to date", 32)
63
+ : git.ahead === null || git.behind === null
64
+ ? muted("commit counts unavailable")
65
+ : `${paint(`${git.ahead} ahead`, git.ahead ? 32 : 90)} · ${paint(`${git.behind} behind`, git.behind ? 33 : 90)} ${muted("(commits)")}`;
66
+ lines.push(row("Upstream", `${sync} ${muted("· " + text(git.upstream))}`));
67
+ }
68
+ lines.push("");
69
+ const pr = data.pr;
70
+ if (pr) {
71
+ const number = typeof pr.number === "number" ? ` #${pr.number}` : "";
72
+ const state = text(pr.state).toLowerCase();
73
+ const label = [pr.isDraft ? "draft" : "", state]
74
+ .filter(Boolean)
75
+ .join(" · ");
76
+ const badge = paint(label, pr.isDraft
77
+ ? 33
78
+ : state === "open"
79
+ ? 32
80
+ : state === "merged"
81
+ ? 35
82
+ : state === "closed"
83
+ ? 31
84
+ : 33);
85
+ const target = pr.baseRefName
86
+ ? `${pr.headRefName && pr.headRefName !== data.branch ? text(pr.headRefName) + " " : ""}→ ${text(pr.baseRefName)}`
87
+ : "";
88
+ lines.push(` ${paint(`PR${number}`, 1)}${badge ? ` · ${badge}` : ""}${target ? ` ${muted("· " + target)}` : ""}`);
89
+ if (pr.title)
90
+ lines.push(` ${text(pr.title)}`);
91
+ lines.push(` ${paint(text(pr.url), 36)}`);
92
+ if (pr.syncedAt) {
93
+ const stamp = text(pr.syncedAt);
94
+ lines.push(` ${muted(`Cached ${Number.isFinite(Date.parse(stamp)) ? accessLabel(stamp) : stamp}`)}`);
95
+ }
96
+ }
97
+ else {
98
+ lines.push(` ${paint("PR", 1)} ${muted("· none saved")}`, ` ${muted("Run rl pr sync to refresh")}`);
99
+ }
100
+ lines.push("", ` ${paint("Sessions", 1)}`);
101
+ for (const agent of ["codex", "claude"]) {
102
+ const activity = activityView(data.activity?.[agent]).filter((item) => !item.childId);
103
+ const states = ["working", "idle", "stale"]
104
+ .map((state) => {
105
+ const count = activity.filter((item) => item.state === state).length;
106
+ return count
107
+ ? paint(`${count} ${state}`, state === "working" ? 32 : state === "stale" ? 33 : 90)
108
+ : "";
109
+ })
110
+ .filter(Boolean);
111
+ lines.push(row(agent === "codex" ? "Codex" : "Claude", `${data.sessions[agent].length} saved${states.length ? ` · ${states.join(" · ")}` : ""}`));
112
+ }
113
+ if (workflow) {
114
+ lines.push("", ` ${paint("Workflow", 1)} · ${text(workflow.workflow)} · ${text(workflow.status)}`, row("Steps", `${workflow.completed}/${workflow.total} completed`), ...(workflow.owner
115
+ ? [
116
+ row("Owner", `${workflow.owner.agent} · native subagents · ${workflow.owner.sessionId}`),
117
+ ]
118
+ : []), ...(workflow.verdict
119
+ ? [
120
+ row("Review", `${workflow.score === null ? "not scored" : `${workflow.score}/100`} · ${text(workflow.verdict)}`),
121
+ ]
122
+ : []));
123
+ }
124
+ const handoff = data.task?.handoffs.at(-1);
125
+ if (handoff) {
126
+ lines.push("", ` ${paint("Task", 1)} · ${paint(data.task.state, data.task.state === "paused" ? 33 : 32)}`, ` ${text(handoff.summary)}`, row("Next", text(handoff.next)));
127
+ if (handoff.blockers)
128
+ lines.push(row("Blockers", paint(text(handoff.blockers), 33)));
129
+ }
130
+ if (Object.values(data.sessions).some((sessions) => sessions.length))
131
+ lines.push("", ` ${muted("Resume with")} ${paint("rl resume", 36)}`);
132
+ lines.push("");
133
+ return lines.join("\n");
134
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,145 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { adapters, agentAdapter } from "./agent-registry.js";
4
+ import { activityView, STALE_AFTER_MS } from "./activity.js";
5
+ import { execute, isSymlink, json, message } from "./common.js";
6
+ import { hookCommand } from "./store.js";
7
+ import { isJsonObject, readJsonObject } from "./json.js";
8
+ const PROBE_TIMEOUT_MS = 2000;
9
+ const MAX_VERSION_LENGTH = 200;
10
+ export async function doctor(store, selected, asJson = false) {
11
+ const [file, data] = store.current();
12
+ const selectedAdapters = selected
13
+ ? [agentAdapter(selected)]
14
+ : Object.values(adapters);
15
+ const agents = await Promise.all(selectedAdapters.map(async (adapter) => {
16
+ const configPath = path.join(data.worktree, adapter.configPath);
17
+ const expectedEvents = Object.keys({
18
+ ...adapter.events,
19
+ ...(data.extendedHooks ? adapter.extraEvents : {}),
20
+ });
21
+ const executablePath = (process.env.PATH ?? "")
22
+ .split(path.delimiter)
23
+ .map((directory) => path.resolve(data.worktree, directory, adapter.name))
24
+ .find((candidate) => {
25
+ try {
26
+ fs.accessSync(candidate, fs.constants.X_OK);
27
+ return fs.statSync(candidate).isFile();
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }) ?? null;
33
+ let configError = null;
34
+ let installedEvents = [];
35
+ try {
36
+ if (isSymlink(configPath) || isSymlink(path.dirname(configPath)))
37
+ throw new Error("Hook configuration is a symlink");
38
+ const config = readJsonObject(configPath);
39
+ if (!isJsonObject(config.hooks))
40
+ throw new Error("Missing hooks object");
41
+ const command = hookCommand(store, data, adapter.name);
42
+ const hooks = config.hooks;
43
+ installedEvents = expectedEvents.filter((event) => {
44
+ const groups = hooks[event];
45
+ if (!Array.isArray(groups))
46
+ return false;
47
+ return groups.some((group) => isJsonObject(group) &&
48
+ (group.matcher === undefined ||
49
+ group.matcher === "" ||
50
+ group.matcher === "*") &&
51
+ Array.isArray(group.hooks) &&
52
+ group.hooks.some((h) => isJsonObject(h) &&
53
+ h.type === "command" &&
54
+ h.command === command &&
55
+ h.async !== true));
56
+ });
57
+ }
58
+ catch (error) {
59
+ configError = message(error);
60
+ }
61
+ const missingEvents = expectedEvents.filter((event) => !installedEvents.includes(event));
62
+ let cliAvailable = false, version = null, cliError = null, launchVerified = false;
63
+ try {
64
+ const result = await execute([adapter.name, "--version"], data.worktree, { timeoutMs: PROBE_TIMEOUT_MS });
65
+ cliAvailable = result.code === 0;
66
+ version = result.stdout.trim().slice(0, MAX_VERSION_LENGTH) || null;
67
+ if (!cliAvailable)
68
+ cliError = "Version probe failed or timed out";
69
+ if (cliAvailable) {
70
+ const help = await execute(adapter.name === "codex"
71
+ ? [adapter.name, "exec", "--help"]
72
+ : [adapter.name, "--help"], data.worktree, { timeoutMs: PROBE_TIMEOUT_MS });
73
+ launchVerified =
74
+ help.code === 0 &&
75
+ (adapter.name === "codex"
76
+ ? /codex exec/.test(help.stdout) && /PROMPT/.test(help.stdout)
77
+ : /--print/.test(help.stdout) && /--resume/.test(help.stdout));
78
+ }
79
+ }
80
+ catch (error) {
81
+ cliError = message(error);
82
+ }
83
+ const health = data.hookHealth?.[adapter.name];
84
+ return {
85
+ agent: adapter.name,
86
+ configPath,
87
+ installed: !configError && missingEvents.length === 0,
88
+ installedEvents,
89
+ missingEvents,
90
+ configError,
91
+ cliAvailable,
92
+ executablePath,
93
+ launchVerified,
94
+ extendedHooks: !!data.extendedHooks,
95
+ delivery: health?.lastSuccess ? "observed" : "not-observed",
96
+ version,
97
+ cliError,
98
+ compatibility: "unverified",
99
+ trust: "unknown",
100
+ lastSuccess: health?.lastSuccess ?? null,
101
+ lastError: health?.lastError ?? null,
102
+ activity: activityView(data.activity?.[adapter.name]),
103
+ };
104
+ }));
105
+ let stateWritable = true;
106
+ try {
107
+ fs.accessSync(store.root, fs.constants.W_OK);
108
+ fs.accessSync(path.dirname(file), fs.constants.W_OK);
109
+ }
110
+ catch {
111
+ stateWritable = false;
112
+ }
113
+ const report = {
114
+ workspace: data.worktree,
115
+ stateDirectory: path.dirname(file),
116
+ stateWritable,
117
+ staleAfterMs: STALE_AFTER_MS,
118
+ agents,
119
+ };
120
+ if (asJson)
121
+ json(report);
122
+ else {
123
+ console.log(`RL doctor: ${data.worktree}\nState writable from this process: ${stateWritable ? "yes" : "no"}`);
124
+ for (const item of agents) {
125
+ console.log(`\n${item.agent}: hooks ${item.installed ? "installed" : "incomplete"}; CLI ${item.cliAvailable ? (item.version ?? "available") : "unavailable"}`);
126
+ console.log(` CLI: ${item.executablePath ?? "not on PATH"}; launch flags: ${item.launchVerified ? "verified in installed help" : "unverified"}; hook trust: unknown`);
127
+ console.log(` Last received event: ${item.lastSuccess ? `${item.lastSuccess.event} at ${item.lastSuccess.at}` : "none"}`);
128
+ if (item.missingEvents.length)
129
+ console.log(` Missing hooks: ${item.missingEvents.join(", ")}. Run rl adopt.`);
130
+ if (item.configError)
131
+ console.log(` Configuration error: ${item.configError}`);
132
+ if (item.cliError)
133
+ console.log(` CLI error: ${item.cliError}`);
134
+ if (item.lastError)
135
+ console.log(` Last recorded hook error: ${item.lastError.message} at ${item.lastError.at}`);
136
+ for (const activity of item.activity)
137
+ console.log(` ${activity.sessionId}${activity.childId ? ` / child ${activity.childId}` : ""}: ${activity.state}${activity.resumable ? "" : " (observation only)"}`);
138
+ }
139
+ console.log("\nInstallation does not prove hooks are enabled or trusted. Review agent hook settings and start a session here to verify delivery. Version compatibility is not certified. Activity is based on received events; after 30 minutes without an event it is stale, not confirmed running. Agent sandbox access may differ from this process.");
140
+ }
141
+ return stateWritable &&
142
+ agents.every((item) => item.installed && item.cliAvailable)
143
+ ? 0
144
+ : 1;
145
+ }
@@ -0,0 +1,24 @@
1
+ import fs from "node:fs";
2
+ import { git } from "./common.js";
3
+ export const GIT_WORKFLOW_DIRECTORY = ".git-workflow";
4
+ export const GIT_WORKFLOW_LOCK = ".lock";
5
+ const OPERATION_FILES = [
6
+ "MERGE_HEAD",
7
+ "CHERRY_PICK_HEAD",
8
+ "REVERT_HEAD",
9
+ "rebase-merge",
10
+ "rebase-apply",
11
+ "sequencer",
12
+ ];
13
+ /** Shared mutation guard; untracked content counts as dirty. */
14
+ export function assertReady(root, clean, expectedBranch) {
15
+ if (git(root, "branch", "--show-current") !== expectedBranch)
16
+ throw new Error("Workspace branch changed; retry from the intended branch");
17
+ for (const marker of OPERATION_FILES)
18
+ if (fs.existsSync(git(root, "rev-parse", "--path-format=absolute", "--git-path", marker)))
19
+ throw new Error(`An operation is already in progress in ${root}; finish or abort it first`);
20
+ if (git(root, "ls-files", "--unmerged"))
21
+ throw new Error(`Unresolved conflicts in ${root}`);
22
+ if (clean && git(root, "status", "--porcelain", "--untracked-files=normal"))
23
+ throw new Error(`Workspace is dirty: ${root}. Commit or stash changes explicitly first`);
24
+ }