rl-workspaces 0.0.0-stage → 0.6.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.
- package/LICENSE +21 -0
- package/README.md +735 -2
- package/bin/rl +54 -0
- package/config.example.zsh +9 -0
- package/dist/access.js +40 -0
- package/dist/activity.js +90 -0
- package/dist/agent-claude.js +16 -0
- package/dist/agent-codex.js +22 -0
- package/dist/agent-launch.js +74 -0
- package/dist/agent-presets.js +70 -0
- package/dist/agent-registry.js +8 -0
- package/dist/agent-resume.js +96 -0
- package/dist/agent-runner.js +386 -0
- package/dist/agent-workflows.js +275 -0
- package/dist/agents.js +73 -0
- package/dist/automation-config.js +381 -0
- package/dist/base.js +66 -0
- package/dist/cleanup-path.js +33 -0
- package/dist/cli.js +57 -0
- package/dist/codex-sessions.js +188 -0
- package/dist/command-context.js +67 -0
- package/dist/common.js +262 -0
- package/dist/delete.js +113 -0
- package/dist/display.js +134 -0
- package/dist/doctor.js +145 -0
- package/dist/git-safety.js +24 -0
- package/dist/git-workflow.js +89 -0
- package/dist/help.js +726 -0
- package/dist/hooks.js +159 -0
- package/dist/json.js +34 -0
- package/dist/mcp.js +336 -0
- package/dist/picker.js +182 -0
- package/dist/pr-discovery.js +223 -0
- package/dist/pr-update.js +419 -0
- package/dist/pr-workflow.js +161 -0
- package/dist/repos.js +537 -0
- package/dist/review-policy.js +150 -0
- package/dist/session-workflows.js +650 -0
- package/dist/session.js +12 -0
- package/dist/state.js +250 -0
- package/dist/storage.js +366 -0
- package/dist/store.js +379 -0
- package/dist/summary.js +164 -0
- package/dist/task-state.js +65 -0
- package/dist/task.js +192 -0
- package/dist/templates.js +4 -0
- package/dist/workflow-collaboration.js +531 -0
- package/dist/workflow-config.js +177 -0
- package/dist/workflow.js +211 -0
- package/lib/rl.zsh +329 -0
- package/node_modules/graceful-fs/LICENSE +15 -0
- package/node_modules/graceful-fs/README.md +143 -0
- package/node_modules/graceful-fs/clone.js +23 -0
- package/node_modules/graceful-fs/graceful-fs.js +448 -0
- package/node_modules/graceful-fs/legacy-streams.js +118 -0
- package/node_modules/graceful-fs/package.json +53 -0
- package/node_modules/graceful-fs/polyfills.js +355 -0
- package/node_modules/proper-lockfile/LICENSE +21 -0
- package/node_modules/proper-lockfile/README.md +183 -0
- package/node_modules/proper-lockfile/index.js +40 -0
- package/node_modules/proper-lockfile/lib/adapter.js +85 -0
- package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
- package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
- package/node_modules/proper-lockfile/package.json +71 -0
- package/node_modules/retry/.npmignore +3 -0
- package/node_modules/retry/.travis.yml +15 -0
- package/node_modules/retry/License +21 -0
- package/node_modules/retry/Makefile +18 -0
- package/node_modules/retry/README.md +227 -0
- package/node_modules/retry/equation.gif +0 -0
- package/node_modules/retry/example/dns.js +31 -0
- package/node_modules/retry/example/stop.js +40 -0
- package/node_modules/retry/index.js +1 -0
- package/node_modules/retry/lib/retry.js +100 -0
- package/node_modules/retry/lib/retry_operation.js +158 -0
- package/node_modules/retry/package.json +32 -0
- package/node_modules/retry/test/common.js +10 -0
- package/node_modules/retry/test/integration/test-forever.js +24 -0
- package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
- package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
- package/node_modules/retry/test/integration/test-timeouts.js +69 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +39 -0
- package/node_modules/signal-exit/index.js +202 -0
- package/node_modules/signal-exit/package.json +38 -0
- package/node_modules/signal-exit/signals.js +53 -0
- package/package.json +57 -4
- package/shell/rl.zsh +11 -0
package/dist/repos.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
import { writeWorkspaceOutput } from "./command-context.js";
|
|
6
|
+
import { syncCodexSessions } from "./codex-sessions.js";
|
|
7
|
+
import { args, arity, canonical, execute, expandHome, git, gitRaw, inside, json, locked, message, object, pooled, readJson, required, writeJson, } from "./common.js";
|
|
8
|
+
import { branchStatus, Store, } from "./store.js";
|
|
9
|
+
const validName = (name) => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
|
|
10
|
+
export function worktrees(repo) {
|
|
11
|
+
return gitRaw(repo, "worktree", "list", "--porcelain", "-z")
|
|
12
|
+
.split("\0\0")
|
|
13
|
+
.map((block) => Object.fromEntries(block
|
|
14
|
+
.split("\0")
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.map((part) => {
|
|
17
|
+
const index = part.indexOf(" ");
|
|
18
|
+
return index < 0
|
|
19
|
+
? [part, ""]
|
|
20
|
+
: [part.slice(0, index), part.slice(index + 1)];
|
|
21
|
+
})))
|
|
22
|
+
.filter((record) => "worktree" in record);
|
|
23
|
+
}
|
|
24
|
+
class Registry {
|
|
25
|
+
settings;
|
|
26
|
+
file;
|
|
27
|
+
constructor(settings) {
|
|
28
|
+
this.settings = settings;
|
|
29
|
+
this.file = path.join(settings.stateDir, "repositories.json");
|
|
30
|
+
}
|
|
31
|
+
read() {
|
|
32
|
+
if (!fs.existsSync(this.file))
|
|
33
|
+
return { schemaVersion: 1, default: null, repositories: {} };
|
|
34
|
+
const data = readJson(this.file);
|
|
35
|
+
try {
|
|
36
|
+
if (data.schemaVersion !== 1 || !object(data.repositories))
|
|
37
|
+
throw new Error();
|
|
38
|
+
const roots = new Set(), directories = new Set();
|
|
39
|
+
for (const [name, repo] of Object.entries(data.repositories)) {
|
|
40
|
+
if (!validName(name) ||
|
|
41
|
+
!object(repo) ||
|
|
42
|
+
!validName(repo.worktreeName) ||
|
|
43
|
+
typeof repo.root !== "string" ||
|
|
44
|
+
!path.isAbsolute(repo.root) ||
|
|
45
|
+
typeof repo.commonDir !== "string" ||
|
|
46
|
+
!path.isAbsolute(repo.commonDir) ||
|
|
47
|
+
typeof repo.base !== "string" ||
|
|
48
|
+
!repo.base ||
|
|
49
|
+
[repo.root, repo.commonDir, repo.base].some((value) => /[\r\n]/.test(value)) ||
|
|
50
|
+
roots.has(repo.root) ||
|
|
51
|
+
directories.has(repo.worktreeName))
|
|
52
|
+
throw new Error();
|
|
53
|
+
roots.add(repo.root);
|
|
54
|
+
directories.add(repo.worktreeName);
|
|
55
|
+
}
|
|
56
|
+
if (data.default !== null &&
|
|
57
|
+
(typeof data.default !== "string" ||
|
|
58
|
+
!Object.hasOwn(data.repositories, data.default)))
|
|
59
|
+
throw new Error();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`Invalid repository registry: ${this.file}`);
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
legacy() {
|
|
67
|
+
const s = this.settings;
|
|
68
|
+
return {
|
|
69
|
+
name: s.legacyName,
|
|
70
|
+
root: canonical(s.legacyRoot),
|
|
71
|
+
worktreeName: s.legacyName,
|
|
72
|
+
base: s.legacyBase,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
resolve(data) {
|
|
76
|
+
const repos = data.repositories, selection = this.settings.selection;
|
|
77
|
+
if (selection) {
|
|
78
|
+
if (!Object.hasOwn(repos, selection))
|
|
79
|
+
throw new Error(`Unknown repository: ${selection}. Use 'rl repo list'`);
|
|
80
|
+
return { name: selection, ...repos[selection] };
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(repos).length) {
|
|
83
|
+
let common = null;
|
|
84
|
+
try {
|
|
85
|
+
common = canonical(git(process.cwd(), "rev-parse", "--path-format=absolute", "--git-common-dir"));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* Outside a checkout: use default. */
|
|
89
|
+
}
|
|
90
|
+
for (const [name, repo] of Object.entries(repos))
|
|
91
|
+
if (repo.commonDir === common)
|
|
92
|
+
return { name, ...repo };
|
|
93
|
+
if (data.default)
|
|
94
|
+
return { name: data.default, ...repos[data.default] };
|
|
95
|
+
}
|
|
96
|
+
return this.legacy();
|
|
97
|
+
}
|
|
98
|
+
async checkout(name, output) {
|
|
99
|
+
const data = this.read();
|
|
100
|
+
const repos = Object.entries(data.repositories)
|
|
101
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
102
|
+
.map(([name, repo]) => ({ name, ...repo }));
|
|
103
|
+
if (!repos.length)
|
|
104
|
+
throw new Error("No registered repositories. Run 'rl repo add .' inside a repository first.");
|
|
105
|
+
let selected = name ? repos.find((repo) => repo.name === name) : undefined;
|
|
106
|
+
if (name && !selected)
|
|
107
|
+
throw new Error(`Unknown repository: ${name}. Use 'rl repo list'`);
|
|
108
|
+
if (!name && repos.length === 1)
|
|
109
|
+
selected = repos[0];
|
|
110
|
+
if (!selected) {
|
|
111
|
+
const visible = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
112
|
+
const cell = (value, width) => {
|
|
113
|
+
const text = visible(value);
|
|
114
|
+
return (text.length > width ? text.slice(0, width - 1) + "…" : text).padEnd(width);
|
|
115
|
+
};
|
|
116
|
+
const nameWidth = Math.min(32, Math.max(12, ...repos.map((repo) => repo.name.length)));
|
|
117
|
+
const baseWidth = Math.min(24, Math.max(4, ...repos.map((repo) => visible(repo.base).length)));
|
|
118
|
+
const pathWidth = Math.max(12, (process.stderr.columns || 110) - nameWidth - baseWidth - 10);
|
|
119
|
+
const dim = (value) => process.env.NO_COLOR !== undefined ? value : `\x1b[90m${value}\x1b[0m`;
|
|
120
|
+
const home = os.homedir();
|
|
121
|
+
const rows = repos.map((repo) => ({
|
|
122
|
+
repo,
|
|
123
|
+
line: `${repo.name}\t${cell(repo.name, nameWidth)} ${dim(cell(repo.base, baseWidth))} ${dim(cell(repo.root.startsWith(home + path.sep) ? "~" + repo.root.slice(home.length) : repo.root, pathWidth))}`,
|
|
124
|
+
}));
|
|
125
|
+
const result = await execute([
|
|
126
|
+
"fzf",
|
|
127
|
+
"--ansi",
|
|
128
|
+
"--prompt=Repository › ",
|
|
129
|
+
"--delimiter=\t",
|
|
130
|
+
"--with-nth=2..",
|
|
131
|
+
"--height=~50%",
|
|
132
|
+
"--min-height=8",
|
|
133
|
+
"--border=rounded",
|
|
134
|
+
"--info=inline",
|
|
135
|
+
"--reverse",
|
|
136
|
+
"--no-multi",
|
|
137
|
+
"--no-sort",
|
|
138
|
+
`--header=Enter open · Esc cancel\n\n${cell("REPOSITORY", nameWidth)} ${cell("BASE", baseWidth)} PATH`,
|
|
139
|
+
], process.cwd(), {
|
|
140
|
+
input: rows.map((row) => row.line).join("\n") + "\n",
|
|
141
|
+
terminalError: true,
|
|
142
|
+
});
|
|
143
|
+
if ([1, 130].includes(result.code))
|
|
144
|
+
return;
|
|
145
|
+
if (result.code)
|
|
146
|
+
throw new Error(`fzf failed (exit ${result.code})`);
|
|
147
|
+
const line = result.stdout.replace(/\r?\n$/, "");
|
|
148
|
+
selected = rows.find((row) => stripVTControlCharacters(row.line) ===
|
|
149
|
+
stripVTControlCharacters(line) || row.repo.name === line)?.repo;
|
|
150
|
+
if (!selected)
|
|
151
|
+
throw new Error("Picker returned an unknown repository");
|
|
152
|
+
}
|
|
153
|
+
const current = this.read().repositories[selected.name];
|
|
154
|
+
if (!current ||
|
|
155
|
+
JSON.stringify(current) !==
|
|
156
|
+
JSON.stringify(data.repositories[selected.name]))
|
|
157
|
+
throw new Error("Repository registration changed during selection; retry the command");
|
|
158
|
+
if (canonical(git(selected.root, "rev-parse", "--show-toplevel")) !==
|
|
159
|
+
selected.root ||
|
|
160
|
+
canonical(git(selected.root, "rev-parse", "--path-format=absolute", "--git-common-dir")) !== selected.commonDir)
|
|
161
|
+
throw new Error("Repository checkout no longer matches its registration; register it again");
|
|
162
|
+
writeWorkspaceOutput(selected.root, output);
|
|
163
|
+
}
|
|
164
|
+
async manage(rest, values) {
|
|
165
|
+
await locked(path.dirname(this.file), ".repositories.typescript.lock", () => {
|
|
166
|
+
const data = this.read(), repos = data.repositories, [action] = rest;
|
|
167
|
+
let name = rest[1];
|
|
168
|
+
if (action === "list") {
|
|
169
|
+
arity(rest, 1);
|
|
170
|
+
if (values.json)
|
|
171
|
+
json(data);
|
|
172
|
+
else if (!Object.keys(repos).length)
|
|
173
|
+
console.log("No registered repositories; using legacy RL_REPO_ROOT configuration.");
|
|
174
|
+
else
|
|
175
|
+
for (const name of Object.keys(repos).sort()) {
|
|
176
|
+
const repo = repos[name];
|
|
177
|
+
console.log(`${name === data.default ? "*" : " "} ${name} ${repo.root} base=${repo.base} directory=${repo.worktreeName}`);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (action === "add") {
|
|
182
|
+
arity(rest, 1, 3);
|
|
183
|
+
if (rest.length === 3 && values.alias !== undefined)
|
|
184
|
+
throw new Error("Choose either --alias <name> or the legacy <alias> <path> form");
|
|
185
|
+
const target = rest.length === 3 ? rest[2] : (rest[1] ?? ".");
|
|
186
|
+
const root = canonical(worktrees(expandHome(target))[0].worktree);
|
|
187
|
+
name = String(values.alias ?? (rest.length === 3 ? name : repositoryName(root)));
|
|
188
|
+
if (!validName(name))
|
|
189
|
+
throw new Error("Repository names must contain letters, numbers, dots, underscores, or hyphens; supply --alias <name>");
|
|
190
|
+
if (Object.hasOwn(repos, name))
|
|
191
|
+
throw new Error(`Repository name already registered: ${name}; choose --alias <name>`);
|
|
192
|
+
const common = canonical(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir"));
|
|
193
|
+
if (Object.values(repos).some((repo) => repo.commonDir === common))
|
|
194
|
+
throw new Error("This Git repository is already registered under another name");
|
|
195
|
+
const isLegacy = root === this.legacy().root;
|
|
196
|
+
const directory = values["worktree-name"] ??
|
|
197
|
+
(isLegacy ? this.settings.legacyName : name);
|
|
198
|
+
const base = values.base ??
|
|
199
|
+
(isLegacy ? this.settings.legacyBase : defaultBase(root));
|
|
200
|
+
if (!validName(directory) ||
|
|
201
|
+
typeof base !== "string" ||
|
|
202
|
+
!base ||
|
|
203
|
+
/[\r\n]/.test(root + base))
|
|
204
|
+
throw new Error("Invalid repository path, worktree directory name, or base");
|
|
205
|
+
if (Object.values(repos).some((repo) => repo.worktreeName === directory))
|
|
206
|
+
throw new Error("Worktree directory name is already in use; choose --worktree-name");
|
|
207
|
+
Object.defineProperty(repos, name, {
|
|
208
|
+
value: { root, commonDir: common, worktreeName: directory, base },
|
|
209
|
+
enumerable: true,
|
|
210
|
+
writable: true,
|
|
211
|
+
configurable: true,
|
|
212
|
+
});
|
|
213
|
+
if (!data.default)
|
|
214
|
+
data.default = name;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
arity(rest, 2);
|
|
218
|
+
if (action !== "use" && action !== "remove")
|
|
219
|
+
throw new Error("Usage: rl repo add|list|use|remove");
|
|
220
|
+
if (!Object.hasOwn(repos, name))
|
|
221
|
+
throw new Error(`Unknown repository: ${name}`);
|
|
222
|
+
if (action === "use")
|
|
223
|
+
data.default = name;
|
|
224
|
+
else {
|
|
225
|
+
delete repos[name];
|
|
226
|
+
if (data.default === name)
|
|
227
|
+
data.default = Object.keys(repos)[0] ?? null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
writeJson(this.file, data);
|
|
231
|
+
console.log(`Repository ${action}: ${name}`);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function repositoryName(root) {
|
|
236
|
+
let source;
|
|
237
|
+
try {
|
|
238
|
+
source = git(root, "remote", "get-url", "origin");
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return path.basename(root);
|
|
242
|
+
}
|
|
243
|
+
if (source.includes("://")) {
|
|
244
|
+
try {
|
|
245
|
+
source = new URL(source).pathname;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* Git also accepts local paths. */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return source
|
|
252
|
+
.replace(/\/+$/, "")
|
|
253
|
+
.split(/[/:]/)
|
|
254
|
+
.at(-1)
|
|
255
|
+
.replace(/\.git$/, "");
|
|
256
|
+
}
|
|
257
|
+
function defaultBase(root) {
|
|
258
|
+
try {
|
|
259
|
+
return git(root, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
const branch = git(root, "branch", "--show-current");
|
|
263
|
+
if (!branch)
|
|
264
|
+
throw new Error("Cannot infer default base; supply --base <branch>");
|
|
265
|
+
return branch;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function repositoryRows(repo, settings, fetch, includeDeleted) {
|
|
269
|
+
const store = new Store(repo.root, settings.stateDir), rows = [], errors = [];
|
|
270
|
+
if (fetch) {
|
|
271
|
+
try {
|
|
272
|
+
const result = await execute(["git", "-C", repo.root, "fetch", "--all", "--prune"], repo.root);
|
|
273
|
+
if (result.code)
|
|
274
|
+
errors.push(result.stderr.trim() || "Git fetch failed");
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
errors.push(message(error));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await syncCodexSessions(store);
|
|
282
|
+
const records = store.records(), activePaths = new Set(records
|
|
283
|
+
.filter(([, data]) => data.status === "active")
|
|
284
|
+
.map(([, data]) => data.worktree));
|
|
285
|
+
for (const [file, data] of records)
|
|
286
|
+
if (data.status !== "deleted" || includeDeleted)
|
|
287
|
+
rows.push({
|
|
288
|
+
...data,
|
|
289
|
+
stateDirectory: path.dirname(file),
|
|
290
|
+
repositoryName: repo.name,
|
|
291
|
+
gitStatus: branchStatus(repo.root, data),
|
|
292
|
+
});
|
|
293
|
+
const base = canonical(path.join(settings.baseDir, repo.worktreeName));
|
|
294
|
+
try {
|
|
295
|
+
for (const item of worktrees(repo.root).slice(1)) {
|
|
296
|
+
const root = canonical(item.worktree);
|
|
297
|
+
if (!inside(root, base) || activePaths.has(root))
|
|
298
|
+
continue;
|
|
299
|
+
const row = {
|
|
300
|
+
id: path.relative(base, root),
|
|
301
|
+
key: null,
|
|
302
|
+
repository: repo.root,
|
|
303
|
+
worktree: root,
|
|
304
|
+
branch: (item.branch ?? "").replace(/^refs\/heads\//, ""),
|
|
305
|
+
status: "unregistered",
|
|
306
|
+
pr: null,
|
|
307
|
+
};
|
|
308
|
+
rows.push({
|
|
309
|
+
...row,
|
|
310
|
+
repositoryName: repo.name,
|
|
311
|
+
gitStatus: branchStatus(repo.root, row),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
errors.push(message(error));
|
|
317
|
+
}
|
|
318
|
+
for (const row of rows)
|
|
319
|
+
if (row.gitStatus.availability === "error")
|
|
320
|
+
errors.push(`${row.id}: ${row.gitStatus.error}`);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
errors.push(message(error));
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
rows,
|
|
327
|
+
errors: errors.map((error) => ({ repository: repo.name, message: error })),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function display(rows) {
|
|
331
|
+
const table = [
|
|
332
|
+
[
|
|
333
|
+
"REPOSITORY",
|
|
334
|
+
"INSTANCE",
|
|
335
|
+
"BRANCH",
|
|
336
|
+
"WORKTREE",
|
|
337
|
+
"CHANGES",
|
|
338
|
+
"UPSTREAM",
|
|
339
|
+
"COMMITS + / −",
|
|
340
|
+
"PR (cached)",
|
|
341
|
+
"CODEX",
|
|
342
|
+
"CLAUDE",
|
|
343
|
+
"TASK",
|
|
344
|
+
],
|
|
345
|
+
];
|
|
346
|
+
for (const row of rows) {
|
|
347
|
+
const s = row.gitStatus;
|
|
348
|
+
const changes = s.availability !== "ok"
|
|
349
|
+
? s.availability
|
|
350
|
+
: !s.dirty
|
|
351
|
+
? "clean"
|
|
352
|
+
: [
|
|
353
|
+
["staged", "S"],
|
|
354
|
+
["unstaged", "M"],
|
|
355
|
+
["untracked", "?"],
|
|
356
|
+
["conflicts", "!"],
|
|
357
|
+
]
|
|
358
|
+
.filter(([key]) => s[key])
|
|
359
|
+
.map(([key, letter]) => `${s[key]}${letter}`)
|
|
360
|
+
.join("/");
|
|
361
|
+
const sync = !s.upstream
|
|
362
|
+
? "no upstream"
|
|
363
|
+
: s.ahead === null
|
|
364
|
+
? "unavailable"
|
|
365
|
+
: `+${s.ahead}/-${s.behind}`;
|
|
366
|
+
table.push([
|
|
367
|
+
row.repositoryName,
|
|
368
|
+
row.id,
|
|
369
|
+
(s.branch || row.branch || "(detached)") +
|
|
370
|
+
(s.branchChanged ? " [changed]" : ""),
|
|
371
|
+
row.status,
|
|
372
|
+
changes,
|
|
373
|
+
s.upstream || "-",
|
|
374
|
+
sync,
|
|
375
|
+
row.pr ? `#${row.pr.number} ${row.pr.state}` : "-",
|
|
376
|
+
String(row.sessions?.codex.length ?? 0),
|
|
377
|
+
String(row.sessions?.claude.length ?? 0),
|
|
378
|
+
row.task?.state ?? "-",
|
|
379
|
+
]);
|
|
380
|
+
}
|
|
381
|
+
const widths = table[0].map((_, col) => Math.max(...table.map((row) => row[col].length)));
|
|
382
|
+
for (const row of table)
|
|
383
|
+
console.log(row
|
|
384
|
+
.map((value, col) => value.padEnd(widths[col]))
|
|
385
|
+
.join(" ")
|
|
386
|
+
.trimEnd());
|
|
387
|
+
if (!rows.length)
|
|
388
|
+
console.log("No RL instances found.");
|
|
389
|
+
}
|
|
390
|
+
async function repositoryStatus(registry, data, fetch, asJson) {
|
|
391
|
+
const repo = registry.resolve(data);
|
|
392
|
+
const result = await repositoryRows(repo, registry.settings, fetch, false);
|
|
393
|
+
let currentWorkspace = null;
|
|
394
|
+
try {
|
|
395
|
+
if (canonical(git(process.cwd(), "rev-parse", "--path-format=absolute", "--git-common-dir")) ===
|
|
396
|
+
canonical(git(repo.root, "rev-parse", "--path-format=absolute", "--git-common-dir"))) {
|
|
397
|
+
const root = canonical(git(process.cwd(), "rev-parse", "--show-toplevel"));
|
|
398
|
+
currentWorkspace = {
|
|
399
|
+
path: root,
|
|
400
|
+
branch: git(root, "branch", "--show-current") || "(detached)",
|
|
401
|
+
main: root === repo.root,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
/* Outside the selected repository. */
|
|
407
|
+
}
|
|
408
|
+
const rows = result.rows.filter((row) => row.worktree !== repo.root);
|
|
409
|
+
const status = {
|
|
410
|
+
repository: {
|
|
411
|
+
name: repo.name,
|
|
412
|
+
root: repo.root,
|
|
413
|
+
base: repo.base,
|
|
414
|
+
worktreeDirectory: path.join(registry.settings.baseDir, repo.worktreeName),
|
|
415
|
+
registered: Object.hasOwn(data.repositories, repo.name),
|
|
416
|
+
},
|
|
417
|
+
selection: registry.settings.selection
|
|
418
|
+
? "explicit"
|
|
419
|
+
: currentWorkspace
|
|
420
|
+
? "current checkout"
|
|
421
|
+
: "default",
|
|
422
|
+
defaultRepository: data.default,
|
|
423
|
+
registeredRepositories: Object.keys(data.repositories).length,
|
|
424
|
+
currentWorkspace,
|
|
425
|
+
workspaces: {
|
|
426
|
+
total: rows.length,
|
|
427
|
+
dirty: rows.filter((row) => row.gitStatus.dirty).length,
|
|
428
|
+
unavailable: rows.filter((row) => row.gitStatus.availability !== "ok")
|
|
429
|
+
.length,
|
|
430
|
+
},
|
|
431
|
+
sessions: {
|
|
432
|
+
codex: result.rows.reduce((n, row) => n + (row.sessions?.codex.length ?? 0), 0),
|
|
433
|
+
claude: result.rows.reduce((n, row) => n + (row.sessions?.claude.length ?? 0), 0),
|
|
434
|
+
},
|
|
435
|
+
fetched: fetch,
|
|
436
|
+
errors: result.errors,
|
|
437
|
+
};
|
|
438
|
+
if (asJson)
|
|
439
|
+
json(status);
|
|
440
|
+
else {
|
|
441
|
+
const safe = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
442
|
+
const row = (label, value) => ` ${label.padEnd(13)}${safe(value)}`;
|
|
443
|
+
console.log([
|
|
444
|
+
"",
|
|
445
|
+
` ${safe(repo.name)}`,
|
|
446
|
+
"",
|
|
447
|
+
row("Repository", repo.root),
|
|
448
|
+
row("Selected via", status.selection),
|
|
449
|
+
row("Base", repo.base),
|
|
450
|
+
row("Worktrees", status.repository.worktreeDirectory),
|
|
451
|
+
row("Current", currentWorkspace
|
|
452
|
+
? `${currentWorkspace.main ? "main checkout" : currentWorkspace.branch} · ${currentWorkspace.path}`
|
|
453
|
+
: "outside this repository"),
|
|
454
|
+
row("Workspaces", `${status.workspaces.total} total · ${status.workspaces.dirty} dirty · ${status.workspaces.unavailable} unavailable`),
|
|
455
|
+
row("Sessions", `${status.sessions.codex} Codex · ${status.sessions.claude} Claude saved`),
|
|
456
|
+
row("Default", data.default ?? "legacy configuration"),
|
|
457
|
+
"",
|
|
458
|
+
" rl checkout switch repository · rl list workspaces",
|
|
459
|
+
" rl status --workspace current workspace, Git, PR and sessions",
|
|
460
|
+
"",
|
|
461
|
+
].join("\n"));
|
|
462
|
+
for (const error of result.errors)
|
|
463
|
+
console.error(`${safe(error.repository)}: ${safe(error.message)}`);
|
|
464
|
+
}
|
|
465
|
+
return result.errors.length ? 1 : 0;
|
|
466
|
+
}
|
|
467
|
+
export async function main(argv) {
|
|
468
|
+
const { values, positionals } = args(argv, [
|
|
469
|
+
"state-dir",
|
|
470
|
+
"base-dir",
|
|
471
|
+
"legacy-root",
|
|
472
|
+
"legacy-name",
|
|
473
|
+
"legacy-base",
|
|
474
|
+
"selection",
|
|
475
|
+
"base",
|
|
476
|
+
"worktree-name",
|
|
477
|
+
"alias",
|
|
478
|
+
"output-path",
|
|
479
|
+
], ["all", "list", "json", "fetch", "include-deleted"]);
|
|
480
|
+
const settings = {
|
|
481
|
+
stateDir: required(values, "state-dir"),
|
|
482
|
+
baseDir: required(values, "base-dir"),
|
|
483
|
+
legacyRoot: required(values, "legacy-root"),
|
|
484
|
+
legacyName: required(values, "legacy-name"),
|
|
485
|
+
legacyBase: required(values, "legacy-base"),
|
|
486
|
+
selection: String(values.selection ?? ""),
|
|
487
|
+
};
|
|
488
|
+
const registry = new Registry(settings), [command, ...rest] = positionals;
|
|
489
|
+
if (command === "repo") {
|
|
490
|
+
await registry.manage(rest, values);
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
if (command === "checkout") {
|
|
494
|
+
arity(rest, 0, 1);
|
|
495
|
+
await registry.checkout(rest[0], values["output-path"]);
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
const data = registry.read();
|
|
499
|
+
arity(rest, 0);
|
|
500
|
+
if (command === "status" && !values.all && !values.list)
|
|
501
|
+
return repositoryStatus(registry, data, !!values.fetch, !!values.json);
|
|
502
|
+
if (command === "resolve") {
|
|
503
|
+
const repo = registry.resolve(data);
|
|
504
|
+
console.log([repo.root, repo.worktreeName, repo.base, repo.name].join("\n"));
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
if (command !== "status" || !!values.all === !!values.list)
|
|
508
|
+
throw new Error("Choose status --all or --list");
|
|
509
|
+
if (values.all && settings.selection)
|
|
510
|
+
throw new Error("Choose --repo <name> with status --list, or status --all");
|
|
511
|
+
let repos = values.all
|
|
512
|
+
? Object.keys(data.repositories)
|
|
513
|
+
.sort()
|
|
514
|
+
.map((name) => ({ name, ...data.repositories[name] }))
|
|
515
|
+
: [registry.resolve(data)];
|
|
516
|
+
if (!repos.length)
|
|
517
|
+
repos = [registry.legacy()];
|
|
518
|
+
const results = await pooled(repos, 4, (repo) => repositoryRows(repo, settings, !!values.fetch, !!values["include-deleted"]));
|
|
519
|
+
const rows = results
|
|
520
|
+
.flatMap((result) => result.rows)
|
|
521
|
+
.sort((a, b) => [a.repositoryName, a.id, a.status]
|
|
522
|
+
.join("\0")
|
|
523
|
+
.localeCompare([b.repositoryName, b.id, b.status].join("\0"))), errors = results.flatMap((result) => result.errors);
|
|
524
|
+
if (values.json)
|
|
525
|
+
json({ instances: rows, errors, fetched: !!values.fetch });
|
|
526
|
+
else {
|
|
527
|
+
display(rows);
|
|
528
|
+
console.log("S=staged M=unstaged ?=untracked !=conflicts; +ahead/-behind relative to upstream.");
|
|
529
|
+
console.log("Remote refs: " +
|
|
530
|
+
(values.fetch
|
|
531
|
+
? "fetch requested (see errors if any)."
|
|
532
|
+
: "locally cached; use --fetch to refresh."));
|
|
533
|
+
for (const error of errors)
|
|
534
|
+
console.error(`${error.repository}: ${error.message}`);
|
|
535
|
+
}
|
|
536
|
+
return errors.length ? 1 : 0;
|
|
537
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { fields, SEVERITIES, } from "./automation-config.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const string = { type: "string" };
|
|
4
|
+
const findingProperties = {
|
|
5
|
+
title: string,
|
|
6
|
+
severity: { type: "string", enum: [...SEVERITIES] },
|
|
7
|
+
required: { type: "boolean" },
|
|
8
|
+
rootCause: string,
|
|
9
|
+
file: string,
|
|
10
|
+
line: { type: "integer", minimum: 1 },
|
|
11
|
+
evidence: string,
|
|
12
|
+
recommendation: string,
|
|
13
|
+
confidence: { type: "string", enum: ["observed", "inferred"] },
|
|
14
|
+
};
|
|
15
|
+
export const RESULT_SCHEMA = {
|
|
16
|
+
type: "object",
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
properties: {
|
|
19
|
+
summary: string,
|
|
20
|
+
complete: { type: "boolean" },
|
|
21
|
+
findings: {
|
|
22
|
+
type: "array",
|
|
23
|
+
items: {
|
|
24
|
+
type: "object",
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: findingProperties,
|
|
27
|
+
required: Object.keys(findingProperties),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
limitations: { type: "array", items: string },
|
|
31
|
+
checkRequests: { type: "array", items: string },
|
|
32
|
+
routingLeads: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: { owner: string, file: string, reason: string },
|
|
38
|
+
required: ["owner", "file", "reason"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: [
|
|
43
|
+
"summary",
|
|
44
|
+
"complete",
|
|
45
|
+
"findings",
|
|
46
|
+
"limitations",
|
|
47
|
+
"checkRequests",
|
|
48
|
+
"routingLeads",
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
function prose(value) {
|
|
52
|
+
if (typeof value !== "string" ||
|
|
53
|
+
!value.trim() ||
|
|
54
|
+
value.length > 16000 ||
|
|
55
|
+
/[\x00-\x08\x0b-\x1f\x7f]/.test(value))
|
|
56
|
+
throw new Error("Expected nonempty review text (up to 16000 characters)");
|
|
57
|
+
}
|
|
58
|
+
function sourcePath(value) {
|
|
59
|
+
prose(value);
|
|
60
|
+
if (path.isAbsolute(value) ||
|
|
61
|
+
value.includes("\\") ||
|
|
62
|
+
value.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
63
|
+
/[\r\n]/.test(value))
|
|
64
|
+
throw new Error("Evidence paths must be relative to the reviewed workspace");
|
|
65
|
+
}
|
|
66
|
+
export function validateResult(value) {
|
|
67
|
+
fields(value, Object.keys(RESULT_SCHEMA.properties));
|
|
68
|
+
prose(value.summary);
|
|
69
|
+
if (typeof value.complete !== "boolean" ||
|
|
70
|
+
!Array.isArray(value.findings) ||
|
|
71
|
+
value.findings.length > 100)
|
|
72
|
+
throw new Error("Invalid review completeness or findings");
|
|
73
|
+
for (const finding of value.findings) {
|
|
74
|
+
fields(finding, Object.keys(findingProperties));
|
|
75
|
+
for (const key of ["title", "rootCause", "evidence", "recommendation"])
|
|
76
|
+
prose(finding[key]);
|
|
77
|
+
sourcePath(finding.file);
|
|
78
|
+
if (!SEVERITIES.includes(finding.severity) ||
|
|
79
|
+
typeof finding.required !== "boolean" ||
|
|
80
|
+
!Number.isSafeInteger(finding.line) ||
|
|
81
|
+
Number(finding.line) < 1 ||
|
|
82
|
+
!["observed", "inferred"].includes(String(finding.confidence)))
|
|
83
|
+
throw new Error("Invalid finding severity, line or confidence");
|
|
84
|
+
}
|
|
85
|
+
for (const key of ["limitations", "checkRequests"]) {
|
|
86
|
+
if (!Array.isArray(value[key]) || value[key].length > 100)
|
|
87
|
+
throw new Error(`Invalid ${key}`);
|
|
88
|
+
for (const item of value[key])
|
|
89
|
+
prose(item);
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(value.routingLeads) || value.routingLeads.length > 32)
|
|
92
|
+
throw new Error("Invalid routing leads");
|
|
93
|
+
for (const lead of value.routingLeads) {
|
|
94
|
+
fields(lead, ["owner", "file", "reason"]);
|
|
95
|
+
prose(lead.owner);
|
|
96
|
+
sourcePath(lead.file);
|
|
97
|
+
prose(lead.reason);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Scoring belongs to RL. Agents supply evidence and qualitative classifications. */
|
|
101
|
+
export function evaluate(policy, reports, limitations = []) {
|
|
102
|
+
const unique = new Map();
|
|
103
|
+
for (const { owner, result } of reports) {
|
|
104
|
+
if (!result.complete)
|
|
105
|
+
limitations.push(`${owner}: review coverage is incomplete`);
|
|
106
|
+
limitations.push(...result.limitations.map((note) => `${owner}: ${note}`));
|
|
107
|
+
for (const finding of result.findings) {
|
|
108
|
+
// Deliberately conservative: distinct descriptions/locations remain separate.
|
|
109
|
+
const key = JSON.stringify([
|
|
110
|
+
finding.file,
|
|
111
|
+
finding.line,
|
|
112
|
+
finding.rootCause.toLowerCase().replace(/\s+/g, " ").trim(),
|
|
113
|
+
]);
|
|
114
|
+
const previous = unique.get(key);
|
|
115
|
+
if (!previous)
|
|
116
|
+
unique.set(key, { ...finding, owners: [owner] });
|
|
117
|
+
else {
|
|
118
|
+
previous.owners = [...new Set([...previous.owners, owner])];
|
|
119
|
+
previous.required ||= finding.required;
|
|
120
|
+
if (SEVERITIES.indexOf(finding.severity) <
|
|
121
|
+
SEVERITIES.indexOf(previous.severity))
|
|
122
|
+
previous.severity = finding.severity;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const findings = [...unique.values()].sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
127
|
+
const deductions = {
|
|
128
|
+
critical: 0,
|
|
129
|
+
high: 0,
|
|
130
|
+
medium: 0,
|
|
131
|
+
low: 0,
|
|
132
|
+
};
|
|
133
|
+
for (const finding of findings)
|
|
134
|
+
deductions[finding.severity] += policy.weights[finding.severity];
|
|
135
|
+
const score = reports.length
|
|
136
|
+
? Math.max(0, 100 - Object.values(deductions).reduce((a, b) => a + b, 0))
|
|
137
|
+
: null;
|
|
138
|
+
return {
|
|
139
|
+
score,
|
|
140
|
+
verdict: limitations.length || score === null
|
|
141
|
+
? "incomplete"
|
|
142
|
+
: (policy.blockRequired && findings.some((f) => f.required)) ||
|
|
143
|
+
score < policy.minimumScore
|
|
144
|
+
? "fix-required"
|
|
145
|
+
: "ready",
|
|
146
|
+
findings,
|
|
147
|
+
limitations: [...new Set(limitations)],
|
|
148
|
+
deductions,
|
|
149
|
+
};
|
|
150
|
+
}
|