parallel-codex-tui 0.1.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/.parallel-codex/config.example.toml +62 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/bootstrap.js +25 -0
- package/dist/cli-args.js +26 -0
- package/dist/cli.js +48 -0
- package/dist/core/config.js +304 -0
- package/dist/core/file-store.js +56 -0
- package/dist/core/paths.js +17 -0
- package/dist/core/router.js +151 -0
- package/dist/core/session-index.js +180 -0
- package/dist/core/session-manager.js +264 -0
- package/dist/doctor.js +121 -0
- package/dist/domain/schemas.js +78 -0
- package/dist/orchestrator/collaboration-channel.js +103 -0
- package/dist/orchestrator/orchestrator.js +545 -0
- package/dist/orchestrator/prompts.js +124 -0
- package/dist/orchestrator/supervisor-summary.js +38 -0
- package/dist/tui/App.js +740 -0
- package/dist/tui/AppShell.js +129 -0
- package/dist/tui/InputBar.js +141 -0
- package/dist/tui/StatusBar.js +288 -0
- package/dist/tui/TerminalOutput.js +22 -0
- package/dist/tui/WorkerOutputView.js +4015 -0
- package/dist/tui/chat-input.js +37 -0
- package/dist/tui/display-width.js +132 -0
- package/dist/tui/keyboard.js +38 -0
- package/dist/tui/native-input.js +120 -0
- package/dist/tui/raw-input-decoder.js +18 -0
- package/dist/tui/scrolling.js +25 -0
- package/dist/tui/status-line.js +90 -0
- package/dist/tui/task-memory.js +26 -0
- package/dist/tui/terminal-screen.js +168 -0
- package/dist/version.js +1 -0
- package/dist/workers/mock-adapter.js +62 -0
- package/dist/workers/native-attach.js +189 -0
- package/dist/workers/process-adapter.js +265 -0
- package/dist/workers/registry.js +32 -0
- package/dist/workers/types.js +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureDir, pathExists, readJson, readTextIfExists, writeJson, writeText } from "../core/file-store.js";
|
|
4
|
+
import { routeRequestWithCodex } from "../core/router.js";
|
|
5
|
+
import { TaskMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
6
|
+
import { getAdapter } from "../workers/registry.js";
|
|
7
|
+
import { appendFeatureDialogue, createFeatureChannel, featurePromptContext, updateFeatureStatus, writeFeatureDecision } from "./collaboration-channel.js";
|
|
8
|
+
import { buildActorPrompt, buildCriticPrompt, buildJudgePrompt } from "./prompts.js";
|
|
9
|
+
import { buildSupervisorSummary } from "./supervisor-summary.js";
|
|
10
|
+
export class Orchestrator {
|
|
11
|
+
config;
|
|
12
|
+
sessions;
|
|
13
|
+
workers;
|
|
14
|
+
routeRunner;
|
|
15
|
+
constructor(config, sessions, workers, routeRunner) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.sessions = sessions;
|
|
18
|
+
this.workers = workers;
|
|
19
|
+
this.routeRunner = routeRunner;
|
|
20
|
+
}
|
|
21
|
+
async handleRequest(input) {
|
|
22
|
+
const route = await routeRequestWithCodex(input.request, this.config, this.routeRunner, input.cwd);
|
|
23
|
+
const workers = [];
|
|
24
|
+
if (route.mode === "simple") {
|
|
25
|
+
input.onStatus?.({ taskId: "main", main: "running" });
|
|
26
|
+
const output = await this.runMain(input, workers);
|
|
27
|
+
input.onStatus?.({ taskId: "main", main: "done" });
|
|
28
|
+
return {
|
|
29
|
+
mode: "simple",
|
|
30
|
+
taskId: null,
|
|
31
|
+
summary: extractMainResponse(output) || emptyMainResponseSummary(),
|
|
32
|
+
workers
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const task = await this.sessions.createTask({
|
|
36
|
+
request: input.request,
|
|
37
|
+
cwd: input.cwd,
|
|
38
|
+
route
|
|
39
|
+
});
|
|
40
|
+
const turn = (await this.sessions.latestTurn(task)) ?? {
|
|
41
|
+
turnId: "0001",
|
|
42
|
+
dir: join(task.dir, "turns", "0001"),
|
|
43
|
+
metaPath: join(task.dir, "turns", "0001", "turn.json"),
|
|
44
|
+
userPath: join(task.dir, "turns", "0001", "user.md"),
|
|
45
|
+
routePath: join(task.dir, "turns", "0001", "route.json")
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
await this.sessions.updateTaskStatus(task, "judging");
|
|
49
|
+
input.onStatus?.({ taskId: task.id, judge: "running", actor: "waiting", critic: "waiting" });
|
|
50
|
+
const judge = await this.runJudge(input, task, route.judge_engine, workers, turn);
|
|
51
|
+
const feature = await createFeatureChannel({
|
|
52
|
+
task,
|
|
53
|
+
turn,
|
|
54
|
+
request: input.request,
|
|
55
|
+
judgeDir: judge.dir
|
|
56
|
+
});
|
|
57
|
+
await this.sessions.updateTaskStatus(task, "actor_running");
|
|
58
|
+
await updateFeatureStatus(feature, "actor_running");
|
|
59
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "running", critic: "waiting" });
|
|
60
|
+
let actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, feature);
|
|
61
|
+
await this.sessions.updateTaskStatus(task, "critic_running");
|
|
62
|
+
await updateFeatureStatus(feature, "critic_running");
|
|
63
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "running" });
|
|
64
|
+
let critic = await this.runCritic(input, task, route.critic_engine, judge.dir, actor.dir, workers, turn, feature);
|
|
65
|
+
const review = await readTextIfExists(`${critic.dir}/review.md`);
|
|
66
|
+
if (review.includes("REVISION_REQUIRED")) {
|
|
67
|
+
await this.sessions.updateTaskStatus(task, "revision_needed");
|
|
68
|
+
await updateFeatureStatus(feature, "revision_needed");
|
|
69
|
+
await appendFeatureDialogue(feature, "critic.revision_requested", "critic", "Critic requested Actor revision.", {
|
|
70
|
+
review: join(critic.dir, "review.md"),
|
|
71
|
+
findings: feature.criticFindingsPath
|
|
72
|
+
});
|
|
73
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "revision", critic: "done" });
|
|
74
|
+
actor = await this.runActor(input, task, route.actor_engine, judge.dir, workers, turn, feature, buildRevisionRequest(review, feature));
|
|
75
|
+
await this.sessions.updateTaskStatus(task, "critic_running");
|
|
76
|
+
await updateFeatureStatus(feature, "critic_running");
|
|
77
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "rerunning" });
|
|
78
|
+
critic = await this.runCritic(input, task, route.critic_engine, judge.dir, actor.dir, workers, turn, feature);
|
|
79
|
+
}
|
|
80
|
+
await this.sessions.updateTaskStatus(task, "done");
|
|
81
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
|
|
82
|
+
const summary = await buildSupervisorSummary({
|
|
83
|
+
judgeDir: judge.dir,
|
|
84
|
+
actorDir: actor.dir,
|
|
85
|
+
criticDir: critic.dir,
|
|
86
|
+
turnDir: turn.dir,
|
|
87
|
+
featureActorWorklogPath: feature.actorWorklogPath,
|
|
88
|
+
featureCriticFindingsPath: feature.criticFindingsPath
|
|
89
|
+
});
|
|
90
|
+
await writeText(join(turn.dir, "supervisor-summary.md"), `${summary}\n`);
|
|
91
|
+
await writeFeatureDecision(feature, summary);
|
|
92
|
+
await updateFeatureStatus(feature, "approved");
|
|
93
|
+
return {
|
|
94
|
+
mode: "complex",
|
|
95
|
+
taskId: task.id,
|
|
96
|
+
summary,
|
|
97
|
+
workers
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
await this.sessions.updateTaskStatus(task, "failed");
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async handleTaskTurn(input) {
|
|
106
|
+
const task = this.sessions.taskFromId(input.taskId);
|
|
107
|
+
const route = await routeRequestWithCodex(input.request, this.config, this.routeRunner, input.cwd);
|
|
108
|
+
const turn = await this.sessions.appendTurn(task, {
|
|
109
|
+
request: input.request,
|
|
110
|
+
route
|
|
111
|
+
});
|
|
112
|
+
const workers = [];
|
|
113
|
+
const judgeEngine = route.judge_engine;
|
|
114
|
+
const actorEngine = route.actor_engine;
|
|
115
|
+
const criticEngine = route.critic_engine;
|
|
116
|
+
const judge = this.workerFiles(task, `judge-${judgeEngine}`);
|
|
117
|
+
const feature = await createFeatureChannel({
|
|
118
|
+
task,
|
|
119
|
+
turn,
|
|
120
|
+
request: input.request,
|
|
121
|
+
judgeDir: judge.dir
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
await this.sessions.updateTaskStatus(task, "actor_running");
|
|
125
|
+
await updateFeatureStatus(feature, "actor_running");
|
|
126
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "running", critic: "waiting" });
|
|
127
|
+
let actor = await this.runActor(input, task, actorEngine, judge.dir, workers, turn, feature);
|
|
128
|
+
await this.sessions.updateTaskStatus(task, "critic_running");
|
|
129
|
+
await updateFeatureStatus(feature, "critic_running");
|
|
130
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "running" });
|
|
131
|
+
let critic = await this.runCritic(input, task, criticEngine, judge.dir, actor.dir, workers, turn, feature);
|
|
132
|
+
const review = await readTextIfExists(`${critic.dir}/review.md`);
|
|
133
|
+
if (review.includes("REVISION_REQUIRED")) {
|
|
134
|
+
await this.sessions.updateTaskStatus(task, "revision_needed");
|
|
135
|
+
await updateFeatureStatus(feature, "revision_needed");
|
|
136
|
+
await appendFeatureDialogue(feature, "critic.revision_requested", "critic", "Critic requested Actor revision.", {
|
|
137
|
+
review: join(critic.dir, "review.md"),
|
|
138
|
+
findings: feature.criticFindingsPath
|
|
139
|
+
});
|
|
140
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "revision", critic: "done" });
|
|
141
|
+
actor = await this.runActor(input, task, actorEngine, judge.dir, workers, turn, feature, buildRevisionRequest(review, feature));
|
|
142
|
+
await this.sessions.updateTaskStatus(task, "critic_running");
|
|
143
|
+
await updateFeatureStatus(feature, "critic_running");
|
|
144
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "rerunning" });
|
|
145
|
+
critic = await this.runCritic(input, task, criticEngine, judge.dir, actor.dir, workers, turn, feature);
|
|
146
|
+
}
|
|
147
|
+
await this.sessions.updateTaskStatus(task, "done");
|
|
148
|
+
input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
|
|
149
|
+
const summary = await buildSupervisorSummary({
|
|
150
|
+
judgeDir: judge.dir,
|
|
151
|
+
actorDir: actor.dir,
|
|
152
|
+
criticDir: critic.dir,
|
|
153
|
+
turnDir: turn.dir,
|
|
154
|
+
featureActorWorklogPath: feature.actorWorklogPath,
|
|
155
|
+
featureCriticFindingsPath: feature.criticFindingsPath
|
|
156
|
+
});
|
|
157
|
+
await writeText(join(turn.dir, "supervisor-summary.md"), `${summary}\n`);
|
|
158
|
+
await writeFeatureDecision(feature, summary);
|
|
159
|
+
await updateFeatureStatus(feature, "approved");
|
|
160
|
+
return {
|
|
161
|
+
mode: "complex",
|
|
162
|
+
taskId: task.id,
|
|
163
|
+
summary,
|
|
164
|
+
workers
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
await this.sessions.updateTaskStatus(task, "failed");
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async routeTaskFollowUp(input) {
|
|
173
|
+
const task = this.sessions.taskFromId(input.taskId);
|
|
174
|
+
const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
|
|
175
|
+
const route = await routeRequestWithCodex([
|
|
176
|
+
"Active task follow-up routing.",
|
|
177
|
+
`Task id: ${input.taskId}`,
|
|
178
|
+
`Task status: ${meta?.status ?? "unknown"}`,
|
|
179
|
+
"",
|
|
180
|
+
"Choose simple for status/log/reason/explanation questions that can be answered from session files.",
|
|
181
|
+
"Choose complex for requests that change direction, ask for code changes, reruns, fixes, new experiments, or implementation work.",
|
|
182
|
+
"",
|
|
183
|
+
"Follow-up request:",
|
|
184
|
+
input.request
|
|
185
|
+
].join("\n"), this.config, this.routeRunner, input.cwd);
|
|
186
|
+
return {
|
|
187
|
+
mode: route.mode,
|
|
188
|
+
taskId: route.mode === "complex" ? input.taskId : null,
|
|
189
|
+
reason: route.reason
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async answerTaskQuestion(input) {
|
|
193
|
+
const task = this.sessions.taskFromId(input.taskId);
|
|
194
|
+
const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
|
|
195
|
+
const workerSummaries = await Promise.all(["judge", "actor", "critic"].map((role) => this.readLatestWorkerQuestionSummary(task, role)));
|
|
196
|
+
const workers = workerSummaries.filter((worker) => worker !== null);
|
|
197
|
+
const failed = workers.find((worker) => worker.status.state === "failed");
|
|
198
|
+
const latest = failed ?? workers.at(-1);
|
|
199
|
+
const lines = [
|
|
200
|
+
`Task ${task.id}${meta ? ` is ${meta.status}` : ""}.`,
|
|
201
|
+
latest
|
|
202
|
+
? `${labelWorker(latest.status)}: ${latest.status.state}/${latest.status.phase}: ${latest.status.summary}`
|
|
203
|
+
: "No worker status files found for this task."
|
|
204
|
+
];
|
|
205
|
+
if (latest?.logTail) {
|
|
206
|
+
lines.push("", "Latest worker log:", latest.logTail);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
mode: "simple",
|
|
210
|
+
taskId: task.id,
|
|
211
|
+
summary: lines.join("\n"),
|
|
212
|
+
workers: []
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async listTaskWorkers(taskId) {
|
|
216
|
+
const task = this.sessions.taskFromId(taskId);
|
|
217
|
+
if (!(await pathExists(task.dir))) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const entries = await readdir(task.dir, { withFileTypes: true });
|
|
221
|
+
const workers = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (!entry.isDirectory()) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const dir = join(task.dir, entry.name);
|
|
227
|
+
const statusPath = join(dir, "status.json");
|
|
228
|
+
if (!(await pathExists(statusPath))) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const status = await readJson(statusPath, WorkerStatusSchema);
|
|
232
|
+
workers.push({
|
|
233
|
+
id: status.worker_id,
|
|
234
|
+
role: status.role,
|
|
235
|
+
engine: status.engine,
|
|
236
|
+
label: `${capitalize(status.role)} (${status.engine})`,
|
|
237
|
+
logPath: join(dir, "output.log"),
|
|
238
|
+
statusPath
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return workers.sort((left, right) => workerRoleOrder(left.role) - workerRoleOrder(right.role));
|
|
242
|
+
}
|
|
243
|
+
async runMain(input, workers) {
|
|
244
|
+
const engine = this.config.pairing.main;
|
|
245
|
+
const dir = this.sessions.mainSessionDir();
|
|
246
|
+
const workerId = `main-${engine}`;
|
|
247
|
+
const filesDir = join(dir, workerId);
|
|
248
|
+
const promptPath = join(filesDir, "prompt.md");
|
|
249
|
+
const outputLogPath = join(filesDir, "output.log");
|
|
250
|
+
const statusPath = join(filesDir, "status.json");
|
|
251
|
+
await ensureDir(filesDir);
|
|
252
|
+
await writeText(promptPath, input.request);
|
|
253
|
+
await writeText(outputLogPath, "");
|
|
254
|
+
await writeJson(statusPath, {
|
|
255
|
+
worker_id: workerId,
|
|
256
|
+
role: "main",
|
|
257
|
+
engine,
|
|
258
|
+
state: "idle",
|
|
259
|
+
phase: "initialized",
|
|
260
|
+
last_event_at: new Date().toISOString(),
|
|
261
|
+
summary: "Main chat worker initialized"
|
|
262
|
+
});
|
|
263
|
+
this.recordWorker(input, workers, {
|
|
264
|
+
id: workerId,
|
|
265
|
+
role: "main",
|
|
266
|
+
engine,
|
|
267
|
+
label: `Main (${engine})`,
|
|
268
|
+
logPath: outputLogPath,
|
|
269
|
+
statusPath
|
|
270
|
+
});
|
|
271
|
+
await getAdapter(this.workers, engine).run({
|
|
272
|
+
workerId,
|
|
273
|
+
role: "main",
|
|
274
|
+
engine,
|
|
275
|
+
cwd: input.cwd,
|
|
276
|
+
filesDir,
|
|
277
|
+
promptPath,
|
|
278
|
+
outputLogPath,
|
|
279
|
+
statusPath,
|
|
280
|
+
prompt: input.request
|
|
281
|
+
});
|
|
282
|
+
return readTextIfExists(outputLogPath);
|
|
283
|
+
}
|
|
284
|
+
async runJudge(input, task, engine, workers, turn) {
|
|
285
|
+
const workerId = `judge-${engine}`;
|
|
286
|
+
const judgeFiles = this.workerFiles(task, workerId);
|
|
287
|
+
const judge = await this.sessions.initializeWorker(task, {
|
|
288
|
+
workerId,
|
|
289
|
+
role: "judge",
|
|
290
|
+
engine,
|
|
291
|
+
prompt: buildJudgePrompt({
|
|
292
|
+
request: input.request,
|
|
293
|
+
taskDir: task.dir,
|
|
294
|
+
workerDir: judgeFiles.dir,
|
|
295
|
+
turn: this.promptTurnContext(turn),
|
|
296
|
+
role: this.config.roles.judge
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
this.recordWorker(input, workers, {
|
|
300
|
+
id: judge.workerId,
|
|
301
|
+
role: "judge",
|
|
302
|
+
engine,
|
|
303
|
+
label: `Judge (${engine})`,
|
|
304
|
+
logPath: judge.outputLogPath,
|
|
305
|
+
statusPath: judge.statusPath
|
|
306
|
+
});
|
|
307
|
+
const result = await this.runWorkerWithNativeSession(engine, {
|
|
308
|
+
workerId: judge.workerId,
|
|
309
|
+
role: "judge",
|
|
310
|
+
engine,
|
|
311
|
+
cwd: input.cwd,
|
|
312
|
+
filesDir: judge.dir,
|
|
313
|
+
promptPath: judge.promptPath,
|
|
314
|
+
outputLogPath: judge.outputLogPath,
|
|
315
|
+
statusPath: judge.statusPath,
|
|
316
|
+
prompt: await readTextIfExists(judge.promptPath)
|
|
317
|
+
});
|
|
318
|
+
ensureWorkerSuccess(result.workerId, result.exitCode);
|
|
319
|
+
return judge;
|
|
320
|
+
}
|
|
321
|
+
async runActor(input, task, engine, judgeDir, workers, turn, feature, revision) {
|
|
322
|
+
const actor = await this.sessions.initializeWorker(task, {
|
|
323
|
+
workerId: `actor-${engine}`,
|
|
324
|
+
role: "actor",
|
|
325
|
+
engine,
|
|
326
|
+
prompt: buildActorPrompt({
|
|
327
|
+
request: input.request,
|
|
328
|
+
taskDir: task.dir,
|
|
329
|
+
judgeDir,
|
|
330
|
+
turn: this.promptTurnContext(turn),
|
|
331
|
+
feature: featurePromptContext(feature),
|
|
332
|
+
revision,
|
|
333
|
+
role: this.config.roles.actor
|
|
334
|
+
})
|
|
335
|
+
});
|
|
336
|
+
this.recordWorker(input, workers, {
|
|
337
|
+
id: actor.workerId,
|
|
338
|
+
role: "actor",
|
|
339
|
+
engine,
|
|
340
|
+
label: `Actor (${engine})`,
|
|
341
|
+
logPath: actor.outputLogPath,
|
|
342
|
+
statusPath: actor.statusPath
|
|
343
|
+
});
|
|
344
|
+
const result = await this.runWorkerWithNativeSession(engine, {
|
|
345
|
+
workerId: actor.workerId,
|
|
346
|
+
role: "actor",
|
|
347
|
+
engine,
|
|
348
|
+
cwd: input.cwd,
|
|
349
|
+
filesDir: actor.dir,
|
|
350
|
+
promptPath: actor.promptPath,
|
|
351
|
+
outputLogPath: actor.outputLogPath,
|
|
352
|
+
statusPath: actor.statusPath,
|
|
353
|
+
prompt: await readTextIfExists(actor.promptPath)
|
|
354
|
+
});
|
|
355
|
+
ensureWorkerSuccess(result.workerId, result.exitCode);
|
|
356
|
+
await mirrorWorkerFileToFeature(join(actor.dir, "worklog.md"), feature.actorWorklogPath);
|
|
357
|
+
await appendFeatureDialogue(feature, "actor.completed", "actor", "Actor completed feature work.", {
|
|
358
|
+
worklog: actor.outputLogPath,
|
|
359
|
+
feature_worklog: feature.actorWorklogPath,
|
|
360
|
+
replies: feature.actorRepliesPath
|
|
361
|
+
});
|
|
362
|
+
return actor;
|
|
363
|
+
}
|
|
364
|
+
async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature) {
|
|
365
|
+
const critic = await this.sessions.initializeWorker(task, {
|
|
366
|
+
workerId: `critic-${engine}`,
|
|
367
|
+
role: "critic",
|
|
368
|
+
engine,
|
|
369
|
+
prompt: buildCriticPrompt({
|
|
370
|
+
request: input.request,
|
|
371
|
+
taskDir: task.dir,
|
|
372
|
+
judgeDir,
|
|
373
|
+
actorDir,
|
|
374
|
+
turn: this.promptTurnContext(turn),
|
|
375
|
+
feature: featurePromptContext(feature),
|
|
376
|
+
role: this.config.roles.critic
|
|
377
|
+
})
|
|
378
|
+
});
|
|
379
|
+
this.recordWorker(input, workers, {
|
|
380
|
+
id: critic.workerId,
|
|
381
|
+
role: "critic",
|
|
382
|
+
engine,
|
|
383
|
+
label: `Critic (${engine})`,
|
|
384
|
+
logPath: critic.outputLogPath,
|
|
385
|
+
statusPath: critic.statusPath
|
|
386
|
+
});
|
|
387
|
+
const result = await this.runWorkerWithNativeSession(engine, {
|
|
388
|
+
workerId: critic.workerId,
|
|
389
|
+
role: "critic",
|
|
390
|
+
engine,
|
|
391
|
+
cwd: input.cwd,
|
|
392
|
+
filesDir: critic.dir,
|
|
393
|
+
promptPath: critic.promptPath,
|
|
394
|
+
outputLogPath: critic.outputLogPath,
|
|
395
|
+
statusPath: critic.statusPath,
|
|
396
|
+
prompt: await readTextIfExists(critic.promptPath)
|
|
397
|
+
});
|
|
398
|
+
ensureWorkerSuccess(result.workerId, result.exitCode);
|
|
399
|
+
await appendFeatureDialogue(feature, "critic.completed", "critic", "Critic completed feature review.", {
|
|
400
|
+
review: join(critic.dir, "review.md"),
|
|
401
|
+
findings: feature.criticFindingsPath
|
|
402
|
+
});
|
|
403
|
+
return critic;
|
|
404
|
+
}
|
|
405
|
+
recordWorker(input, workers, worker) {
|
|
406
|
+
const existingIndex = workers.findIndex((item) => item.id === worker.id);
|
|
407
|
+
if (existingIndex >= 0) {
|
|
408
|
+
workers[existingIndex] = worker;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
workers.push(worker);
|
|
412
|
+
}
|
|
413
|
+
input.onWorker?.(worker);
|
|
414
|
+
}
|
|
415
|
+
async runWorkerWithNativeSession(engine, spec) {
|
|
416
|
+
const adapter = getAdapter(this.workers, engine);
|
|
417
|
+
const workerFiles = {
|
|
418
|
+
workerId: spec.workerId,
|
|
419
|
+
dir: spec.filesDir,
|
|
420
|
+
promptPath: spec.promptPath,
|
|
421
|
+
outputLogPath: spec.outputLogPath,
|
|
422
|
+
statusPath: spec.statusPath
|
|
423
|
+
};
|
|
424
|
+
const existing = await this.sessions.readNativeSession(workerFiles);
|
|
425
|
+
if (existing) {
|
|
426
|
+
await this.sessions.writeNativeSession(workerFiles, {
|
|
427
|
+
...existing,
|
|
428
|
+
last_used_at: new Date().toISOString()
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return adapter.run({
|
|
432
|
+
...spec,
|
|
433
|
+
nativeSession: existing,
|
|
434
|
+
nativeSessionConfig: this.config.workers[engine].nativeSession,
|
|
435
|
+
onNativeSession: async (sessionId) => {
|
|
436
|
+
const now = new Date().toISOString();
|
|
437
|
+
const previous = await this.sessions.readNativeSession(workerFiles);
|
|
438
|
+
const record = {
|
|
439
|
+
engine,
|
|
440
|
+
role: spec.role,
|
|
441
|
+
worker_id: spec.workerId,
|
|
442
|
+
session_id: sessionId,
|
|
443
|
+
scope: "task",
|
|
444
|
+
cwd: spec.cwd,
|
|
445
|
+
created_at: previous?.created_at ?? now,
|
|
446
|
+
last_used_at: now,
|
|
447
|
+
source: previous?.source ?? "output-detected"
|
|
448
|
+
};
|
|
449
|
+
await this.sessions.writeNativeSession(workerFiles, record);
|
|
450
|
+
},
|
|
451
|
+
onNativeSessionRetired: async (_sessionId, reason) => {
|
|
452
|
+
await this.sessions.retireNativeSession(workerFiles, summarizeRetirementReason(reason));
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
workerFiles(task, workerId) {
|
|
457
|
+
const dir = join(task.dir, workerId);
|
|
458
|
+
return {
|
|
459
|
+
workerId,
|
|
460
|
+
dir,
|
|
461
|
+
promptPath: join(dir, "prompt.md"),
|
|
462
|
+
outputLogPath: join(dir, "output.log"),
|
|
463
|
+
statusPath: join(dir, "status.json")
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
promptTurnContext(turn) {
|
|
467
|
+
return {
|
|
468
|
+
turnId: turn.turnId,
|
|
469
|
+
turnDir: turn.dir,
|
|
470
|
+
previousSummaries: []
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async readLatestWorkerQuestionSummary(task, role) {
|
|
474
|
+
for (const engine of ["codex", "claude", "mock"]) {
|
|
475
|
+
const files = this.workerFiles(task, `${role}-${engine}`);
|
|
476
|
+
if (!(await pathExists(files.statusPath))) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const status = await readJson(files.statusPath, WorkerStatusSchema);
|
|
480
|
+
return {
|
|
481
|
+
status,
|
|
482
|
+
logTail: tailText(await readTextIfExists(files.outputLogPath), 8)
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function ensureWorkerSuccess(workerId, exitCode) {
|
|
489
|
+
if (exitCode !== 0) {
|
|
490
|
+
throw new Error(`${workerId} failed with exit code ${exitCode}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function summarizeRetirementReason(reason) {
|
|
494
|
+
const lines = reason
|
|
495
|
+
.split(/\r?\n/)
|
|
496
|
+
.map((line) => line.trim())
|
|
497
|
+
.filter(Boolean);
|
|
498
|
+
const contextLine = lines.find((line) => /context window|ran out of room|clear earlier history|start a new thread/i.test(line));
|
|
499
|
+
const summary = contextLine ?? lines.at(-1) ?? "Native session retired";
|
|
500
|
+
return summary.length > 240 ? `${summary.slice(0, 237)}...` : summary;
|
|
501
|
+
}
|
|
502
|
+
function buildRevisionRequest(review, feature) {
|
|
503
|
+
return [
|
|
504
|
+
review.trim(),
|
|
505
|
+
"",
|
|
506
|
+
"Feature mailbox:",
|
|
507
|
+
`- Critic findings: ${feature.criticFindingsPath}`,
|
|
508
|
+
`- Actor replies: ${feature.actorRepliesPath}`,
|
|
509
|
+
"Reply to each fixed finding in actor-replies.jsonl."
|
|
510
|
+
].join("\n");
|
|
511
|
+
}
|
|
512
|
+
async function mirrorWorkerFileToFeature(sourcePath, targetPath) {
|
|
513
|
+
const content = await readTextIfExists(sourcePath);
|
|
514
|
+
if (content.trim()) {
|
|
515
|
+
await writeText(targetPath, content);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function extractMainResponse(outputLog) {
|
|
519
|
+
return outputLog
|
|
520
|
+
.split("\n")
|
|
521
|
+
.filter((line) => !line.startsWith("$ "))
|
|
522
|
+
.filter((line) => !line.startsWith("[mock:main]"))
|
|
523
|
+
.join("\n")
|
|
524
|
+
.trim();
|
|
525
|
+
}
|
|
526
|
+
function emptyMainResponseSummary() {
|
|
527
|
+
return "简单对话通道没有收到可显示回复。";
|
|
528
|
+
}
|
|
529
|
+
function labelWorker(status) {
|
|
530
|
+
return `${capitalize(status.role)} (${status.engine})`;
|
|
531
|
+
}
|
|
532
|
+
function capitalize(value) {
|
|
533
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
534
|
+
}
|
|
535
|
+
function workerRoleOrder(role) {
|
|
536
|
+
return ["main", "judge", "actor", "critic"].indexOf(role);
|
|
537
|
+
}
|
|
538
|
+
function tailText(text, lines) {
|
|
539
|
+
return text
|
|
540
|
+
.split(/\r?\n/)
|
|
541
|
+
.filter((line) => line.trim())
|
|
542
|
+
.slice(-lines)
|
|
543
|
+
.join("\n")
|
|
544
|
+
.trim();
|
|
545
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export function buildJudgePrompt(input) {
|
|
2
|
+
const role = roleConfig(input.role, "Judge", [
|
|
3
|
+
"You clarify requirements and write task files. Do not implement code."
|
|
4
|
+
]);
|
|
5
|
+
return [
|
|
6
|
+
`# Role: ${role.title}`,
|
|
7
|
+
"",
|
|
8
|
+
...instructionLines(role.instructions),
|
|
9
|
+
"",
|
|
10
|
+
`Task directory: ${input.taskDir}`,
|
|
11
|
+
...(input.workerDir ? [`Worker directory: ${input.workerDir}`] : []),
|
|
12
|
+
...turnLines(input.turn),
|
|
13
|
+
"",
|
|
14
|
+
"Write these files in the worker directory above:",
|
|
15
|
+
"- requirements.md",
|
|
16
|
+
"- plan.md",
|
|
17
|
+
"- acceptance.md",
|
|
18
|
+
"- actor-brief.md",
|
|
19
|
+
"- critic-brief.md",
|
|
20
|
+
"",
|
|
21
|
+
"User request:",
|
|
22
|
+
input.request,
|
|
23
|
+
""
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
export function buildActorPrompt(input) {
|
|
27
|
+
const role = roleConfig(input.role, "Actor", [
|
|
28
|
+
"Read Judge files, implement the requested change, and record your work."
|
|
29
|
+
]);
|
|
30
|
+
return [
|
|
31
|
+
`# Role: ${role.title}`,
|
|
32
|
+
"",
|
|
33
|
+
...instructionLines(role.instructions),
|
|
34
|
+
"",
|
|
35
|
+
`Task directory: ${input.taskDir}`,
|
|
36
|
+
`Judge directory: ${input.judgeDir}`,
|
|
37
|
+
...turnLines(input.turn),
|
|
38
|
+
...featureLines(input.feature),
|
|
39
|
+
"",
|
|
40
|
+
"Read:",
|
|
41
|
+
"- requirements.md",
|
|
42
|
+
"- plan.md",
|
|
43
|
+
"- acceptance.md",
|
|
44
|
+
"- actor-brief.md",
|
|
45
|
+
"",
|
|
46
|
+
"Write in your worker directory:",
|
|
47
|
+
"- worklog.md",
|
|
48
|
+
"- patch.diff when a diff is available",
|
|
49
|
+
"",
|
|
50
|
+
"Feature mailbox writes:",
|
|
51
|
+
"- actor-worklog.md with implementation notes for this feature.",
|
|
52
|
+
"- actor-replies.jsonl with one JSON object per Critic finding you fixed.",
|
|
53
|
+
"",
|
|
54
|
+
input.revision ? `Revision request:\n${input.revision}` : "No Critic revision request is active.",
|
|
55
|
+
"",
|
|
56
|
+
"User request:",
|
|
57
|
+
input.request,
|
|
58
|
+
""
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
export function buildCriticPrompt(input) {
|
|
62
|
+
const role = roleConfig(input.role, "Critic", [
|
|
63
|
+
"Review Actor work against Judge requirements. Lead with blocking findings."
|
|
64
|
+
]);
|
|
65
|
+
return [
|
|
66
|
+
`# Role: ${role.title}`,
|
|
67
|
+
"",
|
|
68
|
+
...instructionLines(role.instructions),
|
|
69
|
+
"",
|
|
70
|
+
`Task directory: ${input.taskDir}`,
|
|
71
|
+
`Judge directory: ${input.judgeDir}`,
|
|
72
|
+
`Actor directory: ${input.actorDir ?? ""}`,
|
|
73
|
+
...turnLines(input.turn),
|
|
74
|
+
...featureLines(input.feature),
|
|
75
|
+
"",
|
|
76
|
+
"Read Judge files and Actor output.",
|
|
77
|
+
"Read actor-replies.jsonl when reviewing a revision.",
|
|
78
|
+
"",
|
|
79
|
+
"Write review.md in your worker directory. Include APPROVED when no blocking findings remain.",
|
|
80
|
+
"If revision is required, include REVISION_REQUIRED and a concise fix list.",
|
|
81
|
+
"Write critic-findings.jsonl in the feature mailbox with one JSON object per blocking issue.",
|
|
82
|
+
"",
|
|
83
|
+
"User request:",
|
|
84
|
+
input.request,
|
|
85
|
+
""
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
function roleConfig(role, title, instructions) {
|
|
89
|
+
return {
|
|
90
|
+
title: role?.title ?? title,
|
|
91
|
+
instructions: role?.instructions?.length ? role.instructions : instructions
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function instructionLines(instructions) {
|
|
95
|
+
if (instructions.length === 1) {
|
|
96
|
+
return [instructions[0]];
|
|
97
|
+
}
|
|
98
|
+
return instructions.map((instruction) => `- ${instruction}`);
|
|
99
|
+
}
|
|
100
|
+
function featureLines(feature) {
|
|
101
|
+
if (!feature) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return [
|
|
105
|
+
`Feature id: ${feature.featureId}`,
|
|
106
|
+
`Feature directory: ${feature.featureDir}`,
|
|
107
|
+
`Actor/Critic dialogue log: ${feature.dialoguePath}`,
|
|
108
|
+
`Actor feature worklog: ${feature.actorWorklogPath}`,
|
|
109
|
+
`Critic findings: ${feature.criticFindingsPath}`,
|
|
110
|
+
`Actor replies: ${feature.actorRepliesPath}`,
|
|
111
|
+
`Feature decisions: ${feature.decisionsPath}`
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
function turnLines(turn) {
|
|
115
|
+
if (!turn) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
return [
|
|
119
|
+
`Current turn: ${turn.turnId}`,
|
|
120
|
+
`Current turn directory: ${turn.turnDir}`,
|
|
121
|
+
"Previous turn summaries:",
|
|
122
|
+
...(turn.previousSummaries?.length ? turn.previousSummaries.map((summary) => `- ${summary}`) : ["- (none)"])
|
|
123
|
+
];
|
|
124
|
+
}
|