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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +735 -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 +70 -0
  11. package/dist/agent-registry.js +8 -0
  12. package/dist/agent-resume.js +96 -0
  13. package/dist/agent-runner.js +386 -0
  14. package/dist/agent-workflows.js +275 -0
  15. package/dist/agents.js +73 -0
  16. package/dist/automation-config.js +381 -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 +89 -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 +650 -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-collaboration.js +531 -0
  48. package/dist/workflow-config.js +177 -0
  49. package/dist/workflow.js +211 -0
  50. package/lib/rl.zsh +329 -0
  51. package/node_modules/graceful-fs/LICENSE +15 -0
  52. package/node_modules/graceful-fs/README.md +143 -0
  53. package/node_modules/graceful-fs/clone.js +23 -0
  54. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  55. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  56. package/node_modules/graceful-fs/package.json +53 -0
  57. package/node_modules/graceful-fs/polyfills.js +355 -0
  58. package/node_modules/proper-lockfile/LICENSE +21 -0
  59. package/node_modules/proper-lockfile/README.md +183 -0
  60. package/node_modules/proper-lockfile/index.js +40 -0
  61. package/node_modules/proper-lockfile/lib/adapter.js +85 -0
  62. package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
  63. package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
  64. package/node_modules/proper-lockfile/package.json +71 -0
  65. package/node_modules/retry/.npmignore +3 -0
  66. package/node_modules/retry/.travis.yml +15 -0
  67. package/node_modules/retry/License +21 -0
  68. package/node_modules/retry/Makefile +18 -0
  69. package/node_modules/retry/README.md +227 -0
  70. package/node_modules/retry/equation.gif +0 -0
  71. package/node_modules/retry/example/dns.js +31 -0
  72. package/node_modules/retry/example/stop.js +40 -0
  73. package/node_modules/retry/index.js +1 -0
  74. package/node_modules/retry/lib/retry.js +100 -0
  75. package/node_modules/retry/lib/retry_operation.js +158 -0
  76. package/node_modules/retry/package.json +32 -0
  77. package/node_modules/retry/test/common.js +10 -0
  78. package/node_modules/retry/test/integration/test-forever.js +24 -0
  79. package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
  80. package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
  81. package/node_modules/retry/test/integration/test-timeouts.js +69 -0
  82. package/node_modules/signal-exit/LICENSE.txt +16 -0
  83. package/node_modules/signal-exit/README.md +39 -0
  84. package/node_modules/signal-exit/index.js +202 -0
  85. package/node_modules/signal-exit/package.json +38 -0
  86. package/node_modules/signal-exit/signals.js +53 -0
  87. package/package.json +57 -4
  88. package/shell/rl.zsh +11 -0
