@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,235 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const GITIGNORE_MARKER = '# RLP Desk runtime artifacts';
5
+ const GITIGNORE_RULE = '.claude/ralph-desk/';
6
+
7
+ export async function initCampaign(slug, objective, options = {}) {
8
+ const normalizedSlug = normalizeSlug(slug);
9
+ const normalizedObjective = objective?.trim() || 'TBD - fill in the objective';
10
+ const mode = options.mode ?? 'agent';
11
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
12
+ const tmuxEnv = options.tmuxEnv ?? process.env.TMUX ?? '';
13
+ const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
14
+
15
+ if (mode === 'tmux' && !tmuxEnv) {
16
+ throw new Error('tmux required');
17
+ }
18
+
19
+ if (mode === 'fresh') {
20
+ await fs.rm(deskRoot, { recursive: true, force: true });
21
+ }
22
+
23
+ const paths = buildPaths(rootDir, normalizedSlug);
24
+ await ensureDirectories(paths);
25
+ await ensureGitignore(rootDir);
26
+
27
+ await writeIfMissing(paths.workerPrompt, buildWorkerPrompt(normalizedSlug, normalizedObjective));
28
+ await writeIfMissing(paths.verifierPrompt, buildVerifierPrompt(normalizedSlug));
29
+ await writeIfMissing(paths.contextFile, buildContext(normalizedSlug));
30
+ await writeIfMissing(paths.memoryFile, buildMemory(normalizedSlug, normalizedObjective));
31
+
32
+ const prdContent = options.prdContent ?? buildPrd(normalizedSlug, normalizedObjective);
33
+ await fs.writeFile(paths.prdFile, prdContent, 'utf8');
34
+ await writeIfMissing(paths.testSpecFile, buildTestSpec(normalizedSlug));
35
+ await splitPrdByUs(paths.plansDir, normalizedSlug, prdContent, normalizedObjective);
36
+
37
+ return {
38
+ slug: normalizedSlug,
39
+ paths,
40
+ };
41
+ }
42
+
43
+ export const init = initCampaign;
44
+
45
+ function normalizeSlug(value) {
46
+ const slug = (value ?? '')
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, '-')
49
+ .replace(/^-+|-+$/g, '');
50
+
51
+ if (!slug) {
52
+ throw new Error('slug is required');
53
+ }
54
+
55
+ return slug;
56
+ }
57
+
58
+ function buildPaths(rootDir, slug) {
59
+ const deskRoot = path.join(rootDir, '.claude', 'ralph-desk');
60
+ const promptsDir = path.join(deskRoot, 'prompts');
61
+ const plansDir = path.join(deskRoot, 'plans');
62
+ const memosDir = path.join(deskRoot, 'memos');
63
+ const logsDir = path.join(deskRoot, 'logs');
64
+ const contextDir = path.join(deskRoot, 'context');
65
+
66
+ return {
67
+ deskRoot,
68
+ promptsDir,
69
+ plansDir,
70
+ memosDir,
71
+ logsDir,
72
+ contextDir,
73
+ workerPrompt: path.join(promptsDir, `${slug}.worker.prompt.md`),
74
+ verifierPrompt: path.join(promptsDir, `${slug}.verifier.prompt.md`),
75
+ contextFile: path.join(contextDir, `${slug}-latest.md`),
76
+ memoryFile: path.join(memosDir, `${slug}-memory.md`),
77
+ prdFile: path.join(plansDir, `prd-${slug}.md`),
78
+ testSpecFile: path.join(plansDir, `test-spec-${slug}.md`),
79
+ campaignLogDir: path.join(logsDir, slug),
80
+ };
81
+ }
82
+
83
+ async function ensureDirectories(paths) {
84
+ await Promise.all(
85
+ [
86
+ paths.promptsDir,
87
+ paths.plansDir,
88
+ paths.memosDir,
89
+ paths.logsDir,
90
+ paths.contextDir,
91
+ paths.campaignLogDir,
92
+ ].map((directory) => fs.mkdir(directory, { recursive: true })),
93
+ );
94
+ }
95
+
96
+ async function ensureGitignore(rootDir) {
97
+ const gitignorePath = path.join(rootDir, '.gitignore');
98
+ let content = '';
99
+
100
+ try {
101
+ content = await fs.readFile(gitignorePath, 'utf8');
102
+ } catch (error) {
103
+ if (error.code !== 'ENOENT') {
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ if (content.includes(GITIGNORE_MARKER) && content.includes(GITIGNORE_RULE)) {
109
+ return;
110
+ }
111
+
112
+ const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
113
+ const block = `${prefix}${GITIGNORE_MARKER}\n${GITIGNORE_RULE}\n`;
114
+ await fs.writeFile(gitignorePath, `${content}${block}`, 'utf8');
115
+ }
116
+
117
+ async function writeIfMissing(targetPath, content) {
118
+ try {
119
+ await fs.access(targetPath);
120
+ } catch (error) {
121
+ if (error.code !== 'ENOENT') {
122
+ throw error;
123
+ }
124
+ await fs.writeFile(targetPath, content, 'utf8');
125
+ }
126
+ }
127
+
128
+ function buildWorkerPrompt(slug, objective) {
129
+ return `Execute the plan for ${slug}.\n\n## Objective\n${objective}\n`;
130
+ }
131
+
132
+ function buildVerifierPrompt(slug) {
133
+ return `Independent verifier for Ralph Desk: ${slug}\n`;
134
+ }
135
+
136
+ function buildContext(slug) {
137
+ return `# ${slug} - Latest Context\n`;
138
+ }
139
+
140
+ function buildMemory(slug, objective) {
141
+ return `# ${slug} - Campaign Memory\n\n## Stop Status\ncontinue\n\n## Objective\n${objective}\n`;
142
+ }
143
+
144
+ function buildPrd(slug, objective) {
145
+ return `# PRD: ${slug}\n\n## Objective\n${objective}\n`;
146
+ }
147
+
148
+ function buildTestSpec(slug) {
149
+ return `# Test Specification: ${slug}\n`;
150
+ }
151
+
152
+ async function splitPrdByUs(plansDir, slug, prdContent, fallbackObjective) {
153
+ const matches = extractUsSections(prdContent);
154
+ const objectiveBlock = extractObjectiveBlock(prdContent, fallbackObjective);
155
+
156
+ await removeExistingSplitFiles(plansDir, slug);
157
+
158
+ await Promise.all(
159
+ matches.map((section) => {
160
+ const usId = section.match(/^## (US-\d{3}):/m)?.[1];
161
+ if (!usId) {
162
+ return Promise.resolve();
163
+ }
164
+
165
+ const content = `# PRD: ${slug}\n\n${objectiveBlock}\n\n${section}\n`;
166
+ return fs.writeFile(path.join(plansDir, `prd-${slug}-${usId}.md`), content, 'utf8');
167
+ }),
168
+ );
169
+ }
170
+
171
+ async function removeExistingSplitFiles(plansDir, slug) {
172
+ const entries = await fs.readdir(plansDir, { withFileTypes: true });
173
+ const prefix = `prd-${slug}-US-`;
174
+
175
+ await Promise.all(
176
+ entries
177
+ .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.md'))
178
+ .map((entry) => fs.rm(path.join(plansDir, entry.name), { force: true })),
179
+ );
180
+ }
181
+
182
+ function extractObjectiveBlock(prdContent, fallbackObjective) {
183
+ const lines = prdContent.split(/\r?\n/);
184
+ const collected = [];
185
+ let collecting = false;
186
+
187
+ for (const line of lines) {
188
+ if (/^## Objective\s*$/.test(line)) {
189
+ collecting = true;
190
+ collected.push('## Objective');
191
+ continue;
192
+ }
193
+
194
+ if (collecting && /^## US-\d{3}:/.test(line)) {
195
+ break;
196
+ }
197
+
198
+ if (collecting) {
199
+ collected.push(line);
200
+ }
201
+ }
202
+
203
+ const content = collected.join('\n').trim();
204
+ if (content) {
205
+ return content;
206
+ }
207
+
208
+ return `## Objective\n${fallbackObjective}`;
209
+ }
210
+
211
+ function extractUsSections(prdContent) {
212
+ const lines = prdContent.split(/\r?\n/);
213
+ const sections = [];
214
+ let current = [];
215
+
216
+ for (const line of lines) {
217
+ if (/^## US-\d{3}:/.test(line)) {
218
+ if (current.length > 0) {
219
+ sections.push(current.join('\n').trim());
220
+ }
221
+ current = [line];
222
+ continue;
223
+ }
224
+
225
+ if (current.length > 0) {
226
+ current.push(line);
227
+ }
228
+ }
229
+
230
+ if (current.length > 0) {
231
+ sections.push(current.join('\n').trim());
232
+ }
233
+
234
+ return sections;
235
+ }
@@ -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
+ }