rl-workspaces 0.5.0 → 0.7.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 (55) hide show
  1. package/README.md +234 -193
  2. package/bin/rl +2 -54
  3. package/dist/agent-launch.js +1 -1
  4. package/dist/agent-presets.js +42 -17
  5. package/dist/agent-resume.js +2 -2
  6. package/dist/agent-runner.js +20 -0
  7. package/dist/agent-workflows.js +60 -16
  8. package/dist/automation-config.js +144 -5
  9. package/dist/bootstrap.js +74 -0
  10. package/dist/cli.js +2 -0
  11. package/dist/command-context.js +1 -1
  12. package/dist/common.js +3 -2
  13. package/dist/dependencies.js +197 -0
  14. package/dist/doctor.js +1 -1
  15. package/dist/git-workflow.js +8 -102
  16. package/dist/help.js +113 -116
  17. package/dist/hooks.js +1 -1
  18. package/dist/install.js +143 -0
  19. package/dist/mcp-setup.js +171 -0
  20. package/dist/mcp.js +13 -16
  21. package/dist/picker.js +24 -7
  22. package/dist/pr-workflow.js +7 -28
  23. package/dist/repos.js +5 -1
  24. package/dist/session-workflows.js +122 -18
  25. package/dist/session.js +1 -1
  26. package/dist/state.js +4 -14
  27. package/dist/storage.js +2 -2
  28. package/dist/store.js +2 -2
  29. package/dist/summary.js +1 -1
  30. package/dist/workflow-collaboration.js +531 -0
  31. package/dist/workflow.js +24 -38
  32. package/lib/entry.zsh +54 -0
  33. package/lib/rl.zsh +37 -9
  34. package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
  35. package/node_modules/smol-toml/LICENSE +24 -0
  36. package/node_modules/smol-toml/README.md +605 -0
  37. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  38. package/node_modules/smol-toml/dist/date.js +156 -0
  39. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  40. package/node_modules/smol-toml/dist/error.js +67 -0
  41. package/node_modules/smol-toml/dist/extract.js +293 -0
  42. package/node_modules/smol-toml/dist/index.cjs +771 -0
  43. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  44. package/node_modules/smol-toml/dist/index.js +34 -0
  45. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  46. package/node_modules/smol-toml/dist/parse.js +164 -0
  47. package/node_modules/smol-toml/dist/primitive.js +151 -0
  48. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  49. package/node_modules/smol-toml/dist/stringify.js +219 -0
  50. package/node_modules/smol-toml/dist/struct.js +156 -0
  51. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  52. package/node_modules/smol-toml/dist/util.js +57 -0
  53. package/node_modules/smol-toml/package.json +68 -0
  54. package/package.json +6 -3
  55. package/shell/rl.zsh +1 -1
package/dist/state.js CHANGED
@@ -145,7 +145,7 @@ export async function main(argv) {
145
145
  arity(rest, 0, rest[0] === "open" ? 1 : 0);
146
146
  return openPr(store, !!values.json);
147
147
  }
148
- if (command === "status" || command === "instances")
148
+ if (command === "status")
149
149
  arity(rest, 0);
