@ai-dev-methodologies/rlp-desk 0.7.5 → 0.8.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 +58 -0
- package/docs/blueprints/blueprint-pivot-step.md +137 -0
- package/package.json +5 -2
- package/scripts/postinstall.js +91 -51
- package/scripts/uninstall.js +18 -9
- package/src/commands/rlp-desk.md +0 -2
- package/src/governance.md +1 -0
- package/src/node/cli/command-builder.mjs +96 -0
- package/src/node/init/campaign-initializer.mjs +235 -0
- package/src/node/polling/signal-poller.mjs +106 -0
- package/src/node/prompts/prompt-assembler.mjs +213 -0
- package/src/node/reporting/campaign-reporting.mjs +257 -0
- package/src/node/run.mjs +234 -0
- package/src/node/runner/campaign-main-loop.mjs +624 -0
- package/src/node/shared/fs.mjs +23 -0
- package/src/node/shared/paths.mjs +28 -0
- package/src/node/tmux/pane-manager.mjs +77 -0
- package/docs/blueprints/blueprint-v0.4-evolution.md +0 -347
- package/docs/prompts/ralplan-codex-review.md +0 -55
- package/docs/superpowers/plans/2026-04-06-worker-verifier-prompt-restructure.md +0 -179
- package/src/scripts/init_ralph_desk.zsh +0 -885
- package/src/scripts/lib_ralph_desk.zsh +0 -904
- package/src/scripts/run_ralph_desk.zsh +0 -2750
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { buildClaudeCmd, buildCodexCmd, parseModelFlag } from '../cli/command-builder.mjs';
|
|
7
|
+
import { initCampaign } from '../init/campaign-initializer.mjs';
|
|
8
|
+
import { TimeoutError, pollForSignal as defaultPollForSignal } from '../polling/signal-poller.mjs';
|
|
9
|
+
import {
|
|
10
|
+
assembleVerifierPrompt,
|
|
11
|
+
assembleWorkerPrompt,
|
|
12
|
+
} from '../prompts/prompt-assembler.mjs';
|
|
13
|
+
import {
|
|
14
|
+
appendCampaignAnalytics,
|
|
15
|
+
generateCampaignReport,
|
|
16
|
+
prepareCampaignAnalytics,
|
|
17
|
+
} from '../reporting/campaign-reporting.mjs';
|
|
18
|
+
import {
|
|
19
|
+
createPane as defaultCreatePane,
|
|
20
|
+
sendKeys as defaultSendKeys,
|
|
21
|
+
} from '../tmux/pane-manager.mjs';
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
const REQUIRED_SCAFFOLD_NAMES = ['workerPrompt', 'verifierPrompt', 'memoryFile', 'prdFile', 'testSpecFile'];
|
|
25
|
+
const CLAUDE_MODELS = new Set(['haiku', 'sonnet', 'opus']);
|
|
26
|
+
const MODEL_UPGRADES = {
|
|
27
|
+
'gpt-5.4:medium': 'gpt-5.4:high',
|
|
28
|
+
'gpt-5.4:high': 'gpt-5.4:xhigh',
|
|
29
|
+
'gpt-5.4:xhigh': 'BLOCKED',
|
|
30
|
+
'gpt-5.3-codex-spark:medium': 'gpt-5.3-codex-spark:high',
|
|
31
|
+
'gpt-5.3-codex-spark:high': 'gpt-5.3-codex-spark:xhigh',
|
|
32
|
+
'gpt-5.3-codex-spark:xhigh': 'BLOCKED',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function buildPaths(rootDir, slug) {
|
|
36
|
+
const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
|
|
37
|
+
const campaignLogDir = path.join(deskRoot, 'logs', slug);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
deskRoot,
|
|
41
|
+
promptsDir: path.join(deskRoot, 'prompts'),
|
|
42
|
+
plansDir: path.join(deskRoot, 'plans'),
|
|
43
|
+
memosDir: path.join(deskRoot, 'memos'),
|
|
44
|
+
contextDir: path.join(deskRoot, 'context'),
|
|
45
|
+
campaignLogDir,
|
|
46
|
+
runtimeDir: path.join(campaignLogDir, 'runtime'),
|
|
47
|
+
workerPrompt: path.join(deskRoot, 'prompts', `${slug}.worker.prompt.md`),
|
|
48
|
+
verifierPrompt: path.join(deskRoot, 'prompts', `${slug}.verifier.prompt.md`),
|
|
49
|
+
memoryFile: path.join(deskRoot, 'memos', `${slug}-memory.md`),
|
|
50
|
+
doneClaimFile: path.join(deskRoot, 'memos', `${slug}-done-claim.json`),
|
|
51
|
+
signalFile: path.join(deskRoot, 'memos', `${slug}-iter-signal.json`),
|
|
52
|
+
verdictFile: path.join(deskRoot, 'memos', `${slug}-verify-verdict.json`),
|
|
53
|
+
blockedSentinel: path.join(deskRoot, 'memos', `${slug}-blocked.md`),
|
|
54
|
+
completeSentinel: path.join(deskRoot, 'memos', `${slug}-complete.md`),
|
|
55
|
+
contextFile: path.join(deskRoot, 'context', `${slug}-latest.md`),
|
|
56
|
+
prdFile: path.join(deskRoot, 'plans', `prd-${slug}.md`),
|
|
57
|
+
testSpecFile: path.join(deskRoot, 'plans', `test-spec-${slug}.md`),
|
|
58
|
+
analyticsFile: path.join(campaignLogDir, 'campaign.jsonl'),
|
|
59
|
+
reportFile: path.join(campaignLogDir, 'campaign-report.md'),
|
|
60
|
+
statusFile: path.join(campaignLogDir, 'runtime', 'status.json'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function exists(targetPath) {
|
|
65
|
+
try {
|
|
66
|
+
await fs.access(targetPath);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureScaffold(paths) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
for (const key of REQUIRED_SCAFFOLD_NAMES) {
|
|
76
|
+
if (!(await exists(paths[key]))) {
|
|
77
|
+
missing.push(paths[key]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (missing.length > 0) {
|
|
82
|
+
throw new Error(`missing required scaffold: ${missing.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function ensureDirs(paths) {
|
|
87
|
+
await fs.mkdir(paths.campaignLogDir, { recursive: true });
|
|
88
|
+
await fs.mkdir(paths.runtimeDir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readJsonIfExists(targetPath) {
|
|
92
|
+
if (!(await exists(targetPath))) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return JSON.parse(await fs.readFile(targetPath, 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeJson(targetPath, value) {
|
|
100
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
101
|
+
await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readUsList(paths, slug) {
|
|
105
|
+
const entries = await fs.readdir(paths.plansDir, { withFileTypes: true });
|
|
106
|
+
const splitPrefix = `prd-${slug}-US-`;
|
|
107
|
+
const splitFiles = entries
|
|
108
|
+
.filter((entry) => entry.isFile() && entry.name.startsWith(splitPrefix) && entry.name.endsWith('.md'))
|
|
109
|
+
.map((entry) => entry.name.match(/US-\d{3}/)?.[0])
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.sort();
|
|
112
|
+
|
|
113
|
+
if (splitFiles.length > 0) {
|
|
114
|
+
return splitFiles;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const prdContent = await fs.readFile(paths.prdFile, 'utf8');
|
|
118
|
+
return [...prdContent.matchAll(/^## (US-\d{3}):/gm)].map((match) => match[1]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getNextUs(usList, verifiedUs, currentUs) {
|
|
122
|
+
if (currentUs && usList.includes(currentUs) && !verifiedUs.includes(currentUs)) {
|
|
123
|
+
return currentUs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return usList.find((usId) => !verifiedUs.includes(usId)) ?? 'ALL';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toIso(now) {
|
|
130
|
+
return new Date(now).toISOString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveNow(nowOverride) {
|
|
134
|
+
if (typeof nowOverride === 'function') {
|
|
135
|
+
return nowOverride();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return nowOverride ?? Date.now();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function writeStatus(paths, status, onStatusChange, nowOverride) {
|
|
142
|
+
const nextStatus = {
|
|
143
|
+
...status,
|
|
144
|
+
updated_at_utc: toIso(resolveNow(nowOverride)),
|
|
145
|
+
};
|
|
146
|
+
await writeJson(paths.statusFile, nextStatus);
|
|
147
|
+
if (typeof onStatusChange === 'function') {
|
|
148
|
+
onStatusChange(nextStatus);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function shQuote(value) {
|
|
153
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildLaunchCommand(promptFile, modelFlag) {
|
|
157
|
+
const parsed = parseModelFlag(modelFlag);
|
|
158
|
+
const promptExpr = `"$(cat ${shQuote(promptFile)})"`;
|
|
159
|
+
|
|
160
|
+
if (parsed.engine === 'claude') {
|
|
161
|
+
return `${buildClaudeCmd('tui', parsed.model, { effort: parsed.effort })} ${promptExpr}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return `${buildCodexCmd('tui', parsed.model, { reasoning: parsed.reasoning })} ${promptExpr}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function writePromptFile(targetPath, content) {
|
|
168
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
169
|
+
await fs.writeFile(targetPath, content, 'utf8');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildFixContract(verdict) {
|
|
173
|
+
const issues = [...(verdict.issues ?? [])].sort((left, right) => {
|
|
174
|
+
const rank = { critical: 0, major: 1, minor: 2 };
|
|
175
|
+
return (rank[left.severity] ?? 3) - (rank[right.severity] ?? 3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const lines = ['# Fix Contract', ''];
|
|
179
|
+
if (issues.length === 0) {
|
|
180
|
+
lines.push('- No structured issues were provided. Re-check the failing scope and verifier evidence.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const issue of issues) {
|
|
184
|
+
lines.push(`- ${issue.criterion_id ?? 'unknown'} [${issue.severity ?? 'major'}]: ${issue.summary ?? 'unspecified issue'}`);
|
|
185
|
+
if (issue.fix_hint) {
|
|
186
|
+
lines.push(` fix_hint: ${issue.fix_hint}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `${lines.join('\n')}\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function nextWorkerModel(currentModel, consecutiveFailures) {
|
|
194
|
+
if (consecutiveFailures < 3) {
|
|
195
|
+
return currentModel;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const stage = Math.floor(consecutiveFailures / 3);
|
|
199
|
+
let model = currentModel;
|
|
200
|
+
|
|
201
|
+
for (let index = 0; index < stage; index += 1) {
|
|
202
|
+
const next = MODEL_UPGRADES[model];
|
|
203
|
+
if (!next || next === 'BLOCKED') {
|
|
204
|
+
return 'BLOCKED';
|
|
205
|
+
}
|
|
206
|
+
model = next;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return model;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function defaultCreateSession({ sessionName, workingDir }) {
|
|
213
|
+
const { stdout } = await execFileAsync('tmux', [
|
|
214
|
+
'new-session',
|
|
215
|
+
'-d',
|
|
216
|
+
'-P',
|
|
217
|
+
'-F',
|
|
218
|
+
'#{pane_id}',
|
|
219
|
+
'-s',
|
|
220
|
+
sessionName,
|
|
221
|
+
'-c',
|
|
222
|
+
workingDir,
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
sessionName,
|
|
227
|
+
leaderPaneId: stdout.trim(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function deriveVerifierModel(usId, options) {
|
|
232
|
+
return usId === 'ALL'
|
|
233
|
+
? (options.finalVerifierModel ?? 'opus')
|
|
234
|
+
: (options.verifierModel ?? 'sonnet');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readCurrentState(paths, slug, options) {
|
|
238
|
+
const status = (await readJsonIfExists(paths.statusFile)) ?? {};
|
|
239
|
+
const startedAt = status.started_at_utc ?? toIso(resolveNow(options.now));
|
|
240
|
+
return {
|
|
241
|
+
slug,
|
|
242
|
+
iteration: status.iteration ?? 1,
|
|
243
|
+
max_iterations: status.max_iterations ?? options.maxIterations ?? 100,
|
|
244
|
+
phase: status.phase ?? 'worker',
|
|
245
|
+
worker_model: status.worker_model ?? options.workerModel ?? 'sonnet',
|
|
246
|
+
verifier_model: status.verifier_model ?? options.verifierModel ?? 'sonnet',
|
|
247
|
+
final_verifier_model: status.final_verifier_model ?? options.finalVerifierModel ?? 'opus',
|
|
248
|
+
verified_us: status.verified_us ?? [],
|
|
249
|
+
consecutive_failures: status.consecutive_failures ?? 0,
|
|
250
|
+
current_us: status.current_us ?? null,
|
|
251
|
+
session_name: status.session_name ?? null,
|
|
252
|
+
leader_pane_id: status.leader_pane_id ?? null,
|
|
253
|
+
worker_pane_id: status.worker_pane_id ?? null,
|
|
254
|
+
verifier_pane_id: status.verifier_pane_id ?? null,
|
|
255
|
+
started_at_utc: startedAt,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function appendIterationAnalytics(paths, state, usId, verdict, options) {
|
|
260
|
+
await appendCampaignAnalytics(paths.analyticsFile, {
|
|
261
|
+
iter: state.iteration,
|
|
262
|
+
us_id: usId,
|
|
263
|
+
worker_model: state.worker_model,
|
|
264
|
+
worker_engine: parseModelFlag(state.worker_model).engine,
|
|
265
|
+
verdict,
|
|
266
|
+
duration: 0,
|
|
267
|
+
timestamp: toIso(resolveNow(options.now)),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function dispatchWorker({
|
|
272
|
+
iteration,
|
|
273
|
+
paths,
|
|
274
|
+
slug,
|
|
275
|
+
usList,
|
|
276
|
+
state,
|
|
277
|
+
sendKeys,
|
|
278
|
+
workerPaneId,
|
|
279
|
+
fixContractPath,
|
|
280
|
+
}) {
|
|
281
|
+
const perUsPrdPath = path.join(paths.plansDir, `prd-${slug}-${state.current_us}.md`);
|
|
282
|
+
const perUsTestSpecPath = path.join(paths.plansDir, `test-spec-${slug}-${state.current_us}.md`);
|
|
283
|
+
const prompt = await assembleWorkerPrompt({
|
|
284
|
+
promptBase: paths.workerPrompt,
|
|
285
|
+
memoryFile: paths.memoryFile,
|
|
286
|
+
iteration,
|
|
287
|
+
verifyMode: 'per-us',
|
|
288
|
+
usList,
|
|
289
|
+
verifiedUs: state.verified_us,
|
|
290
|
+
fullPrdPath: paths.prdFile,
|
|
291
|
+
perUsPrdPath,
|
|
292
|
+
fullTestSpecPath: paths.testSpecFile,
|
|
293
|
+
perUsTestSpecPath,
|
|
294
|
+
fixContractPath,
|
|
295
|
+
});
|
|
296
|
+
const promptFile = path.join(paths.campaignLogDir, `iter-${String(iteration).padStart(3, '0')}.worker-prompt.md`);
|
|
297
|
+
|
|
298
|
+
await writePromptFile(promptFile, prompt);
|
|
299
|
+
await sendKeys(workerPaneId, buildLaunchCommand(promptFile, state.worker_model));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function dispatchVerifier({
|
|
303
|
+
iteration,
|
|
304
|
+
suffix,
|
|
305
|
+
paths,
|
|
306
|
+
state,
|
|
307
|
+
usId,
|
|
308
|
+
sendKeys,
|
|
309
|
+
verifierPaneId,
|
|
310
|
+
verifierModel,
|
|
311
|
+
}) {
|
|
312
|
+
const prompt = await assembleVerifierPrompt({
|
|
313
|
+
promptBase: paths.verifierPrompt,
|
|
314
|
+
iteration,
|
|
315
|
+
doneClaimFile: paths.doneClaimFile,
|
|
316
|
+
verifyMode: 'per-us',
|
|
317
|
+
usId,
|
|
318
|
+
verifiedUs: state.verified_us,
|
|
319
|
+
});
|
|
320
|
+
const fileName = suffix
|
|
321
|
+
? `${suffix}.verifier-prompt.md`
|
|
322
|
+
: `iter-${String(iteration).padStart(3, '0')}.verifier-prompt.md`;
|
|
323
|
+
const promptFile = path.join(paths.campaignLogDir, fileName);
|
|
324
|
+
|
|
325
|
+
await writePromptFile(promptFile, prompt);
|
|
326
|
+
await sendKeys(verifierPaneId, buildLaunchCommand(promptFile, verifierModel));
|
|
327
|
+
return promptFile;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function writeSentinel(filePath, status, usId) {
|
|
331
|
+
const content = `${status.toUpperCase()}: ${usId}\n`;
|
|
332
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function runFinalSequentialVerify({
|
|
336
|
+
paths,
|
|
337
|
+
state,
|
|
338
|
+
usList,
|
|
339
|
+
sendKeys,
|
|
340
|
+
verifierPaneId,
|
|
341
|
+
pollForSignal,
|
|
342
|
+
runIntegrationCheck,
|
|
343
|
+
}) {
|
|
344
|
+
const verifierModel = state.final_verifier_model;
|
|
345
|
+
|
|
346
|
+
for (const usId of usList) {
|
|
347
|
+
await dispatchVerifier({
|
|
348
|
+
iteration: state.iteration,
|
|
349
|
+
suffix: `final-${usId}`,
|
|
350
|
+
paths,
|
|
351
|
+
state,
|
|
352
|
+
usId,
|
|
353
|
+
sendKeys,
|
|
354
|
+
verifierPaneId,
|
|
355
|
+
verifierModel,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const verdict = await pollForSignal(paths.verdictFile, {
|
|
359
|
+
mode: parseModelFlag(verifierModel, 'verifier').engine,
|
|
360
|
+
paneId: verifierPaneId,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (verdict.verdict !== 'pass') {
|
|
364
|
+
return {
|
|
365
|
+
status: 'continue',
|
|
366
|
+
usId,
|
|
367
|
+
verdict,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const integrationResult = await runIntegrationCheck();
|
|
373
|
+
if (integrationResult.exitCode !== 0) {
|
|
374
|
+
return {
|
|
375
|
+
status: 'continue',
|
|
376
|
+
usId: 'ALL',
|
|
377
|
+
verdict: {
|
|
378
|
+
verdict: 'fail',
|
|
379
|
+
recommended_state_transition: 'continue',
|
|
380
|
+
issues: [
|
|
381
|
+
{
|
|
382
|
+
criterion_id: 'AC-6.4',
|
|
383
|
+
severity: 'major',
|
|
384
|
+
summary: integrationResult.summary ?? 'integration verification failed',
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
status: 'complete',
|
|
393
|
+
usId: 'ALL',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function run(slug, options = {}) {
|
|
398
|
+
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
399
|
+
const paths = buildPaths(rootDir, slug);
|
|
400
|
+
const sendKeys = options.sendKeys ?? defaultSendKeys;
|
|
401
|
+
const createPane = options.createPane ?? defaultCreatePane;
|
|
402
|
+
const createSession = options.createSession ?? defaultCreateSession;
|
|
403
|
+
const pollForSignal = options.pollForSignal ?? defaultPollForSignal;
|
|
404
|
+
const runIntegrationCheck = options.runIntegrationCheck ?? (async () => ({ exitCode: 0, summary: 'integration skipped' }));
|
|
405
|
+
const maxIterations = options.maxIterations ?? 100;
|
|
406
|
+
|
|
407
|
+
await ensureDirs(paths);
|
|
408
|
+
await ensureScaffold(paths);
|
|
409
|
+
await prepareCampaignAnalytics({
|
|
410
|
+
analyticsFile: paths.analyticsFile,
|
|
411
|
+
statusFile: paths.statusFile,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (await exists(paths.blockedSentinel)) {
|
|
415
|
+
throw new Error(`Campaign ${slug} is blocked. Run clean first.`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const state = await readCurrentState(paths, slug, options);
|
|
419
|
+
const usList = await readUsList(paths, slug);
|
|
420
|
+
|
|
421
|
+
if (usList.length === 0) {
|
|
422
|
+
throw new Error(`No user stories found for ${slug}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!state.current_us) {
|
|
426
|
+
state.current_us = getNextUs(usList, state.verified_us, null);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!state.session_name || !state.leader_pane_id) {
|
|
430
|
+
const session = await createSession({
|
|
431
|
+
sessionName: options.sessionName ?? `rlp-${slug}`,
|
|
432
|
+
workingDir: rootDir,
|
|
433
|
+
});
|
|
434
|
+
state.session_name = session.sessionName;
|
|
435
|
+
state.leader_pane_id = session.leaderPaneId;
|
|
436
|
+
state.worker_pane_id = await createPane({
|
|
437
|
+
targetPaneId: session.leaderPaneId,
|
|
438
|
+
layout: 'horizontal',
|
|
439
|
+
});
|
|
440
|
+
state.verifier_pane_id = await createPane({
|
|
441
|
+
targetPaneId: session.leaderPaneId,
|
|
442
|
+
layout: 'vertical',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let fixContractPath = null;
|
|
447
|
+
|
|
448
|
+
while (state.iteration <= maxIterations) {
|
|
449
|
+
state.current_us = getNextUs(usList, state.verified_us, state.current_us);
|
|
450
|
+
if (state.current_us === 'ALL') {
|
|
451
|
+
const finalResult = await runFinalSequentialVerify({
|
|
452
|
+
paths,
|
|
453
|
+
state,
|
|
454
|
+
usList,
|
|
455
|
+
sendKeys,
|
|
456
|
+
verifierPaneId: state.verifier_pane_id,
|
|
457
|
+
pollForSignal,
|
|
458
|
+
runIntegrationCheck,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (finalResult.status === 'complete') {
|
|
462
|
+
state.phase = 'complete';
|
|
463
|
+
await writeSentinel(paths.completeSentinel, 'complete', 'ALL');
|
|
464
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
465
|
+
await generateCampaignReport({
|
|
466
|
+
slug,
|
|
467
|
+
reportFile: paths.reportFile,
|
|
468
|
+
prdFile: paths.prdFile,
|
|
469
|
+
statusFile: paths.statusFile,
|
|
470
|
+
analyticsFile: paths.analyticsFile,
|
|
471
|
+
now: resolveNow(options.now),
|
|
472
|
+
});
|
|
473
|
+
return {
|
|
474
|
+
status: 'complete',
|
|
475
|
+
usId: 'ALL',
|
|
476
|
+
statusFile: paths.statusFile,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
state.phase = 'worker';
|
|
481
|
+
state.current_us = finalResult.usId;
|
|
482
|
+
fixContractPath = path.join(paths.campaignLogDir, `iter-${String(state.iteration).padStart(3, '0')}.fix-contract.md`);
|
|
483
|
+
await writePromptFile(fixContractPath, buildFixContract(finalResult.verdict));
|
|
484
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
485
|
+
return {
|
|
486
|
+
status: 'continue',
|
|
487
|
+
usId: finalResult.usId,
|
|
488
|
+
statusFile: paths.statusFile,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
state.phase = 'worker';
|
|
493
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
494
|
+
await dispatchWorker({
|
|
495
|
+
iteration: state.iteration,
|
|
496
|
+
paths,
|
|
497
|
+
slug,
|
|
498
|
+
usList,
|
|
499
|
+
state,
|
|
500
|
+
sendKeys,
|
|
501
|
+
workerPaneId: state.worker_pane_id,
|
|
502
|
+
fixContractPath,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
let signal;
|
|
506
|
+
try {
|
|
507
|
+
signal = await pollForSignal(paths.signalFile, {
|
|
508
|
+
mode: parseModelFlag(state.worker_model).engine,
|
|
509
|
+
paneId: state.worker_pane_id,
|
|
510
|
+
});
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error instanceof TimeoutError && parseModelFlag(state.worker_model).engine === 'codex') {
|
|
513
|
+
signal = {
|
|
514
|
+
iteration: state.iteration,
|
|
515
|
+
status: 'verify',
|
|
516
|
+
us_id: state.current_us,
|
|
517
|
+
summary: 'auto-generated after codex exit fallback',
|
|
518
|
+
};
|
|
519
|
+
} else {
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const usId = signal.us_id ?? state.current_us;
|
|
525
|
+
const verifierModel = deriveVerifierModel(usId, options);
|
|
526
|
+
state.phase = 'verifier';
|
|
527
|
+
state.verifier_model = options.verifierModel ?? 'sonnet';
|
|
528
|
+
state.final_verifier_model = options.finalVerifierModel ?? 'opus';
|
|
529
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
530
|
+
await dispatchVerifier({
|
|
531
|
+
iteration: state.iteration,
|
|
532
|
+
paths,
|
|
533
|
+
state,
|
|
534
|
+
usId,
|
|
535
|
+
sendKeys,
|
|
536
|
+
verifierPaneId: state.verifier_pane_id,
|
|
537
|
+
verifierModel,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const verdict = await pollForSignal(paths.verdictFile, {
|
|
541
|
+
mode: parseModelFlag(verifierModel, 'verifier').engine,
|
|
542
|
+
paneId: state.verifier_pane_id,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
if (verdict.verdict === 'pass') {
|
|
546
|
+
state.consecutive_failures = 0;
|
|
547
|
+
if (!state.verified_us.includes(usId)) {
|
|
548
|
+
state.verified_us.push(usId);
|
|
549
|
+
}
|
|
550
|
+
state.current_us = getNextUs(usList, state.verified_us, null);
|
|
551
|
+
fixContractPath = null;
|
|
552
|
+
await appendIterationAnalytics(paths, state, usId, 'pass', options);
|
|
553
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
554
|
+
|
|
555
|
+
if (state.verified_us.length === usList.length) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
state.iteration += 1;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (verdict.verdict === 'blocked') {
|
|
564
|
+
state.phase = 'blocked';
|
|
565
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', usId);
|
|
566
|
+
await appendIterationAnalytics(paths, state, usId, 'blocked', options);
|
|
567
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
568
|
+
await generateCampaignReport({
|
|
569
|
+
slug,
|
|
570
|
+
reportFile: paths.reportFile,
|
|
571
|
+
prdFile: paths.prdFile,
|
|
572
|
+
statusFile: paths.statusFile,
|
|
573
|
+
analyticsFile: paths.analyticsFile,
|
|
574
|
+
now: resolveNow(options.now),
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
status: 'blocked',
|
|
578
|
+
usId,
|
|
579
|
+
statusFile: paths.statusFile,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
state.consecutive_failures += 1;
|
|
584
|
+
await appendIterationAnalytics(paths, state, usId, 'fail', options);
|
|
585
|
+
const upgradedModel = nextWorkerModel(options.workerModel ?? state.worker_model, state.consecutive_failures);
|
|
586
|
+
if (upgradedModel === 'BLOCKED') {
|
|
587
|
+
state.phase = 'blocked';
|
|
588
|
+
await writeSentinel(paths.blockedSentinel, 'blocked', usId);
|
|
589
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
590
|
+
await generateCampaignReport({
|
|
591
|
+
slug,
|
|
592
|
+
reportFile: paths.reportFile,
|
|
593
|
+
prdFile: paths.prdFile,
|
|
594
|
+
statusFile: paths.statusFile,
|
|
595
|
+
analyticsFile: paths.analyticsFile,
|
|
596
|
+
now: resolveNow(options.now),
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
status: 'blocked',
|
|
600
|
+
usId,
|
|
601
|
+
statusFile: paths.statusFile,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
state.worker_model = upgradedModel;
|
|
606
|
+
state.current_us = usId;
|
|
607
|
+
fixContractPath = path.join(paths.campaignLogDir, `iter-${String(state.iteration).padStart(3, '0')}.fix-contract.md`);
|
|
608
|
+
await writePromptFile(fixContractPath, buildFixContract(verdict));
|
|
609
|
+
state.phase = 'worker';
|
|
610
|
+
await writeStatus(paths, state, options.onStatusChange, options.now);
|
|
611
|
+
state.iteration += 1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
status: 'continue',
|
|
616
|
+
usId: state.current_us,
|
|
617
|
+
statusFile: paths.statusFile,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export async function initAndRun(slug, objective, options = {}) {
|
|
622
|
+
await initCampaign(slug, objective, options);
|
|
623
|
+
return run(slug, options);
|
|
624
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ensureProjectPath } from './paths.mjs';
|
|
5
|
+
|
|
6
|
+
export async function writeFileAtomic(targetPath, content) {
|
|
7
|
+
const normalizedTargetPath = ensureProjectPath(targetPath);
|
|
8
|
+
const targetDirectory = path.dirname(normalizedTargetPath);
|
|
9
|
+
const tmpPath = path.join(
|
|
10
|
+
targetDirectory,
|
|
11
|
+
`.${path.basename(normalizedTargetPath)}.${process.pid}.${Date.now()}.tmp`,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
await fs.mkdir(targetDirectory, { recursive: true });
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await fs.writeFile(tmpPath, content);
|
|
18
|
+
await fs.rename(tmpPath, normalizedTargetPath);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
await fs.rm(tmpPath, { force: true });
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
5
|
+
const currentDir = path.dirname(currentFile);
|
|
6
|
+
|
|
7
|
+
export const projectRoot = path.resolve(currentDir, '..', '..', '..');
|
|
8
|
+
|
|
9
|
+
export function ensureProjectPath(targetPath) {
|
|
10
|
+
const normalizedPath = path.resolve(targetPath);
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
normalizedPath !== projectRoot &&
|
|
14
|
+
!normalizedPath.startsWith(`${projectRoot}${path.sep}`)
|
|
15
|
+
) {
|
|
16
|
+
throw new Error(`Path is outside the project root: ${targetPath}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return normalizedPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveProjectPath(...segments) {
|
|
23
|
+
if (segments.length === 0) {
|
|
24
|
+
return projectRoot;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return ensureProjectPath(path.resolve(projectRoot, ...segments));
|
|
28
|
+
}
|