rl-workspaces 0.5.0 → 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/README.md +173 -141
- package/dist/agent-presets.js +42 -17
- package/dist/agent-runner.js +20 -0
- package/dist/agent-workflows.js +60 -16
- package/dist/automation-config.js +144 -5
- package/dist/git-workflow.js +8 -102
- package/dist/help.js +32 -32
- package/dist/session-workflows.js +51 -17
- package/dist/workflow-collaboration.js +531 -0
- package/lib/rl.zsh +3 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/rl.zsh
CHANGED
|
@@ -57,7 +57,7 @@ _rl_main() {
|
|
|
57
57
|
return
|
|
58
58
|
fi
|
|
59
59
|
done
|
|
60
|
-
if (( ${#help_args} == 1 )) && [[ "$help_args[1]" == (agent|workflow|policy|repo|git|notes|session|config
|
|
60
|
+
if (( ${#help_args} == 1 )) && [[ "$help_args[1]" == (agent|workflow|policy|repo|git|notes|session|config) ]]; then
|
|
61
61
|
_rl_help "${help_args[@]}"
|
|
62
62
|
return
|
|
63
63
|
fi
|
|
@@ -73,8 +73,7 @@ _rl_main() {
|
|
|
73
73
|
shift
|
|
74
74
|
fi
|
|
75
75
|
case "$1:$2" in
|
|
76
|
-
git:
|
|
77
|
-
git:diff|git:log|git:merge) argv=("$2" "${@:3}") ;;
|
|
76
|
+
git:merge) argv=("$2" "${@:3}") ;;
|
|
78
77
|
agent:sessions) argv=(session list "${@:3}") ;;
|
|
79
78
|
agent:save) argv=(session save "${@:3}") ;;
|
|
80
79
|
agent:handoff) argv=(handoff "${@:3}") ;;
|
|
@@ -227,7 +226,7 @@ _rl_main() {
|
|
|
227
226
|
--repo "$repo_root" --state-dir "$state_dir" --base-dir "$base_dir/$repo_name" \
|
|
228
227
|
--default-base "$default_base" "$@"
|
|
229
228
|
return ;;
|
|
230
|
-
|
|
229
|
+
merge)
|
|
231
230
|
command node "${${functions_source[_rl_main]}:A:h:h}/dist/cli.js" git \
|
|
232
231
|
--repo "$repo_root" --state-dir "$state_dir" --base-dir "$base_dir/$repo_name" \
|
|
233
232
|
--default-base "$default_base" "$@"
|