150
150
  if (command === "session" && rest[0] === "list") {
151
151
  arity(rest, 1, 2);
@@ -154,10 +154,8 @@ export async function main(argv) {
154
154
  }
155
155
  if (command === "status" && values.fetch)
156
156
  git(store.repo, "fetch", "--all", "--prune");
157
- if (command === "status" ||
158
- command === "instances" ||
159
- (command === "session" && rest[0] === "list"))
160
- await syncCodexSessions(store, command === "instances" ? undefined : process.cwd());
157
+ if (command === "status" || (command === "session" && rest[0] === "list"))
158
+ await syncCodexSessions(store, process.cwd());
161
159
  let replacement = "";
162
160
  if (["context", "progress"].includes(command) &&
163
161
  ["set", "append"].includes(rest[0])) {
@@ -165,14 +163,6 @@ export async function main(argv) {
165
163
  replacement = fs.readFileSync(rest[1] === "-" ? 0 : rest[1], "utf8");
166
164
  }
167
165
  return store.locked(() => {
168
- if (command === "instances") {
169
- arity(rest, 0);
170
- json(store.records().map(([, data]) => ({
171
- ...data,
172
- gitStatus: branchStatus(store.repo, data),
173
- })));
174
- return 0;
175
- }
176
166
  if (command === "archive") {
177
167
  arity(rest, 0);
178
168
  const root = canonical(required(values, "worktree"));
@@ -219,7 +209,7 @@ export async function main(argv) {
219
209
  json(rest[1] ? data.sessions[agent(rest[1])] : data.sessions);
220
210
  }
221
211
  else
222
- throw new Error("Usage: rl session list [agent] | save [agent] [UUID] | add <agent> <UUID>");
212
+ throw new Error("Usage: rl agent sessions [agent] | save [agent] [UUID]");
223
213
  }
224
214
  else if (command === "context" || command === "progress") {
225
215
  const target = path.join(path.dirname(file), command + ".md"), action = rest[0];
package/dist/storage.js CHANGED
@@ -239,7 +239,7 @@ function remove(workspace, targets, recheck) {
239
239
  function suggestions(data, usage, estimatedBytes, days) {
240
240
  const result = [];
241
241
  if (estimatedBytes)
242
- result.push(`Run rl clean --dry-run to preview ${size(estimatedBytes)} of configured generated files.`);
242
+ result.push(`Run rl storage clean --dry-run to preview ${size(estimatedBytes)} of configured generated files.`);
243
243
  else if (usage.dependenciesBytes || usage.buildsBytes)
244
244
  result.push("Add generated directory paths to the clean list in this workspace's .rl/workflows.json.");
245
245
  const accessed = lastAccessed(data);
@@ -256,7 +256,7 @@ export async function main(argv) {
256
256
  if (!["storage", "clean"].includes(command) ||
257
257
  (command === "storage" && values["dry-run"]) ||
258
258
  (command === "clean" && values["older-than"] !== undefined))
259
- throw new Error("Usage: rl storage [--json] [--older-than 14d] | rl clean [--dry-run] [--json] (current workspace only)");
259
+ throw new Error("Usage: rl storage [--json] [--older-than 14d] | rl storage clean [--dry-run] [--json] (current workspace only)");
260
260
  const context = commandContext(values);
261
261
  // Resolve from the caller's cwd before any scan, lock creation, or mutation.
262
262
  const [file, data] = context.store.current();
package/dist/store.js CHANGED
@@ -183,7 +183,7 @@ export class Store {
183
183
  throw new Error(`Instance owns branch ${data.branch}; current branch is ${branch}`);
184
184
  return [file, data];
185
185
  }
186
- throw new Error("No RL instance for this worktree. Run 'rl adopt' first");
186
+ throw new Error("No RL instance for this worktree. Run 'rl setup adopt' first");
187
187
  }
188
188
  save(file, data) {
189
189
  data.updatedAt = now();
@@ -359,7 +359,7 @@ function installHooks(store, file, data) {
359
359
  export function contextText(file, data) {
360
360
  const sections = [
361
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.",
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 notes append context <file>' for durable domain or design notes and 'rl notes append progress <file>' for milestone/handoff updates ('-' reads stdin). Append is locked so concurrent agents do not overwrite one another. Use 'rl notes set context <file>' or 'rl notes set progress <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
363
  ];
364
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
365
  const automation = automationContext(data.worktree);
package/dist/summary.js CHANGED
@@ -86,7 +86,7 @@ function render(store, data, context, progress, defaultBase, requested) {
86
86
  parts.push("### Committed file changes", changes ? fence(changes) : "No net committed file changes.");
87
87
  }
88
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.`);
89
+ parts.push(`The configured comparison base ${inline(comparison)} could not be resolved. Committed work was omitted; run \`rl notes summary --base <branch-or-commit>\` to include it.`);
90
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
91
  for (const [title, flags] of [
92
92
  ["Staged changes", ["--cached"]],
@@ -0,0 +1,531 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { canonical, git, inside, now, object, sessionId } from "./common.js";
5
+ import { evaluate, RESULT_SCHEMA, validateResult, } from "./review-policy.js";
6
+ const participant = {
7
+ run: { type: "string" },
8
+ step: { type: "string" },
9
+ claim: { type: "string" },
10
+ };
11
+ const strings = { type: "array", items: { type: "string" }, maxItems: 256 };
12
+ export const COLLABORATION_TOOLS = [
13
+ {
14
+ name: "rl_workflow_bind",
15
+ description: "Bind a claimed role to the native child your current session spawned. Spawn and bind all three peers before implementation; the peers are reused for the whole task.",
16
+ properties: { ...participant, childId: { type: "string" } },
17
+ required: ["run", "step", "claim", "childId"],
18
+ readOnly: false,
19
+ },
20
+ {
21
+ name: "rl_workflow_inbox",
22
+ description: "Read this native peer's consultations, writer submissions, reviewer ratings and peer handles. Wait using native messaging/wait tools or the parent relay; avoid busy polling.",
23
+ properties: participant,
24
+ required: ["run", "step", "claim"],
25
+ readOnly: true,
26
+ },
27
+ {
28
+ name: "rl_workflow_consult",
29
+ description: "Ask an existing peer for evidence through a configured route. Writer must consult DRY before a new function; reviewer must consult DRY on a suspected duplicate. Wait for the response.",
30
+ properties: {
31
+ ...participant,
32
+ to: { type: "string" },
33
+ event: { type: "string" },
34
+ subject: { type: "string" },
35
+ question: { type: "string" },
36
+ },
37
+ required: ["run", "step", "claim", "to", "event", "subject", "question"],
38
+ readOnly: false,
39
+ },
40
+ {
41
+ name: "rl_workflow_answer",
42
+ description: "Answer a consultation addressed to this existing peer. Include concrete paths/symbols, reuse evidence and limitations; the requester receives the response in its inbox.",
43
+ properties: {
44
+ ...participant,
45
+ requestId: { type: "string" },
46
+ text: { type: "string" },
47
+ },
48
+ required: ["run", "step", "claim", "requestId", "text"],
49
+ readOnly: false,
50
+ },
51
+ {
52
+ name: "rl_workflow_submit",
53
+ description: "Writer submits a stable file group to its existing reviewer. Declare all new functions as path#function subjects; required DRY answers are checked. Freeze these files until reviewed, while continuing on unrelated files.",
54
+ properties: {
55
+ ...participant,
56
+ files: strings,
57
+ newFunctions: strings,
58
+ summary: { type: "string" },
59
+ supersedes: strings,
60
+ },
61
+ required: ["run", "step", "claim", "files", "newFunctions", "summary"],
62
+ readOnly: false,
63
+ },
64
+ {
65
+ name: "rl_workflow_review",
66
+ description: "Reviewer returns a rating, rationale and evidence for a writer checkpoint. Declare suspected duplicates; required DRY answers are checked before accepting the rating. Changed or superseded checkpoints are rejected.",
67
+ properties: {
68
+ ...participant,
69
+ submissionId: { type: "string" },
70
+ rating: { type: "number" },
71
+ rationale: { type: "string" },
72
+ result: RESULT_SCHEMA,
73
+ duplicateSuspicions: strings,
74
+ },
75
+ required: [
76
+ "run",
77
+ "step",
78
+ "claim",
79
+ "submissionId",
80
+ "rating",
81
+ "rationale",
82
+ "result",
83
+ "duplicateSuspicions",
84
+ ],
85
+ readOnly: false,
86
+ },
87
+ ];
88
+ function prose(value, label, max = 16000) {
89
+ if (typeof value !== "string" ||
90
+ !value.trim() ||
91
+ value.length > max ||
92
+ /[\x00-\x08\x0b-\x1f\x7f]/.test(value))
93
+ throw new Error(`Expected ${label}`);
94
+ return value;
95
+ }
96
+ function list(value, label) {
97
+ if (!Array.isArray(value) ||
98
+ value.length > 256 ||
99
+ value.some((item) => typeof item !== "string" ||
100
+ !item ||
101
+ item.length > 512 ||
102
+ /[\x00-\x1f]/.test(item)) ||
103
+ new Set(value).size !== value.length)
104
+ throw new Error(`Expected unique ${label}`);
105
+ return value;
106
+ }
107
+ function sourcePath(value) {
108
+ if (path.isAbsolute(value) ||
109
+ value.includes("\\") ||
110
+ value
111
+ .split("/")
112
+ .some((part) => !part || part === "." || part === ".." || part === ".git"))
113
+ throw new Error("Checkpoint paths must be relative files inside this workspace");
114
+ return value;
115
+ }
116
+ function blob(workspace, ref) {
117
+ try {
118
+ return git(workspace, "rev-parse", "--verify", "--end-of-options", ref);
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function checkpoint(run, files) {
125
+ let bytes = 0;
126
+ const versions = files.map((file) => {
127
+ sourcePath(file);
128
+ const absolute = path.join(run.workspace, file);
129
+ if (!inside(canonical(absolute), run.workspace))
130
+ throw new Error("Checkpoint path escapes the workspace");
131
+ const stat = fs.lstatSync(absolute, { throwIfNoEntry: false });
132
+ if (stat && !stat.isFile() && !stat.isSymbolicLink())
133
+ throw new Error("Checkpoints contain files, not directories");
134
+ if (stat && (bytes += stat.size) > 4 * 1024 * 1024)
135
+ throw new Error("Checkpoint exceeds 4 MiB");
136
+ const content = stat
137
+ ? stat.isSymbolicLink()
138
+ ? fs.readlinkSync(absolute)
139
+ : fs.readFileSync(absolute).toString("base64")
140
+ : null;
141
+ const baseOid = blob(run.workspace, `${run.scope.baseCommit}:${file}`), indexOid = blob(run.workspace, `:${file}`);
142
+ if (!stat &&
143
+ !baseOid &&
144
+ !indexOid &&
145
+ !Object.hasOwn(run.collaboration?.baseline ?? {}, file))
146
+ throw new Error(`Checkpoint file does not exist: ${file}`);
147
+ return {
148
+ file,
149
+ kind: !stat ? "deleted" : stat.isSymbolicLink() ? "symlink" : "file",
150
+ mode: stat ? stat.mode & 0o777 : null,
151
+ content,
152
+ baseOid,
153
+ indexOid,
154
+ };
155
+ });
156
+ const fingerprint = createHash("sha256")
157
+ .update(JSON.stringify(versions))
158
+ .digest("hex");
159
+ return { workspace: run.workspace, files, versions, fingerprint };
160
+ }
161
+ function state(run) {
162
+ if (!run.config.workflows[run.workflow].collaboration)
163
+ throw new Error("This workflow does not configure collaborating peers");
164
+ if (!run.collaboration)
165
+ throw new Error("Collaborative workflow state is missing");
166
+ return run.collaboration;
167
+ }
168
+ export function initializeCollaboration(run) {
169
+ if (!run.config.workflows[run.workflow].collaboration)
170
+ return;
171
+ run.collaboration = {
172
+ consultations: [],
173
+ submissions: [],
174
+ baseline: Object.fromEntries(run.scope.files.map((file) => [
175
+ file,
176
+ checkpoint(run, [file]).fingerprint,
177
+ ])),
178
+ };
179
+ }
180
+ function peersReady(run) {
181
+ const roles = run.config.workflows[run.workflow].collaboration;
182
+ for (const id of [roles.writer, roles.reviewer, roles.dry])
183
+ if (!run.stages.some((stage) => stage.id === id &&
184
+ stage.childId &&
185
+ ["running", "completed"].includes(stage.status)))
186
+ throw new Error("Spawn and bind all writer, reviewer and DRY peers before starting work");
187
+ }
188
+ function notify(run, to, text) {
189
+ return {
190
+ step: to,
191
+ childId: run.stages.find((stage) => stage.id === to)?.childId ?? null,
192
+ message: text,
193
+ delivery: "Send with your native peer messaging tool; if unavailable, ask the parent to relay it. Queuing in RL alone does not wake a native agent.",
194
+ };
195
+ }
196
+ function requirements(run, from, event, subjects) {
197
+ const config = run.config.workflows[run.workflow].collaboration;
198
+ for (const rule of config.rules.filter((rule) => rule.from === from && rule.event === event && rule.required))
199
+ for (const subject of subjects) {
200
+ const request = [...state(run).consultations]
201
+ .reverse()
202
+ .find((item) => item.from === from &&
203
+ item.to === rule.to &&
204
+ item.event === event &&
205
+ item.subject === subject);
206
+ if (!request?.answer)
207
+ throw new Error(`${from} must consult ${rule.to} and receive an answer for ${event}: ${subject}`);
208
+ }
209
+ }
210
+ function latestSubmissions(run) {
211
+ return state(run).submissions.filter((item) => !item.supersededBy);
212
+ }
213
+ function current(run, submission) {
214
+ return (checkpoint(run, submission.files).fingerprint === submission.fingerprint);
215
+ }
216
+ function allReviewed(run) {
217
+ const submissions = latestSubmissions(run);
218
+ if (!submissions.length)
219
+ throw new Error("Submit implementation work to the reviewer before completing the workflow");
220
+ for (const submission of submissions) {
221
+ if (!submission.review?.accepted)
222
+ throw new Error(`Submission ${submission.id} needs an accepted reviewer rating before completion`);
223
+ if (!current(run, submission))
224
+ throw new Error(`Submission ${submission.id} changed after review; submit the current work again`);
225
+ }
226
+ }
227
+ function snapshotPath(directory, id) {
228
+ sessionId(id);
229
+ const parent = path.join(directory, "checkpoints"), file = path.join(parent, `${id}.json`);
230
+ if (fs.lstatSync(parent, { throwIfNoEntry: false })?.isSymbolicLink() ||
231
+ fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink())
232
+ throw new Error("Checkpoint state must not be a symlink");
233
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
234
+ return file;
235
+ }
236
+ export function collaborationTool(run, directory, name, input) {
237
+ const data = state(run), config = run.config.workflows[run.workflow].collaboration;
238
+ const stage = run.stages.find((stage) => stage.id === input.step);
239
+ if (!stage || stage.status !== "running" || stage.claim !== input.claim)
240
+ throw new Error("A current native peer claim is required");
241
+ const roster = () => run.stages
242
+ .filter((stage) => [config.writer, config.reviewer, config.dry].includes(stage.id))
243
+ .map(({ id, childId, status }) => ({
244
+ step: id,
245
+ childId: childId ?? null,
246
+ status,
247
+ }));
248
+ if (name === "rl_workflow_inbox")
249
+ return {
250
+ status: run.status,
251
+ ready: roster().every((peer) => peer.childId && ["running", "completed"].includes(peer.status)),
252
+ peers: roster(),
253
+ consultations: data.consultations.filter((item) => item.from === stage.id || item.to === stage.id),
254
+ submissions: stage.id === config.writer || stage.id === config.reviewer
255
+ ? data.submissions
256
+ : [],
257
+ };
258
+ if (run.status !== "running" &&
259
+ !(run.status === "paused" &&
260
+ ["rl_workflow_answer", "rl_workflow_review"].includes(name)))
261
+ throw new Error(`Workflow is ${run.status}`);
262
+ if (name === "rl_workflow_bind") {
263
+ const childId = prose(input.childId, "native child identifier", 200);
264
+ if ((stage.childId && stage.childId !== childId) ||
265
+ run.stages.some((other) => other.id !== stage.id && other.childId === childId))
266
+ throw new Error("Each role must use its own native child; binding cannot be replaced during a claim");
267
+ stage.childId = childId;
268
+ return {
269
+ peers: roster(),
270
+ ready: roster().every((peer) => peer.childId),
271
+ instruction: "After all peers are bound, notify all three using native messaging so they can begin; relay their notifications when direct peer messaging is unavailable.",
272
+ };
273
+ }
274
+ peersReady(run);
275
+ if (name === "rl_workflow_consult") {
276
+ const event = prose(input.event, "consultation event", 64), subject = prose(input.subject, "consultation subject", 512), question = prose(input.question, "consultation question");
277
+ const rule = config.rules.find((rule) => rule.from === stage.id && rule.to === input.to && rule.event === event);
278
+ if (!rule)
279
+ throw new Error("This peer-to-peer route is not configured in the workflow");
280
+ if (!run.stages.some((stage) => stage.id === rule.to && stage.status === "running"))
281
+ throw new Error("The requested peer is no longer running");
282
+ if (data.consultations.length >= 256)
283
+ throw new Error("Consultation limit reached; start a new workflow run");
284
+ const request = {
285
+ id: randomUUID(),
286
+ from: stage.id,
287
+ to: rule.to,
288
+ event,
289
+ subject,
290
+ question,
291
+ createdAt: now(),
292
+ };
293
+ data.consultations.push(request);
294
+ return {
295
+ ...request,
296
+ notify: notify(run, request.to, `Consultation ${request.id} from ${stage.id}: ${subject}. Read your RL inbox and answer.`),
297
+ };
298
+ }
299
+ if (name === "rl_workflow_answer") {
300
+ const request = data.consultations.find((item) => item.id === input.requestId && item.to === stage.id);
301
+ if (!request)
302
+ throw new Error("Consultation is not addressed to this peer");
303
+ const text = prose(input.text, "consultation answer");
304
+ if (request.answer && request.answer.text !== text)
305
+ throw new Error("Consultation has already been answered");
306
+ request.answer ??= { text, at: now() };
307
+ return {
308
+ ...request,
309
+ notify: notify(run, request.from, `Your consultation ${request.id} has an answer. Read your RL inbox.`),
310
+ };
311
+ }
312
+ if (name === "rl_workflow_submit") {
313
+ if (stage.id !== config.writer)
314
+ throw new Error("Only the configured writer may submit implementation work");
315
+ const files = list(input.files, "checkpoint files").map(sourcePath), newFunctions = list(input.newFunctions, "new function subjects"), summary = prose(input.summary, "submission summary");
316
+ if (!files.length)
317
+ throw new Error("A submission requires files to review");
318
+ for (const subject of newFunctions)
319
+ if (!subject.includes("#") ||
320
+ !files.includes(subject.slice(0, subject.indexOf("#"))))
321
+ throw new Error("New functions must use submitted path#function subjects");
322
+ requirements(run, stage.id, "new_function", newFunctions);
323
+ const supersedes = input.supersedes === undefined
324
+ ? []
325
+ : list(input.supersedes, "superseded submissions");
326
+ for (const id of supersedes)
327
+ if (!data.submissions.some((item) => item.id === id && !item.supersededBy))
328
+ throw new Error("Unknown active submission to supersede");
329
+ const overlapping = latestSubmissions(run).filter((item) => item.files.some((file) => files.includes(file)) ||
330
+ supersedes.includes(item.id));
331
+ for (const item of overlapping) {
332
+ if (!item.review && !supersedes.includes(item.id))
333
+ throw new Error("Submitted files are frozen until reviewed; explicitly supersede a checkpoint to replace it");
334
+ if (item.files.some((file) => !files.includes(file)))
335
+ throw new Error("A resubmission must include the complete previous file group");
336
+ }
337
+ const round = Math.max(0, ...overlapping.map((item) => item.round)) + 1;
338
+ if (round > config.maxRounds)
339
+ throw new Error("Maximum review rounds reached; report the blocker to the parent");
340
+ if (data.submissions.length >= 128)
341
+ throw new Error("Submission limit reached; start a new workflow run");
342
+ const id = randomUUID(), snapshot = checkpoint(run, files), file = snapshotPath(directory, id);
343
+ fs.writeFileSync(file, JSON.stringify(snapshot), {
344
+ flag: "wx",
345
+ mode: 0o600,
346
+ });
347
+ const submission = {
348
+ id,
349
+ writer: stage.id,
350
+ reviewer: config.reviewer,
351
+ files,
352
+ newFunctions,
353
+ summary,
354
+ fingerprint: snapshot.fingerprint,
355
+ snapshot: file,
356
+ createdAt: now(),
357
+ round,
358
+ };
359
+ overlapping.forEach((item) => {
360
+ item.supersededBy = id;
361
+ });
362
+ data.submissions.push(submission);
363
+ return {
364
+ ...submission,
365
+ notify: notify(run, config.reviewer, `Writer submission ${id} is ready for review. Read the saved checkpoint from your RL inbox and return a rating.`),
366
+ };
367
+ }
368
+ if (name === "rl_workflow_review") {
369
+ if (stage.id !== config.reviewer)
370
+ throw new Error("Only the configured reviewer may rate submissions");
371
+ const submission = data.submissions.find((item) => item.id === input.submissionId && !item.supersededBy);
372
+ if (!submission || submission.review)
373
+ throw new Error("A current unreviewed submission is required");
374
+ if (!current(run, submission))
375
+ throw new Error("Submitted files or staged versions changed; the writer must resubmit this checkpoint");
376
+ const policy = run.config.policies[run.config.workflows[run.workflow].policy], rating = policy.rating;
377
+ if (typeof input.rating !== "number" ||
378
+ !Number.isFinite(input.rating) ||
379
+ input.rating < rating.min ||
380
+ input.rating > rating.max)
381
+ throw new Error(`Reviewer rating must be within ${rating.min}–${rating.max}`);
382
+ const duplicateSuspicions = list(input.duplicateSuspicions, "suspected duplicate subjects");
383
+ requirements(run, stage.id, "suspected_duplicate", duplicateSuspicions);
384
+ validateResult(input.result);
385
+ for (const finding of input.result.findings)
386
+ if (!inside(canonical(path.join(run.workspace, finding.file)), run.workspace) ||
387
+ (!fs.existsSync(path.join(run.workspace, finding.file)) &&
388
+ !submission.files.includes(finding.file)))
389
+ throw new Error("Review evidence must reference a workspace file or a submitted deletion");
390
+ const assessment = evaluate(policy, [
391
+ { owner: stage.id, result: input.result },
392
+ ]);
393
+ submission.review = {
394
+ rating: input.rating,
395
+ rationale: prose(input.rationale, "rating rationale"),
396
+ result: input.result,
397
+ duplicateSuspicions,
398
+ accepted: input.rating >= rating.minimum &&
399
+ assessment.verdict === "ready" &&
400
+ !input.result.checkRequests.length &&
401
+ !input.result.routingLeads.length,
402
+ at: now(),
403
+ };
404
+ return {
405
+ ...submission,
406
+ notify: notify(run, config.writer, `Submission ${submission.id} was rated ${input.rating}/${rating.max}: ${submission.review.accepted ? "accepted" : "changes or validation required"}. Read the review evidence in your RL inbox.`),
407
+ };
408
+ }
409
+ throw new Error("Unknown collaboration operation");
410
+ }
411
+ export function collaborationPrompt(run, stage) {
412
+ const config = run.config.workflows[run.workflow].collaboration;
413
+ if (!config)
414
+ return "";
415
+ return [
416
+ "This is a persistent collaborative team. The parent must claim, spawn and bind all three native peers in parallel before work begins. Keep your role alive for messages; use rl_workflow_inbox and native messaging/wait tools, or ask the parent to relay messages. Never spawn replacement peers or busy-poll.",
417
+ "Every handoff tool returns a notify destination and native child handle. Send that notification with your host's native messaging tool or ask the parent to relay it. RL persists messages; it cannot wake native agents by itself. The parent announces team readiness after binding all three peers.",
418
+ `You are step ${stage.id}. Use the parent agent=${run.owner.agent}, sessionId=${run.owner.sessionId}, run=${run.id}, step=${stage.id}, claim=${stage.claim} for your own workflow tools. The parent supplies your native child ID to rl_workflow_bind. Peer handles are returned by the inbox.`,
419
+ `Writer=${config.writer}; reviewer=${config.reviewer}; DRY=${config.dry}. Rules: ${JSON.stringify(config.rules)}. Maximum review rounds: ${config.maxRounds}. Rating policy: ${JSON.stringify(run.config.policies[run.config.workflows[run.workflow].policy].rating)}.`,
420
+ "Writer: before each new function, perform any required new_function consultation with rl_workflow_consult and wait for rl_workflow_answer, then implement. Configured optional routes may also be used. Use rl_workflow_submit with a stable file group and all newFunctions as path#function subjects. Freeze submitted files until a rating arrives, work on unrelated files meanwhile, and fix/resubmit rejected checkpoints. Reviewer: read submitted snapshot versions and current surrounding source; perform any required suspected_duplicate consultation for every suspected duplicate and wait for its answer, then use rl_workflow_review with rating, rationale, result and all duplicateSuspicions. DRY: answer both peers through rl_workflow_answer with evidence and limitations.",
421
+ "The reviewer returns the configured human-readable rating; RL independently computes the policy score. Shared submissions and consultation answers are explicit handoffs you may read. Ratings apply only to the saved files and staged versions; never rate a moving or superseded checkpoint. Only the writer may edit. Close roles in order: writer after accepted current reviews, reviewer after writer, DRY after both peers and all consultations. Final closure uses rl_workflow_report with evidence in resultSchema.",
422
+ ].join("\n\n");
423
+ }
424
+ export function assertCollaborationCompletion(run, stage, changedFiles) {
425
+ const config = run.config.workflows[run.workflow].collaboration;
426
+ if (!config)
427
+ return;
428
+ peersReady(run);
429
+ const data = state(run);
430
+ if (data.consultations.some((item) => item.from === stage.id && !item.answer))
431
+ throw new Error("Resolve this role's consultations before completing it");
432
+ if (stage.id === config.writer) {
433
+ allReviewed(run);
434
+ const covered = new Set(latestSubmissions(run).flatMap((item) => item.files));
435
+ for (const file of new Set([
436
+ ...(changedFiles ?? []),
437
+ ...Object.keys(data.baseline),
438
+ ]))
439
+ if ((!Object.hasOwn(data.baseline, file) ||
440
+ checkpoint(run, [file]).fingerprint !== data.baseline[file]) &&
441
+ !covered.has(file))
442
+ throw new Error(`Changed file has not been submitted for review: ${file}`);
443
+ }
444
+ if (stage.id === config.reviewer) {
445
+ if (run.stages.find((stage) => stage.id === config.writer)?.status !==
446
+ "completed")
447
+ throw new Error("The reviewer must remain available until the writer finishes");
448
+ allReviewed(run);
449
+ }
450
+ if (stage.id === config.dry &&
451
+ (data.consultations.some((item) => !item.answer) ||
452
+ [config.writer, config.reviewer].some((id) => run.stages.find((stage) => stage.id === id)?.status !== "completed")))
453
+ throw new Error("DRY must remain available until both peers finish and all requests are answered");
454
+ }
455
+ export function collaborationAssessment(run) {
456
+ if (!run.config.workflows[run.workflow].collaboration)
457
+ return { reports: [], limitations: [] };
458
+ const submissions = latestSubmissions(run), limitations = [];
459
+ if (!submissions.length)
460
+ limitations.push("No implementation submissions were reviewed");
461
+ const reports = submissions.flatMap((item) => item.review ? [{ owner: item.reviewer, result: item.review.result }] : []);
462
+ for (const item of submissions) {
463
+ if (!item.review?.accepted)
464
+ limitations.push(`${item.id}: reviewer acceptance is missing`);
465
+ try {
466
+ if (!current(run, item))
467
+ limitations.push(`${item.id}: checkpoint changed after submission`);
468
+ }
469
+ catch {
470
+ limitations.push(`${item.id}: checkpoint cannot be verified`);
471
+ }
472
+ }
473
+ for (const request of state(run).consultations)
474
+ if (!request.answer)
475
+ limitations.push(`${request.from}: awaiting ${request.to} for ${request.subject}`);
476
+ return { reports, limitations };
477
+ }
478
+ export function validateCollaborationState(value) {
479
+ if (!object(value) ||
480
+ !Array.isArray(value.consultations) ||
481
+ !Array.isArray(value.submissions) ||
482
+ !object(value.baseline) ||
483
+ Object.values(value.baseline).some((value) => typeof value !== "string") ||
484
+ value.consultations.length > 256 ||
485
+ value.submissions.length > 128)
486
+ throw new Error("Invalid collaborative workflow state");
487
+ const ids = new Set();
488
+ for (const item of [...value.consultations, ...value.submissions]) {
489
+ if (!object(item) || typeof item.id !== "string" || ids.has(item.id))
490
+ throw new Error("Invalid or duplicate collaboration record");
491
+ sessionId(item.id);
492
+ ids.add(item.id);
493
+ prose(item.createdAt, "record timestamp", 100);
494
+ if ("question" in item) {
495
+ for (const key of ["from", "to", "event", "subject", "question"])
496
+ prose(item[key], key);
497
+ if (item.answer !== undefined) {
498
+ if (!object(item.answer))
499
+ throw new Error("Invalid peer answer");
500
+ prose(item.answer.text, "answer");
501
+ prose(item.answer.at, "answer timestamp", 100);
502
+ }
503
+ }
504
+ else {
505
+ list(item.files, "files").forEach(sourcePath);
506
+ list(item.newFunctions, "new functions");
507
+ for (const key of [
508
+ "writer",
509
+ "reviewer",
510
+ "summary",
511
+ "fingerprint",
512
+ "snapshot",
513
+ ])
514
+ prose(item[key], key);
515
+ if (!Number.isSafeInteger(item.round) || Number(item.round) < 1)
516
+ throw new Error("Invalid review round");
517
+ if (item.supersededBy !== undefined)
518
+ sessionId(item.supersededBy);
519
+ if (item.review !== undefined) {
520
+ if (!object(item.review) ||
521
+ typeof item.review.rating !== "number" ||
522
+ !Number.isFinite(item.review.rating) ||
523
+ typeof item.review.accepted !== "boolean")
524
+ throw new Error("Invalid reviewer rating");
525
+ prose(item.review.rationale, "rating rationale");
526
+ validateResult(item.review.result);
527
+ list(item.review.duplicateSuspicions, "duplicates");
528
+ }
529
+ }
530
+ }
531
+ }