@ai-dev-methodologies/rlp-desk 0.7.5 → 0.9.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/docs/plans/validated-snacking-crayon.md +407 -0
- package/package.json +5 -2
- package/scripts/postinstall.js +91 -51
- package/scripts/uninstall.js +18 -9
- package/src/commands/rlp-desk.md +10 -3
- package/src/governance.md +2 -1
- 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,106 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const SHELL_COMMANDS = new Set(['', 'zsh', 'bash', 'sh']);
|
|
8
|
+
|
|
9
|
+
export class TimeoutError extends Error {
|
|
10
|
+
constructor(message, options = {}) {
|
|
11
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
12
|
+
this.name = 'TimeoutError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function defaultReadFile(filePath) {
|
|
17
|
+
return fs.readFile(filePath, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function defaultGetPaneCommand(paneId) {
|
|
21
|
+
const { stdout } = await execFileAsync('tmux', [
|
|
22
|
+
'display-message',
|
|
23
|
+
'-p',
|
|
24
|
+
'-t',
|
|
25
|
+
paneId,
|
|
26
|
+
'#{pane_current_command}',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isMissingFileError(error) {
|
|
33
|
+
return error?.code === 'ENOENT';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isJsonParseError(error) {
|
|
37
|
+
return error instanceof SyntaxError;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deadlineExceeded(deadline) {
|
|
41
|
+
return Date.now() >= deadline;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function waitForPaneExit(paneId, { deadline, pollIntervalMs, getPaneCommand }) {
|
|
45
|
+
while (!deadlineExceeded(deadline)) {
|
|
46
|
+
try {
|
|
47
|
+
const currentCommand = await getPaneCommand(paneId);
|
|
48
|
+
if (SHELL_COMMANDS.has(currentCommand)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Transient tmux lookup failures should not end the poll loop early.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (deadlineExceeded(deadline)) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await delay(pollIntervalMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new TimeoutError(`Timed out waiting for pane ${paneId} to exit after signal detection`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function pollForSignal(
|
|
66
|
+
signalFile,
|
|
67
|
+
{
|
|
68
|
+
mode = 'claude',
|
|
69
|
+
paneId = null,
|
|
70
|
+
pollIntervalMs = 100,
|
|
71
|
+
timeoutMs = 5000,
|
|
72
|
+
readFile = defaultReadFile,
|
|
73
|
+
getPaneCommand = defaultGetPaneCommand,
|
|
74
|
+
} = {},
|
|
75
|
+
) {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
|
|
78
|
+
while (!deadlineExceeded(deadline)) {
|
|
79
|
+
try {
|
|
80
|
+
const rawContent = await readFile(signalFile);
|
|
81
|
+
const parsed = JSON.parse(rawContent);
|
|
82
|
+
|
|
83
|
+
if (mode === 'codex' && paneId) {
|
|
84
|
+
await waitForPaneExit(paneId, {
|
|
85
|
+
deadline,
|
|
86
|
+
pollIntervalMs,
|
|
87
|
+
getPaneCommand,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parsed;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!isMissingFileError(error) && !isJsonParseError(error)) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (deadlineExceeded(deadline)) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await delay(pollIntervalMs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new TimeoutError(`Timed out waiting for valid JSON signal at ${signalFile}`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export class FileNotFoundError extends Error {
|
|
4
|
+
constructor(message, filePath, options = {}) {
|
|
5
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
6
|
+
this.name = 'FileNotFoundError';
|
|
7
|
+
this.path = filePath;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function readRequiredFile(filePath, label) {
|
|
12
|
+
try {
|
|
13
|
+
return await fs.readFile(filePath, 'utf8');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code === 'ENOENT') {
|
|
16
|
+
throw new FileNotFoundError(`${label} not found: ${filePath}`, filePath, {
|
|
17
|
+
cause: error,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fileExists(filePath) {
|
|
25
|
+
if (!filePath) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(filePath);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readOptionalFile(filePath) {
|
|
38
|
+
if (!(await fileExists(filePath))) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return fs.readFile(filePath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractSectionValue(content, heading) {
|
|
46
|
+
if (!content) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
const match = content.match(new RegExp(`^## ${escapedHeading}\\s*$([\\s\\S]*?)(?=^## |\\Z)`, 'm'));
|
|
52
|
+
if (!match) {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return match[1]
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(' ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function injectPerUsPrd(basePrompt, fullPrdPath, perUsPrdPath, hasPerUsPrd) {
|
|
64
|
+
if (!fullPrdPath || !perUsPrdPath || !hasPerUsPrd) {
|
|
65
|
+
return basePrompt;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return basePrompt.split(fullPrdPath).join(perUsPrdPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatVerifiedUs(verifiedUs) {
|
|
72
|
+
return verifiedUs.filter(Boolean).join(',');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getNextUs(usList, verifiedUs) {
|
|
76
|
+
const verified = new Set(verifiedUs);
|
|
77
|
+
return usList.find((usId) => !verified.has(usId)) ?? '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appendAutonomousModeSection(lines, { conflictLogPath, verifier = false }) {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('---');
|
|
83
|
+
lines.push('## AUTONOMOUS MODE');
|
|
84
|
+
lines.push('Do NOT stop or ask questions when encountering ambiguity or document conflicts.');
|
|
85
|
+
lines.push('**Resolution priority**: PRD > test-spec > context > memory');
|
|
86
|
+
lines.push(
|
|
87
|
+
verifier
|
|
88
|
+
? 'If documents disagree, follow PRD and proceed. Log any conflict by'
|
|
89
|
+
: 'If documents disagree, follow PRD and proceed. Log any conflict you find by',
|
|
90
|
+
);
|
|
91
|
+
lines.push(`appending to \`${conflictLogPath}\` in format:`);
|
|
92
|
+
lines.push(
|
|
93
|
+
' {"iteration":N,"us_id":"US-NNN","source_a":"prd","source_b":"test-spec","conflict":"description","resolution":"followed PRD"}',
|
|
94
|
+
);
|
|
95
|
+
lines.push(verifier ? 'Do NOT wait for human input. Keep verifying.' : 'Do NOT wait for human input. Keep working.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function assembleWorkerPrompt({
|
|
99
|
+
promptBase,
|
|
100
|
+
memoryFile,
|
|
101
|
+
iteration,
|
|
102
|
+
verifyMode = 'per-us',
|
|
103
|
+
usList = [],
|
|
104
|
+
verifiedUs = [],
|
|
105
|
+
fullPrdPath = '',
|
|
106
|
+
perUsPrdPath = '',
|
|
107
|
+
fullTestSpecPath = '',
|
|
108
|
+
perUsTestSpecPath = '',
|
|
109
|
+
autonomousMode = false,
|
|
110
|
+
fixContractPath = '',
|
|
111
|
+
conflictLogPath = '',
|
|
112
|
+
} = {}) {
|
|
113
|
+
const basePrompt = await readRequiredFile(promptBase, 'Worker prompt base file');
|
|
114
|
+
const memoryContent = await readOptionalFile(memoryFile);
|
|
115
|
+
const hasPerUsPrd = await fileExists(perUsPrdPath);
|
|
116
|
+
const hasPerUsTestSpec = await fileExists(perUsTestSpecPath);
|
|
117
|
+
const promptLines = [
|
|
118
|
+
injectPerUsPrd(basePrompt, fullPrdPath, perUsPrdPath, hasPerUsPrd),
|
|
119
|
+
'',
|
|
120
|
+
'---',
|
|
121
|
+
'## Iteration Context',
|
|
122
|
+
`- **Iteration**: ${iteration}`,
|
|
123
|
+
`- **Memory Stop Status**: ${extractSectionValue(memoryContent, 'Stop Status') || 'unknown'}`,
|
|
124
|
+
`- **Next Iteration Contract**: ${extractSectionValue(memoryContent, 'Next Iteration Contract') || 'Start from the beginning'}`,
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const fixContractContent = await readOptionalFile(fixContractPath);
|
|
128
|
+
if (fixContractContent !== null) {
|
|
129
|
+
promptLines.push('');
|
|
130
|
+
promptLines.push('---');
|
|
131
|
+
promptLines.push(`## IMPORTANT: Fix Contract from Verifier (iteration ${iteration - 1})`);
|
|
132
|
+
promptLines.push('The Verifier REJECTED your previous work. You MUST fix the issues below.');
|
|
133
|
+
promptLines.push('Do NOT just resubmit — actually change the code to address each issue.');
|
|
134
|
+
promptLines.push('');
|
|
135
|
+
promptLines.push(fixContractContent.trimEnd());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (verifyMode === 'per-us' && usList.length > 0) {
|
|
139
|
+
const nextUs = getNextUs(usList, verifiedUs);
|
|
140
|
+
if (nextUs) {
|
|
141
|
+
promptLines.push('');
|
|
142
|
+
promptLines.push('---');
|
|
143
|
+
promptLines.push('## PER-US SCOPE LOCK (this iteration) — OVERRIDES memory contract');
|
|
144
|
+
promptLines.push("**IGNORE the 'Next Iteration Contract' from memory if it references a different story.**");
|
|
145
|
+
promptLines.push(`The Leader has determined that **${nextUs}** is the next unverified story.`);
|
|
146
|
+
promptLines.push(`You MUST implement ONLY **${nextUs}** in this iteration.`);
|
|
147
|
+
promptLines.push('Do NOT implement any other user stories.');
|
|
148
|
+
if (hasPerUsTestSpec) {
|
|
149
|
+
promptLines.push(`- **Test Spec**: Read ONLY \`${perUsTestSpecPath}\` (scoped to ${nextUs})`);
|
|
150
|
+
} else {
|
|
151
|
+
promptLines.push(`- **Test Spec**: Read \`${fullTestSpecPath}\` (full — find ${nextUs} section)`);
|
|
152
|
+
}
|
|
153
|
+
promptLines.push(`When done, signal verify with us_id="${nextUs}" (not "ALL").`);
|
|
154
|
+
promptLines.push(`Signal format: {"iteration": N, "status": "verify", "us_id": "${nextUs}", ...}`);
|
|
155
|
+
promptLines.push('');
|
|
156
|
+
promptLines.push(`**Update the campaign memory's 'Next Iteration Contract' to reflect ${nextUs}.**`);
|
|
157
|
+
} else if (verifiedUs.length > 0) {
|
|
158
|
+
promptLines.push('');
|
|
159
|
+
promptLines.push('---');
|
|
160
|
+
promptLines.push('## FINAL VERIFICATION ITERATION');
|
|
161
|
+
promptLines.push(`All individual US have been verified: ${formatVerifiedUs(verifiedUs)}`);
|
|
162
|
+
promptLines.push('Run all tests and verification commands to confirm everything works together.');
|
|
163
|
+
promptLines.push('Signal verify with us_id="ALL" for the final full verification.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (autonomousMode) {
|
|
168
|
+
appendAutonomousModeSection(promptLines, { conflictLogPath });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return `${promptLines.join('\n')}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function assembleVerifierPrompt({
|
|
175
|
+
promptBase,
|
|
176
|
+
iteration,
|
|
177
|
+
doneClaimFile,
|
|
178
|
+
verifyMode = 'per-us',
|
|
179
|
+
usId = '',
|
|
180
|
+
verifiedUs = [],
|
|
181
|
+
autonomousMode = false,
|
|
182
|
+
conflictLogPath = '',
|
|
183
|
+
} = {}) {
|
|
184
|
+
const basePrompt = await readRequiredFile(promptBase, 'Verifier prompt base file');
|
|
185
|
+
const promptLines = [
|
|
186
|
+
basePrompt.trimEnd(),
|
|
187
|
+
'',
|
|
188
|
+
'---',
|
|
189
|
+
'## Verification Context',
|
|
190
|
+
`- **Iteration**: ${iteration}`,
|
|
191
|
+
`- **Done Claim**: ${doneClaimFile}`,
|
|
192
|
+
`- **Verify Mode**: ${verifyMode}`,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
if (usId) {
|
|
196
|
+
if (usId === 'ALL') {
|
|
197
|
+
promptLines.push('- **Scope**: FULL VERIFY — check ALL acceptance criteria from the PRD');
|
|
198
|
+
} else {
|
|
199
|
+
promptLines.push(`- **Scope**: Verify ONLY the acceptance criteria for **${usId}**`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (verifiedUs.length > 0) {
|
|
203
|
+
promptLines.push(`- **Previously verified US**: ${formatVerifiedUs(verifiedUs)}`);
|
|
204
|
+
promptLines.push('- **Note**: Skip re-verifying the above US. Focus on unverified stories.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (autonomousMode) {
|
|
209
|
+
appendAutonomousModeSection(promptLines, { conflictLogPath, verifier: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return `${promptLines.join('\n')}\n`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const REQUIRED_ANALYTICS_FIELDS = [
|
|
8
|
+
'iter',
|
|
9
|
+
'us_id',
|
|
10
|
+
'worker_model',
|
|
11
|
+
'worker_engine',
|
|
12
|
+
'verdict',
|
|
13
|
+
'duration',
|
|
14
|
+
'timestamp',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
async function exists(targetPath) {
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(targetPath);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asDate(value) {
|
|
27
|
+
if (value instanceof Date) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return value ? new Date(value) : new Date();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatElapsedSeconds(from, to) {
|
|
35
|
+
const elapsedMs = Math.max(0, asDate(to).getTime() - asDate(from).getTime());
|
|
36
|
+
return `${Math.floor(elapsedMs / 1000)}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function analyticsVersionPath(targetPath, version) {
|
|
40
|
+
return targetPath.replace(/\.jsonl$/u, `-v${version}.jsonl`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function reportVersionPath(targetPath, version) {
|
|
44
|
+
return targetPath.replace(/\.md$/u, `-v${version}.md`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function versionFile(targetPath, nextPathForVersion) {
|
|
48
|
+
if (!(await exists(targetPath))) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let version = 1;
|
|
53
|
+
while (await exists(nextPathForVersion(targetPath, version))) {
|
|
54
|
+
version += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const versionedPath = nextPathForVersion(targetPath, version);
|
|
58
|
+
await fs.rename(targetPath, versionedPath);
|
|
59
|
+
return versionedPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readJsonIfExists(targetPath) {
|
|
63
|
+
if (!(await exists(targetPath))) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return JSON.parse(await fs.readFile(targetPath, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readAnalytics(analyticsFile) {
|
|
71
|
+
if (!(await exists(analyticsFile))) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const content = await fs.readFile(analyticsFile, 'utf8');
|
|
76
|
+
return content
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.map((line) => JSON.parse(line));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractObjective(prdContent) {
|
|
83
|
+
const match = prdContent.match(/^## Objective\s*([\s\S]*?)(?:^## |\s*$)/m);
|
|
84
|
+
return match?.[1]?.trim() ?? '(PRD objective not found)';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractUsList(prdContent) {
|
|
88
|
+
return [...prdContent.matchAll(/^## (US-\d{3}):/gm)].map((match) => match[1]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function summarizeUsStatus(usList, status) {
|
|
92
|
+
const verified = new Set(status.verified_us ?? []);
|
|
93
|
+
return usList.length === 0
|
|
94
|
+
? ['- None']
|
|
95
|
+
: usList.map((usId) => `- ${usId}: ${verified.has(usId) ? 'verified' : 'pending'}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarizeVerificationResults(records) {
|
|
99
|
+
return records.length === 0
|
|
100
|
+
? ['- None']
|
|
101
|
+
: records.map((record) => `- iter ${record.iter}: ${record.us_id} -> ${record.verdict}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function summarizeIssues(reportDir) {
|
|
105
|
+
const entries = await fs.readdir(reportDir, { withFileTypes: true }).catch(() => []);
|
|
106
|
+
const fixContracts = entries
|
|
107
|
+
.filter((entry) => entry.isFile() && /^iter-\d+\.fix-contract\.md$/u.test(entry.name))
|
|
108
|
+
.map((entry) => `- ${entry.name}`)
|
|
109
|
+
.sort();
|
|
110
|
+
|
|
111
|
+
return fixContracts.length > 0 ? fixContracts : ['- None'];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function summarizeCost(records) {
|
|
115
|
+
if (records.length === 0) {
|
|
116
|
+
return ['- No cost data available', '- Total duration: 0s'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const totalDuration = records.reduce((sum, record) => sum + Number(record.duration ?? 0), 0);
|
|
120
|
+
return [
|
|
121
|
+
`- Iteration records: ${records.length}`,
|
|
122
|
+
`- Total duration: ${totalDuration}s`,
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function defaultGitDiffProvider({ cwd }) {
|
|
127
|
+
try {
|
|
128
|
+
const { stdout } = await execFileAsync('git', ['diff', '--stat', 'HEAD'], { cwd });
|
|
129
|
+
return stdout.trim();
|
|
130
|
+
} catch {
|
|
131
|
+
return '(git diff unavailable)';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function prepareCampaignAnalytics({ analyticsFile, statusFile }) {
|
|
136
|
+
await fs.mkdir(path.dirname(analyticsFile), { recursive: true });
|
|
137
|
+
if (!(await exists(analyticsFile))) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (await exists(statusFile)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return versionFile(analyticsFile, analyticsVersionPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function appendCampaignAnalytics(analyticsFile, record) {
|
|
149
|
+
for (const field of REQUIRED_ANALYTICS_FIELDS) {
|
|
150
|
+
if (record[field] === undefined || record[field] === null || record[field] === '') {
|
|
151
|
+
throw new Error(`analytics record is missing required field: ${field}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await fs.mkdir(path.dirname(analyticsFile), { recursive: true });
|
|
156
|
+
await fs.appendFile(analyticsFile, `${JSON.stringify(record)}\n`, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function generateCampaignReport({
|
|
160
|
+
slug,
|
|
161
|
+
reportFile,
|
|
162
|
+
prdFile,
|
|
163
|
+
statusFile,
|
|
164
|
+
analyticsFile,
|
|
165
|
+
now = new Date(),
|
|
166
|
+
gitDiffProvider = defaultGitDiffProvider,
|
|
167
|
+
svSummary = 'N/A — --with-self-verification not enabled',
|
|
168
|
+
}) {
|
|
169
|
+
await fs.mkdir(path.dirname(reportFile), { recursive: true });
|
|
170
|
+
await versionFile(reportFile, reportVersionPath);
|
|
171
|
+
|
|
172
|
+
const prdContent = (await fs.readFile(prdFile, 'utf8').catch(() => ''));
|
|
173
|
+
const status = (await readJsonIfExists(statusFile)) ?? {
|
|
174
|
+
slug,
|
|
175
|
+
iteration: 0,
|
|
176
|
+
max_iterations: 100,
|
|
177
|
+
phase: 'idle',
|
|
178
|
+
verified_us: [],
|
|
179
|
+
consecutive_failures: 0,
|
|
180
|
+
};
|
|
181
|
+
const records = await readAnalytics(analyticsFile);
|
|
182
|
+
const usList = extractUsList(prdContent);
|
|
183
|
+
const issues = await summarizeIssues(path.dirname(reportFile));
|
|
184
|
+
const filesChanged = await gitDiffProvider({ cwd: path.dirname(path.dirname(path.dirname(reportFile))) });
|
|
185
|
+
const terminalState = String(status.phase ?? 'timeout').toUpperCase();
|
|
186
|
+
const elapsed = status.started_at_utc
|
|
187
|
+
? formatElapsedSeconds(status.started_at_utc, now)
|
|
188
|
+
: '0s';
|
|
189
|
+
|
|
190
|
+
const lines = [
|
|
191
|
+
`# Campaign Report: ${slug}`,
|
|
192
|
+
'',
|
|
193
|
+
`Generated: ${asDate(now).toISOString()} | Status: ${terminalState} | Iterations: ${status.iteration ?? 0}`,
|
|
194
|
+
'',
|
|
195
|
+
'## Objective',
|
|
196
|
+
extractObjective(prdContent),
|
|
197
|
+
'',
|
|
198
|
+
'## Execution Summary',
|
|
199
|
+
`- Terminal state: ${terminalState}`,
|
|
200
|
+
`- Iterations run: ${status.iteration ?? 0}`,
|
|
201
|
+
`- Elapsed: ${elapsed}`,
|
|
202
|
+
'',
|
|
203
|
+
'## US Status',
|
|
204
|
+
...summarizeUsStatus(usList, status),
|
|
205
|
+
'',
|
|
206
|
+
'## Verification Results',
|
|
207
|
+
...summarizeVerificationResults(records),
|
|
208
|
+
'',
|
|
209
|
+
'## Issues Encountered',
|
|
210
|
+
...issues,
|
|
211
|
+
'',
|
|
212
|
+
'## Cost & Performance',
|
|
213
|
+
...summarizeCost(records),
|
|
214
|
+
'',
|
|
215
|
+
'## SV Summary',
|
|
216
|
+
svSummary,
|
|
217
|
+
'',
|
|
218
|
+
'## Files Changed',
|
|
219
|
+
'```',
|
|
220
|
+
filesChanged || '(no changes)',
|
|
221
|
+
'```',
|
|
222
|
+
'Note: Files Changed may include pre-existing uncommitted changes if the campaign started in a dirty worktree.',
|
|
223
|
+
'',
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
await fs.writeFile(reportFile, `${lines.join('\n')}\n`, 'utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function readStatus(slug, options = {}) {
|
|
230
|
+
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
231
|
+
const statusFile = path.join(rootDir, '.claude', 'ralph-desk', 'logs', slug, 'runtime', 'status.json');
|
|
232
|
+
|
|
233
|
+
if (!(await exists(statusFile))) {
|
|
234
|
+
return `No active campaign for ${slug}.`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let status;
|
|
238
|
+
try {
|
|
239
|
+
status = JSON.parse(await fs.readFile(statusFile, 'utf8'));
|
|
240
|
+
} catch {
|
|
241
|
+
return `Campaign: ${slug}\nstatus.json is corrupt.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const updatedAt = status.updated_at_utc ?? status.started_at_utc ?? asDate(options.now).toISOString();
|
|
245
|
+
const elapsed = formatElapsedSeconds(updatedAt, options.now ?? new Date());
|
|
246
|
+
const verifiedUs = (status.verified_us ?? []).join(', ') || 'none';
|
|
247
|
+
|
|
248
|
+
return [
|
|
249
|
+
`Campaign: ${slug}`,
|
|
250
|
+
`Iteration: ${status.iteration ?? 0} / ${status.max_iterations ?? 100}`,
|
|
251
|
+
`Phase: ${status.phase ?? 'unknown'}`,
|
|
252
|
+
`Worker Model: ${status.worker_model ?? 'unknown'} | Verifier: ${status.verifier_model ?? 'unknown'} (per-US) / ${status.final_verifier_model ?? 'unknown'} (final)`,
|
|
253
|
+
`Verified US: ${verifiedUs}`,
|
|
254
|
+
`Consecutive Failures: ${status.consecutive_failures ?? 0}`,
|
|
255
|
+
`Updated: ${updatedAt} (elapsed: ${elapsed})`,
|
|
256
|
+
].join('\n');
|
|
257
|
+
}
|