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.
- package/README.md +234 -193
- package/bin/rl +2 -54
- package/dist/agent-launch.js +1 -1
- package/dist/agent-presets.js +42 -17
- package/dist/agent-resume.js +2 -2
- package/dist/agent-runner.js +20 -0
- package/dist/agent-workflows.js +60 -16
- package/dist/automation-config.js +144 -5
- package/dist/bootstrap.js +74 -0
- package/dist/cli.js +2 -0
- package/dist/command-context.js +1 -1
- package/dist/common.js +3 -2
- package/dist/dependencies.js +197 -0
- package/dist/doctor.js +1 -1
- package/dist/git-workflow.js +8 -102
- package/dist/help.js +113 -116
- package/dist/hooks.js +1 -1
- package/dist/install.js +143 -0
- package/dist/mcp-setup.js +171 -0
- package/dist/mcp.js +13 -16
- package/dist/picker.js +24 -7
- package/dist/pr-workflow.js +7 -28
- package/dist/repos.js +5 -1
- package/dist/session-workflows.js +122 -18
- package/dist/session.js +1 -1
- package/dist/state.js +4 -14
- package/dist/storage.js +2 -2
- package/dist/store.js +2 -2
- package/dist/summary.js +1 -1
- package/dist/workflow-collaboration.js +531 -0
- package/dist/workflow.js +24 -38
- package/lib/entry.zsh +54 -0
- package/lib/rl.zsh +37 -9
- package/node_modules/proper-lockfile/CHANGELOG.md +108 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +293 -0
- package/node_modules/smol-toml/dist/index.cjs +771 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +6 -3
- 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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|