@ai-dev-methodologies/rlp-desk 0.7.4 → 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.
@@ -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
+ }
@@ -0,0 +1,234 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { initCampaign } from './init/campaign-initializer.mjs';
5
+ import { readStatus } from './reporting/campaign-reporting.mjs';
6
+ import { run as runCampaignMain } from './runner/campaign-main-loop.mjs';
7
+
8
+ const RUN_DEFAULTS = {
9
+ mode: 'agent',
10
+ workerModel: 'haiku',
11
+ verifierModel: 'sonnet',
12
+ finalVerifierModel: 'opus',
13
+ consensusMode: 'off',
14
+ consensusModel: 'gpt-5.4:medium',
15
+ finalConsensusModel: 'gpt-5.4:high',
16
+ verifyMode: 'per-us',
17
+ cbThreshold: 6,
18
+ maxIterations: 100,
19
+ iterTimeout: 600,
20
+ debug: false,
21
+ lockWorkerModel: false,
22
+ autonomous: false,
23
+ withSelfVerification: false,
24
+ };
25
+
26
+ function write(stream, value) {
27
+ stream.write(value.endsWith('\n') ? value : `${value}\n`);
28
+ }
29
+
30
+ function buildHelpText() {
31
+ return [
32
+ 'Usage:',
33
+ ' node src/node/run.mjs <command> [args] [options]',
34
+ '',
35
+ 'Commands:',
36
+ ' brainstorm <description> Plan before init (not implemented in the Node rewrite yet)',
37
+ ' init <slug> [objective] Create project scaffold',
38
+ ' run <slug> [options] Run loop (agent=LLM leader, tmux=shell leader)',
39
+ ' status <slug> Show loop status',
40
+ ' logs <slug> [N] Show iteration log (not implemented in the Node rewrite yet)',
41
+ ' clean <slug> [--kill-session] Reset for re-run (not implemented in the Node rewrite yet)',
42
+ ' resume <slug> Resume loop (not implemented in the Node rewrite yet)',
43
+ '',
44
+ 'Run Options:',
45
+ ' --mode agent|tmux',
46
+ ' --worker-model MODEL',
47
+ ' --lock-worker-model',
48
+ ' --verifier-model MODEL',
49
+ ' --final-verifier-model MODEL',
50
+ ' --consensus off|all|final-only',
51
+ ' --consensus-model MODEL',
52
+ ' --final-consensus-model MODEL',
53
+ ' --verify-mode per-us|batch',
54
+ ' --cb-threshold N',
55
+ ' --max-iter N',
56
+ ' --iter-timeout N',
57
+ ' --debug',
58
+ ' --autonomous',
59
+ ' --with-self-verification',
60
+ ' --help',
61
+ ].join('\n');
62
+ }
63
+
64
+ function consumeValue(args, index, flag) {
65
+ const value = args[index + 1];
66
+ if (!value || value.startsWith('--')) {
67
+ throw new Error(`missing value for ${flag}`);
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function parseInteger(value, flag) {
73
+ const parsed = Number.parseInt(value, 10);
74
+ if (!Number.isInteger(parsed) || parsed < 0) {
75
+ throw new Error(`${flag} must be a non-negative integer`);
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ function parseRunOptions(args, cwd) {
81
+ const options = {
82
+ rootDir: cwd,
83
+ ...RUN_DEFAULTS,
84
+ };
85
+
86
+ for (let index = 0; index < args.length; index += 1) {
87
+ const token = args[index];
88
+ switch (token) {
89
+ case '--mode':
90
+ options.mode = consumeValue(args, index, token);
91
+ index += 1;
92
+ break;
93
+ case '--worker-model':
94
+ options.workerModel = consumeValue(args, index, token);
95
+ index += 1;
96
+ break;
97
+ case '--lock-worker-model':
98
+ options.lockWorkerModel = true;
99
+ break;
100
+ case '--verifier-model':
101
+ options.verifierModel = consumeValue(args, index, token);
102
+ index += 1;
103
+ break;
104
+ case '--final-verifier-model':
105
+ options.finalVerifierModel = consumeValue(args, index, token);
106
+ index += 1;
107
+ break;
108
+ case '--consensus':
109
+ options.consensusMode = consumeValue(args, index, token);
110
+ index += 1;
111
+ break;
112
+ case '--consensus-model':
113
+ options.consensusModel = consumeValue(args, index, token);
114
+ index += 1;
115
+ break;
116
+ case '--final-consensus-model':
117
+ options.finalConsensusModel = consumeValue(args, index, token);
118
+ index += 1;
119
+ break;
120
+ case '--verify-mode':
121
+ options.verifyMode = consumeValue(args, index, token);
122
+ index += 1;
123
+ break;
124
+ case '--cb-threshold':
125
+ options.cbThreshold = parseInteger(consumeValue(args, index, token), token);
126
+ index += 1;
127
+ break;
128
+ case '--max-iter':
129
+ options.maxIterations = parseInteger(consumeValue(args, index, token), token);
130
+ index += 1;
131
+ break;
132
+ case '--iter-timeout':
133
+ options.iterTimeout = parseInteger(consumeValue(args, index, token), token);
134
+ index += 1;
135
+ break;
136
+ case '--debug':
137
+ options.debug = true;
138
+ break;
139
+ case '--autonomous':
140
+ options.autonomous = true;
141
+ break;
142
+ case '--with-self-verification':
143
+ options.withSelfVerification = true;
144
+ break;
145
+ default:
146
+ throw new Error(`unknown option: ${token}`);
147
+ }
148
+ }
149
+
150
+ return options;
151
+ }
152
+
153
+ async function runInit(args, deps) {
154
+ if (args.length === 0 || args[0] === '--help') {
155
+ write(deps.stdout, 'Usage: node src/node/run.mjs init <slug> [objective]');
156
+ return 0;
157
+ }
158
+
159
+ const slug = args[0];
160
+ const objective = args.slice(1).join(' ').trim() || 'TBD - fill in the objective';
161
+ await deps.initCampaign(slug, objective, { rootDir: deps.cwd });
162
+ write(deps.stdout, `Initialized ${slug} in ${path.join(deps.cwd, '.claude', 'ralph-desk')}`);
163
+ return 0;
164
+ }
165
+
166
+ async function runStatusCommand(args, deps) {
167
+ if (args.length === 0 || args[0] === '--help') {
168
+ write(deps.stdout, 'Usage: node src/node/run.mjs status <slug>');
169
+ return 0;
170
+ }
171
+
172
+ write(deps.stdout, await deps.readStatus(args[0], { rootDir: deps.cwd }));
173
+ return 0;
174
+ }
175
+
176
+ async function runRunCommand(args, deps) {
177
+ if (args.length === 0) {
178
+ throw new Error('run requires a slug');
179
+ }
180
+
181
+ if (args[0] === '--help') {
182
+ write(deps.stdout, buildHelpText());
183
+ return 0;
184
+ }
185
+
186
+ const slug = args[0];
187
+ const options = parseRunOptions(args.slice(1), deps.cwd);
188
+ await deps.runCampaign(slug, options);
189
+ write(deps.stdout, `Campaign started for ${slug}`);
190
+ return 0;
191
+ }
192
+
193
+ export async function main(argv = process.argv.slice(2), overrides = {}) {
194
+ const deps = {
195
+ cwd: overrides.cwd ?? process.cwd(),
196
+ stdout: overrides.stdout ?? process.stdout,
197
+ stderr: overrides.stderr ?? process.stderr,
198
+ initCampaign: overrides.initCampaign ?? initCampaign,
199
+ readStatus: overrides.readStatus ?? readStatus,
200
+ runCampaign: overrides.runCampaign ?? runCampaignMain,
201
+ };
202
+
203
+ try {
204
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
205
+ write(deps.stdout, buildHelpText());
206
+ return 0;
207
+ }
208
+
209
+ const [command, ...rest] = argv;
210
+ switch (command) {
211
+ case 'init':
212
+ return await runInit(rest, deps);
213
+ case 'run':
214
+ return await runRunCommand(rest, deps);
215
+ case 'status':
216
+ return await runStatusCommand(rest, deps);
217
+ case 'brainstorm':
218
+ case 'logs':
219
+ case 'clean':
220
+ case 'resume':
221
+ throw new Error(`${command} is not implemented in the Node rewrite yet`);
222
+ default:
223
+ throw new Error(`unknown command: ${command}. Run with --help to see available commands.`);
224
+ }
225
+ } catch (error) {
226
+ write(deps.stderr, error.message);
227
+ return 1;
228
+ }
229
+ }
230
+
231
+ if (process.argv[1] && path.basename(process.argv[1]) === path.basename(fileURLToPath(import.meta.url))) {
232
+ const exitCode = await main();
233
+ process.exitCode = exitCode;
234
+ }