package/dist/store.js ADDED
@@ -0,0 +1,379 @@
1
+ import { automationContext } from "./session-workflows.js";
2
+ import { adapters } from "./agent-registry.js";
3
+ import { validateTracking } from "./activity.js";
4
+ import { lastAccessed } from "./access.js";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { atomicWrite, canonical, git, gitRaw, inside, isSymlink, locked, message, now, object, readJson, sessionId, shellQuote, timestamp, writeJson, } from "./common.js";
9
+ import { NOTE_TEMPLATES } from "./templates.js";
10
+ import { handoffText, validateTask } from "./task-state.js";
11
+ const HOOK_TIMEOUT_SECONDS = 10;
12
+ const CODEX_END_TIMEOUT_SECONDS = 3;
13
+ export function branchStatus(repo, data) {
14
+ const result = {
15
+ availability: data.status,
16
+ branch: null,
17
+ expectedBranch: data.branch,
18
+ head: null,
19
+ upstream: null,
20
+ ahead: null,
21
+ behind: null,
22
+ staged: 0,
23
+ unstaged: 0,
24
+ untracked: 0,
25
+ conflicts: 0,
26
+ dirty: null,
27
+ branchChanged: false,
28
+ };
29
+ if (data.status === "deleted")
30
+ return result;
31
+ if (!fs.existsSync(data.worktree))
32
+ return { ...result, availability: "missing" };
33
+ try {
34
+ const root = canonical(data.worktree);
35
+ if (canonical(git(root, "rev-parse", "--show-toplevel")) !== root ||
36
+ canonical(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir")) !==
37
+ canonical(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")))
38
+ throw new Error("Worktree path no longer belongs to this repository");
39
+ const entries = gitRaw(root, "--no-optional-locks", "status", "--porcelain=v2", "--branch", "--untracked-files=normal", "-z").split("\0");
40
+ for (let i = 0; i < entries.length; i++) {
41
+ const entry = entries[i];
42
+ if (entry.startsWith("# branch.head "))
43
+ result.branch = entry.slice(14);
44
+ else if (entry.startsWith("# branch.oid "))
45
+ result.head = entry.slice(13);
46
+ else if (entry.startsWith("# branch.upstream "))
47
+ result.upstream = entry.slice(18);
48
+ else if (entry.startsWith("# branch.ab ")) {
49
+ const [ahead, behind] = entry.slice(12).split(" ");
50
+ result.ahead = Number(ahead.slice(1));
51
+ result.behind = Number(behind.slice(1));
52
+ }
53
+ else if (/^[12u] /.test(entry)) {
54
+ const xy = entry.split(" ")[1];
55
+ result.staged += Number(xy[0] !== ".");
56
+ result.unstaged += Number(xy[1] !== ".");
57
+ result.conflicts += Number(entry[0] === "u");
58
+ if (entry[0] === "2")
59
+ i++;
60
+ }
61
+ else if (entry.startsWith("? "))
62
+ result.untracked++;
63
+ }
64
+ result.dirty = !!(result.staged ||
65
+ result.unstaged ||
66
+ result.untracked ||
67
+ result.conflicts);
68
+ result.branchChanged = result.branch !== data.branch;
69
+ result.availability = "ok";
70
+ }
71
+ catch (error) {
72
+ result.availability = "error";
73
+ result.error = message(error);
74
+ }
75
+ return result;
76
+ }
77
+ export class Store {
78
+ repo;
79
+ stateDir;
80
+ root;
81
+ constructor(repo, stateDir) {
82
+ this.repo = canonical(repo);
83
+ this.stateDir = canonical(stateDir);
84
+ this.root = path.join(this.stateDir, createHash("sha256").update(this.repo).digest("hex").slice(0, 24));
85
+ }
86
+ archiveWorktrees(paths) {
87
+ for (const [file, data] of this.records()) {
88
+ if (data.status !== "active" || !paths.includes(data.worktree))
89
+ continue;
90
+ data.status = "deleted";
91
+ data.deletedAt = now();
92
+ this.save(file, data);
93
+ }
94
+ }
95
+ readDocument(file, kind) {
96
+ return fs.readFileSync(path.join(path.dirname(file), kind + ".md"), "utf8");
97
+ }
98
+ updateDocument(file, data, kind, action, text) {
99
+ const content = action === "append"
100
+ ? this.readDocument(file, kind).trimEnd() +
101
+ `\n\n### Update ${now()}\n\n` +
102
+ text.trimEnd() +
103
+ "\n"
104
+ : text;
105
+ atomicWrite(path.join(path.dirname(file), kind + ".md"), content);
106
+ this.save(file, data);
107
+ }
108
+ locked(action) {
109
+ return locked(this.root, ".typescript.lock", action);
110
+ }
111
+ records() {
112
+ if (!fs.existsSync(this.root))
113
+ return [];
114
+ const records = [];
115
+ for (const key of fs.readdirSync(this.root).sort()) {
116
+ const file = path.join(this.root, key, "instance.json");
117
+ if (!fs.existsSync(file))
118
+ continue;
119
+ const data = readJson(file);
120
+ try {
121
+ if (data.schemaVersion !== 1 ||
122
+ data.key !== key ||
123
+ data.repository !== this.repo ||
124
+ !["active", "deleted"].includes(String(data.status)) ||
125
+ typeof data.id !== "string" ||
126
+ !data.id ||
127
+ typeof data.branch !== "string" ||
128
+ !data.branch ||
129
+ typeof data.worktree !== "string" ||
130
+ !path.isAbsolute(data.worktree) ||
131
+ !(data.pr === null ||
132
+ (object(data.pr) && typeof data.pr.url === "string")) ||
133
+ !object(data.sessions) ||
134
+ (data.extendedHooks !== undefined &&
135
+ typeof data.extendedHooks !== "boolean"))
136
+ throw new Error();
137
+ validateTracking(data);
138
+ if (data.task !== undefined)
139
+ validateTask(data.task);
140
+ for (const agent of ["codex", "claude"]) {
141
+ const sessions = data.sessions[agent];
142
+ if (!Array.isArray(sessions))
143
+ throw new Error();
144
+ const seen = new Set();
145
+ for (const item of sessions) {
146
+ if (!object(item) ||
147
+ sessionId(item.id) !== item.id ||
148
+ seen.has(item.id) ||
149
+ typeof item.createdAt !== "string" ||
150
+ typeof item.lastUsedAt !== "string")
151
+ throw new Error();
152
+ seen.add(item.id);
153
+ timestamp(item.createdAt);
154
+ timestamp(item.lastUsedAt);
155
+ }
156
+ }
157
+ }
158
+ catch {
159
+ throw new Error(`Invalid instance metadata: ${file}`);
160
+ }
161
+ records.push([file, data]);
162
+ }
163
+ return records;
164
+ }
165
+ worktree(cwd) {
166
+ const root = canonical(git(cwd, "rev-parse", "--show-toplevel"));
167
+ if (canonical(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir")) !==
168
+ canonical(git(this.repo, "rev-parse", "--path-format=absolute", "--git-common-dir")))
169
+ throw new Error("Current directory does not belong to the configured RL repository");
170
+ const branch = git(root, "branch", "--show-current");
171
+ if (!branch)
172
+ throw new Error("RL instances require a checked-out branch");
173
+ return [root, branch];
174
+ }
175
+ current(cwd = process.cwd()) {
176
+ return this.instanceForWorktree(...this.worktree(cwd));
177
+ }
178
+ // Call only with a worktree validated by worktree().
179
+ instanceForWorktree(root, branch) {
180
+ for (const [file, data] of this.records())
181
+ if (data.status === "active" && data.worktree === root) {
182
+ if (data.branch !== branch)
183
+ throw new Error(`Instance owns branch ${data.branch}; current branch is ${branch}`);
184
+ return [file, data];
185
+ }
186
+ throw new Error("No RL instance for this worktree. Run 'rl adopt' first");
187
+ }
188
+ save(file, data) {
189
+ data.updatedAt = now();
190
+ writeJson(file, data);
191
+ }
192
+ touch(file, data, stamp = now()) {
193
+ const previous = lastAccessed(data);
194
+ data.lastAccessedAt =
195
+ previous && timestamp(previous) > timestamp(stamp) ? previous : stamp;
196
+ this.save(file, data);
197
+ }
198
+ async adopt(cwd, baseBranch, extendedHooks) {
199
+ const [root, branch] = this.worktree(cwd);
200
+ if (this.stateDir === root || inside(this.stateDir, root))
201
+ throw new Error("RL_STATE_DIR must be outside the worktree so state survives deletion");
202
+ return this.locked(() => {
203
+ for (const [file, data] of this.records())
204
+ if (data.status === "active" && data.worktree === root) {
205
+ if (data.branch !== branch)
206
+ throw new Error(`This instance already owns branch ${data.branch}`);
207
+ if (extendedHooks !== undefined)
208
+ data.extendedHooks = extendedHooks;
209
+ installHooks(this, file, data);
210
+ this.touch(file, data);
211
+ return [file, data];
212
+ }
213
+ const key = randomUUID(), stamp = now(), file = path.join(this.root, key, "instance.json");
214
+ const data = {
215
+ schemaVersion: 1,
216
+ key,
217
+ id: branch,
218
+ repository: this.repo,
219
+ branch,
220
+ worktree: root,
221
+ status: "active",
222
+ createdAt: stamp,
223
+ lastAccessedAt: stamp,
224
+ updatedAt: stamp,
225
+ sessions: { codex: [], claude: [] },
226
+ pr: null,
227
+ };
228
+ if (extendedHooks !== undefined)
229
+ data.extendedHooks = extendedHooks;
230
+ if (baseBranch)
231
+ data.baseBranch = baseBranch;
232
+ // Discovery also covers worktrees without instance records. Carry their
233
+ // snapshot into the newly adopted instance without another network lookup.
234
+ try {
235
+ const cache = readJson(path.join(this.root, "pr-cache.json"));
236
+ const entries = cache.branches;
237
+ const entry = object(entries) ? entries[branch] : undefined;
238
+ const pr = object(entry) ? entry.pr : undefined;
239
+ if (object(pr) &&
240
+ pr.headRefName === branch &&
241
+ typeof pr.url === "string")
242
+ data.pr = pr;
243
+ }
244
+ catch {
245
+ /* Missing/disposable cache does not prevent adoption. */
246
+ }
247
+ writeJson(file, data);
248
+ for (const [name, template] of Object.entries(NOTE_TEMPLATES))
249
+ atomicWrite(path.join(path.dirname(file), name), template);
250
+ installHooks(this, file, data);
251
+ return [file, data];
252
+ });
253
+ }
254
+ assertSessionOwner(file, agent, id) {
255
+ for (const [otherFile, other] of this.records())
256
+ if (otherFile !== file &&
257
+ (other.sessions[agent].some((s) => s.id === id) ||
258
+ other.activity?.[agent]?.some((s) => s.sessionId === id)))
259
+ throw new Error(`Session ${id} already belongs to RL instance ${other.id}`);
260
+ }
261
+ recordSession(file, data, agent, id, stamp = now()) {
262
+ this.associateSession(file, data, agent, id, stamp);
263
+ this.touch(file, data, stamp);
264
+ }
265
+ // Mutate the supplied record; callers persist session and activity together.
266
+ associateSession(file, data, agent, id, stamp = now()) {
267
+ const sid = sessionId(id);
268
+ this.assertSessionOwner(file, agent, sid);
269
+ const item = data.sessions[agent].find((s) => s.id === sid);
270
+ if (!item)
271
+ data.sessions[agent].push({
272
+ id: sid,
273
+ createdAt: stamp,
274
+ lastUsedAt: stamp,
275
+ });
276
+ else if (timestamp(stamp) > timestamp(item.lastUsedAt))
277
+ item.lastUsedAt = stamp;
278
+ }
279
+ }
280
+ export function hookCommand(store, data, agent) {
281
+ return ([
282
+ "rl",
283
+ "__hook",
284
+ "--repo",
285
+ store.repo,
286
+ "--state-dir",
287
+ store.stateDir,
288
+ "hook",
289
+ data.key,
290
+ agent,
291
+ ]
292
+ .map(shellQuote)
293
+ .join(" ") + " # rl-session-hook");
294
+ }
295
+ function installHooks(store, file, data) {
296
+ const updates = [];
297
+ const commands = {};
298
+ for (const adapter of Object.values(adapters)) {
299
+ const agent = adapter.name, relative = adapter.configPath;
300
+ const target = path.join(data.worktree, relative);
301
+ if (isSymlink(target) || isSymlink(path.dirname(target)))
302
+ throw new Error(`Refusing to install workspace hooks through a symlink: ${target}`);
303
+ const config = fs.existsSync(target) ? readJson(target) : {};
304
+ const hooks = (config.hooks ??= {});
305
+ if (!object(hooks))
306
+ throw new Error(`Invalid hooks object in ${target}`);
307
+ commands[agent] = hookCommand(store, data, agent);
308
+ for (const event of Object.keys({
309
+ ...adapter.events,
310
+ ...(data.extendedHooks ? adapter.extraEvents : {}),
311
+ })) {
312
+ const groups = hooks[event] ?? [];
313
+ if (!Array.isArray(groups))
314
+ throw new Error(`Invalid ${event} hooks in ${target}`);
315
+ const kept = [];
316
+ for (const group of groups) {
317
+ if (!object(group) || !Array.isArray(group.hooks))
318
+ throw new Error(`Invalid hook group in ${target}`);
319
+ const handlers = group.hooks.filter((h) => !(object(h) &&
320
+ typeof h.command === "string" &&
321
+ (h.command === data.hookCommands?.[agent] ||
322
+ h.command.endsWith(" # rl-session-hook"))));
323
+ if (handlers.length)
324
+ kept.push({ ...group, hooks: handlers });
325
+ }
326
+ kept.push({
327
+ hooks: [
328
+ {
329
+ type: "command",
330
+ command: commands[agent],
331
+ timeout: event === "Interrupt" ||
332
+ (agent === "codex" && event === "SessionEnd")
333
+ ? CODEX_END_TIMEOUT_SECONDS
334
+ : HOOK_TIMEOUT_SECONDS,
335
+ },
336
+ ],
337
+ });
338
+ hooks[event] = kept;
339
+ }
340
+ updates.push([target, config]);
341
+ }
342
+ for (const [target, config] of updates)
343
+ if (!fs.existsSync(target) ||
344
+ JSON.stringify(readJson(target)) !== JSON.stringify(config))
345
+ writeJson(target, config);
346
+ const exclude = path.resolve(data.worktree, git(data.worktree, "rev-parse", "--git-path", "info/exclude"));
347
+ const text = fs.existsSync(exclude) ? fs.readFileSync(exclude, "utf8") : "";
348
+ const missing = ["/.codex/hooks.json", "/.claude/settings.local.json"].filter((entry) => !text.split(/\r?\n/).includes(entry));
349
+ if (missing.length)
350
+ atomicWrite(exclude, text +
351
+ (text && !text.endsWith("\n") ? "\n" : "") +
352
+ missing.join("\n") +
353
+ "\n");
354
+ if (JSON.stringify(data.hookCommands) !== JSON.stringify(commands)) {
355
+ data.hookCommands = commands;
356
+ store.save(file, data);
357
+ }
358
+ }
359
+ export function contextText(file, data) {
360
+ const sections = [
361
+ `RL feature workspace: ${data.id}\nBranch: ${data.branch}`,
362
+ "The following RL-owned documents are shared living feature documentation, not just conversation memory. Read them before starting work, and read the full files when excerpts are truncated. At meaningful milestones and before finishing your task, document what changed, what remains, implementation details (file/symbol references, flows, APIs, data structures), decisions and rationale, checks actually run, and blockers. Keep completed and planned work distinct; do not mark unverified work as tested or done. Respect your assigned scope and permissions; read-only agents should return proposed documentation updates to their parent. Use 'rl context append <file>' for durable domain or design notes and 'rl progress append <file>' for milestone/handoff updates ('-' reads stdin). Append is locked so concurrent agents do not overwrite one another. Use 'rl context set <file>' or 'rl progress set <file>' only for a deliberate replacement after reading the latest document and coordinating with other writers. Re-read the documents before planning subsequent work.",
363
+ ];
364
+ sections.push("When the user asks to pause or save this task for later, save a task handoff with rl_task_pause through MCP, or 'rl pause --summary <text> --changes <text> --validation <text> --next <text> [--blockers <text>]'. Include the current session UUID (Codex shell commands can infer it), concrete changes, checks actually run and their results, remaining work, and blockers. Use 'Not run' for unperformed checks. A pause saves task state; it does not terminate the agent. The user can return with 'rl continue'.");
365
+ const automation = automationContext(data.worktree);
366
+ if (automation)
367
+ sections.push(automation);
368
+ const latest = data.task?.handoffs.at(-1);
369
+ if (latest)
370
+ sections.push(`RL task state: ${data.task.state}\n\n${handoffText(latest)}`);
371
+ for (const name of ["context.md", "progress.md"]) {
372
+ const source = path.join(path.dirname(file), name), text = fs.readFileSync(source, "utf8");
373
+ sections.push(`${name} (${source}):\n${text.slice(0, 12000)}`);
374
+ if (text.length > 12000)
375
+ sections.push("[Excerpt truncated; read the file for the full context.]");
376
+ }
377
+ sections.push("Cached GitHub PR state:\n" + JSON.stringify(data.pr));
378
+ return sections.join("\n\n");
379
+ }
@@ -0,0 +1,164 @@
1
+ import { pullRequestBase } from "./base.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { args, arity, atomicWrite, canonical, expandHome, git, gitRaw, isSymlink, now, required, } from "./common.js";
5
+ import { branchStatus, Store } from "./store.js";
6
+ import { NOTE_TEMPLATES } from "./templates.js";
7
+ const longest = (text) => Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
8
+ function fence(text) {
9
+ const delimiter = "`".repeat(Math.max(3, longest(text) + 1));
10
+ return `${delimiter}text\n${text.trimEnd()}\n${delimiter}`;
11
+ }
12
+ function inline(text) {
13
+ text = text.replaceAll("\n", "\\n").replaceAll("\r", "\\r");
14
+ const delimiter = "`".repeat(longest(text) + 1);
15
+ return `${delimiter} ${text} ${delimiter}`;
16
+ }
17
+ function quote(text) {
18
+ return text.trimEnd()
19
+ ? text
20
+ .trimEnd()
21
+ .split(/\r?\n/)
22
+ .map((line) => (line ? "> " + line : ">"))
23
+ .join("\n")
24
+ : "> No notes recorded.";
25
+ }
26
+ function resolveBase(root, requested, explicit) {
27
+ const candidates = [requested];
28
+ if (!requested.startsWith("origin/"))
29
+ candidates.push("origin/" + requested);
30
+ else if (!explicit)
31
+ candidates.push(requested.slice(7));
32
+ for (const candidate of candidates) {
33
+ try {
34
+ return [
35
+ candidate,
36
+ git(root, "rev-parse", "--verify", "--end-of-options", candidate + "^{commit}"),
37
+ ];
38
+ }
39
+ catch {
40
+ /* Try local/remote fallback. */
41
+ }
42
+ }
43
+ if (explicit)
44
+ throw new Error(`Comparison base does not exist: ${requested}`);
45
+ return [null, null];
46
+ }
47
+ function render(store, data, context, progress, defaultBase, requested) {
48
+ const root = data.worktree, status = branchStatus(store.repo, data);
49
+ if (status.availability !== "ok")
50
+ throw new Error(status.error || "Worktree is unavailable");
51
+ if (status.branchChanged)
52
+ throw new Error("The checked-out branch changed while preparing the summary; retry on the instance branch");
53
+ const head = git(root, "rev-parse", "--verify", "HEAD^{commit}");
54
+ const prBase = requested ? null : pullRequestBase(store.repo, data.pr);
55
+ const comparison = requested ||
56
+ prBase?.target ||
57
+ (typeof data.baseBranch === "string" ? data.baseBranch : defaultBase);
58
+ if (prBase && !prBase.resolved)
59
+ throw new Error(`PR target branch is unavailable locally: ${prBase.target}. Fetch its ref or refresh the snapshot with 'rl pr sync'.`);
60
+ const [base, baseOid] = prBase?.resolved
61
+ ? [prBase.resolved.ref, prBase.resolved.oid]
62
+ : resolveBase(root, comparison, !!requested);
63
+ const parts = [
64
+ "# RL worktree summary",
65
+ `Generated: ${now()}`,
66
+ `- Instance: ${inline(data.id)}\n- Repository: ${inline(store.repo)}\n- Worktree: ${inline(root)}\n- Branch: ${inline(data.branch)}\n- HEAD: ${inline(head)}`,
67
+ "This report combines saved documentation with local Git evidence. Recorded progress is authored by you or your agents; commits and file changes do not establish completion or successful validation. No agent was called and no remote state was fetched. Git data is sampled while the command runs.",
68
+ "## Recorded implementation progress",
69
+ quote(progress),
70
+ "## Feature and domain context",
71
+ quote(context),
72
+ "## Committed work",
73
+ ];
74
+ if (baseOid && base) {
75
+ let mergeBase;
76
+ try {
77
+ mergeBase = git(root, "merge-base", baseOid, head);
78
+ }
79
+ catch {
80
+ throw new Error(`No common ancestor between HEAD and ${base}; choose another --base`);
81
+ }
82
+ parts.push(`Comparison reference: ${inline(base)} (${inline(baseOid)}). Changes are measured from its merge-base with HEAD: ${inline(mergeBase)}.`);
83
+ const commits = git(root, "log", "--no-decorate", "--format=%h %s", `${mergeBase}..${head}`, "--");
84
+ parts.push("### Commits", commits ? fence(commits) : "No commits since the comparison merge-base.");
85
+ const changes = git(root, "diff", "--no-ext-diff", "--no-textconv", "--stat", mergeBase, head, "--");
86
+ parts.push("### Committed file changes", changes ? fence(changes) : "No net committed file changes.");
87
+ }
88
+ else
89
+ parts.push(`The configured comparison base ${inline(comparison)} could not be resolved. Committed work was omitted; run \`rl summary --base <branch-or-commit>\` to include it.`);
90
+ parts.push("## Uncommitted work", `Git status entries: ${status.staged} staged, ${status.unstaged} unstaged, ${status.untracked} untracked, ${status.conflicts} conflicts. An untracked directory counts as one status entry.`);
91
+ for (const [title, flags] of [
92
+ ["Staged changes", ["--cached"]],
93
+ ["Unstaged changes", []],
94
+ ]) {
95
+ const changes = git(root, "diff", "--no-ext-diff", "--no-textconv", ...flags, "--stat", "--");
96
+ parts.push("### " + title, changes ? fence(changes) : "None.");
97
+ }
98
+ const files = gitRaw(root, "ls-files", "--others", "--exclude-standard", "-z")
99
+ .split("\0")
100
+ .filter(Boolean);
101
+ parts.push("### Untracked files", files.length
102
+ ? fence(files.map((name) => JSON.stringify(name)).join("\n"))
103
+ : "None.", "## Upstream status");
104
+ parts.push(status.upstream
105
+ ? `Upstream: ${inline(status.upstream)}. ` +
106
+ (status.ahead !== null
107
+ ? `Ahead ${status.ahead}, behind ${status.behind} using locally cached refs.`
108
+ : "Ahead/behind counts are unavailable.")
109
+ : "No upstream is configured for this branch.");
110
+ parts.push("## Pull request (cached)", data.pr
111
+ ? fence(JSON.stringify(data.pr, null, 2))
112
+ : "No PR snapshot saved. Use `rl pr sync` to associate or refresh one.");
113
+ return parts.join("\n\n") + "\n";
114
+ }
115
+ export async function main(argv) {
116
+ const { values, positionals } = args(argv, ["repo", "state-dir", "default-base", "output", "base"], ["stdout", "force"], { output: "o" });
117
+ arity(positionals, 0);
118
+ if (values.force && !values.output)
119
+ throw new Error("--force is only needed with --output");
120
+ if (values.stdout && values.output)
121
+ throw new Error("Choose --stdout or --output");
122
+ const store = new Store(required(values, "repo"), required(values, "state-dir"));
123
+ const { file, data, context, progress } = await store.locked(() => {
124
+ const [file, data] = store.current();
125
+ const note = (name) => {
126
+ const text = fs.readFileSync(path.join(path.dirname(file), name), "utf8");
127
+ return text === NOTE_TEMPLATES[name] ? "" : text;
128
+ };
129
+ return {
130
+ file,
131
+ data,
132
+ context: note("context.md"),
133
+ progress: note("progress.md"),
134
+ };
135
+ });
136
+ const report = render(store, data, context, progress, required(values, "default-base"), typeof values.base === "string" ? values.base : undefined);
137
+ if (values.stdout) {
138
+ process.stdout.write(report);
139
+ return 0;
140
+ }
141
+ const target = values.output
142
+ ? path.resolve(expandHome(String(values.output)))
143
+ : path.join(path.dirname(file), "summary.md");
144
+ if (path.extname(target).toLowerCase() !== ".md")
145
+ throw new Error("Summary output must have a .md extension");
146
+ if (isSymlink(target))
147
+ throw new Error(`Refusing to overwrite a symlink: ${target}`);
148
+ if (["context.md", "progress.md"].some((name) => canonical(target) === canonical(path.join(path.dirname(file), name))))
149
+ throw new Error("Summary output cannot replace the instance's source documentation");
150
+ if (values.output && !values.force) {
151
+ try {
152
+ fs.writeFileSync(target, report, { flag: "wx" });
153
+ }
154
+ catch (error) {
155
+ if (error.code === "EEXIST")
156
+ throw new Error(`Output already exists: ${target}. Use --force to replace it`);
157
+ throw error;
158
+ }
159
+ }
160
+ else
161
+ atomicWrite(target, report);
162
+ console.log(target);
163
+ return 0;
164
+ }
@@ -0,0 +1,65 @@
1
+ import { object, sessionId, timestamp } from "./common.js";
2
+ export const MAX_HANDOFF_TEXT = 16000;
3
+ export const HANDOFF_FIELDS = [
4
+ "summary",
5
+ "changes",
6
+ "validation",
7
+ "next",
8
+ "blockers",
9
+ ];
10
+ export function handoffNotes(value) {
11
+ if (!object(value))
12
+ throw new Error("Handoff must be an object");
13
+ const notes = {};
14
+ for (const field of HANDOFF_FIELDS) {
15
+ const text = value[field] === undefined
16
+ ? field === "blockers"
17
+ ? ""
18
+ : undefined
19
+ : value[field];
20
+ if (typeof text !== "string" ||
21
+ (field !== "blockers" && !text.trim()) ||
22
+ text.includes("\0"))
23
+ throw new Error(`Handoff ${field} must be non-empty text (use 'Not run' for unperformed validation)`);
24
+ notes[field] = text.trim();
25
+ }
26
+ if (Object.values(notes).join("\n").length > MAX_HANDOFF_TEXT)
27
+ throw new Error(`Handoff text must be at most ${MAX_HANDOFF_TEXT} characters`);
28
+ return notes;
29
+ }
30
+ export function validateTask(value) {
31
+ if (!object(value) ||
32
+ !["paused", "active"].includes(String(value.state)) ||
33
+ !Array.isArray(value.handoffs) ||
34
+ !value.handoffs.length)
35
+ throw new Error("Invalid task handoff state");
36
+ const ids = new Set();
37
+ for (const item of value.handoffs) {
38
+ if (!object(item) ||
39
+ !["codex", "claude"].includes(String(item.agent)) ||
40
+ sessionId(item.id) !== item.id ||
41
+ sessionId(item.sessionId) !== item.sessionId ||
42
+ ids.has(item.id) ||
43
+ typeof item.savedAt !== "string")
44
+ throw new Error("Invalid task handoff");
45
+ ids.add(item.id);
46
+ timestamp(item.savedAt);
47
+ handoffNotes(item);
48
+ }
49
+ if (value.continuedAt !== undefined) {
50
+ if (typeof value.continuedAt !== "string")
51
+ throw new Error("Invalid continuation time");
52
+ timestamp(value.continuedAt);
53
+ }
54
+ }
55
+ export function handoffText(handoff) {
56
+ return [
57
+ `Task handoff saved ${handoff.savedAt}`,
58
+ `Session: ${handoff.agent} ${handoff.sessionId}`,
59
+ `Summary:\n${handoff.summary}`,
60
+ `Changes:\n${handoff.changes}`,
61
+ `Reported validation:\n${handoff.validation}`,
62
+ `Next steps:\n${handoff.next}`,
63
+ `Blockers:\n${handoff.blockers || "None reported"}`,
64
+ ].join("\n\n");
65
+ }