@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.
@@ -4,41 +4,19 @@
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
6
  const os = require("os");
7
- const { execSync } = require("child_process");
7
+ const pkg = require(path.join(__dirname, "..", "package.json"));
8
8
 
9
9
  const home = os.homedir();
10
10
  const claudeDir = path.join(home, ".claude");
11
11
  const commandsDir = path.join(claudeDir, "commands");
12
12
  const deskDir = path.join(claudeDir, "ralph-desk");
13
- const pkgDir = path.join(__dirname, "..");
14
- const pkg = require(path.join(pkgDir, "package.json"));
15
-
16
- console.log("");
17
- console.log(" RLP Desk v" + pkg.version);
18
- console.log(" ================");
19
- console.log("");
20
-
21
- // Create directories
22
- fs.mkdirSync(commandsDir, { recursive: true });
23
- fs.mkdirSync(deskDir, { recursive: true });
24
-
25
- // Create docs subdirectories
26
13
  const docsDir = path.join(deskDir, "docs");
27
- const docsInternalDir = path.join(docsDir, "internal");
28
- const docsBlueprintsDir = path.join(docsDir, "blueprints");
29
- fs.mkdirSync(docsInternalDir, { recursive: true });
30
- fs.mkdirSync(docsBlueprintsDir, { recursive: true });
31
-
32
- // Copy files — must match CLAUDE.md "Local File Sync" section exactly
33
- const copies = [
34
- // Runtime files
14
+ const nodeDir = path.join(deskDir, "node");
15
+ const pkgDir = path.join(__dirname, "..");
16
+ const runtimeSources = [
35
17
  ["src/commands/rlp-desk.md", path.join(commandsDir, "rlp-desk.md")],
36
18
  ["src/governance.md", path.join(deskDir, "governance.md")],
37
19
  ["src/model-upgrade-table.md", path.join(deskDir, "model-upgrade-table.md")],
38
- ["src/scripts/init_ralph_desk.zsh", path.join(deskDir, "init_ralph_desk.zsh")],
39
- ["src/scripts/run_ralph_desk.zsh", path.join(deskDir, "run_ralph_desk.zsh")],
40
- ["src/scripts/lib_ralph_desk.zsh", path.join(deskDir, "lib_ralph_desk.zsh")],
41
- // Reference docs
42
20
  ["README.md", path.join(deskDir, "README.md")],
43
21
  ["install.sh", path.join(deskDir, "install.sh")],
44
22
  ["docs/architecture.md", path.join(docsDir, "architecture.md")],
@@ -46,42 +24,104 @@ const copies = [
46
24
  ["docs/protocol-reference.md", path.join(docsDir, "protocol-reference.md")],
47
25
  ["docs/TODO-verification-next.md", path.join(docsDir, "TODO-verification-next.md")],
48
26
  ];
27
+ const legacyFiles = [
28
+ path.join(deskDir, "init_ralph_desk.zsh"),
29
+ path.join(deskDir, "run_ralph_desk.zsh"),
30
+ path.join(deskDir, "lib_ralph_desk.zsh"),
31
+ ];
32
+
33
+ function getNodeVersion() {
34
+ return process.env.RLP_DESK_NODE_VERSION_OVERRIDE || process.version;
35
+ }
36
+
37
+ function isSupportedNodeVersion(version) {
38
+ const match = /^v(\d+)/.exec(version || "");
39
+ return Boolean(match) && Number(match[1]) >= 16;
40
+ }
41
+
42
+ function ensureDir(dirPath) {
43
+ fs.mkdirSync(dirPath, { recursive: true });
44
+ }
45
+
46
+ function removePath(targetPath) {
47
+ fs.rmSync(targetPath, { recursive: true, force: true });
48
+ }
49
+
50
+ function copyFile(sourceRelativePath, targetPath) {
51
+ ensureDir(path.dirname(targetPath));
52
+ fs.copyFileSync(path.join(pkgDir, sourceRelativePath), targetPath);
53
+ console.log(" + " + targetPath);
54
+ }
55
+
56
+ function copyMarkdownDirectory(sourceRelativeDir, targetDir) {
57
+ const sourceDir = path.join(pkgDir, sourceRelativeDir);
58
+ if (!fs.existsSync(sourceDir)) {
59
+ return;
60
+ }
49
61
 
50
- // Copy docs/internal/* and docs/blueprints/* if they exist
51
- for (const subdir of [["docs/internal", docsInternalDir], ["docs/blueprints", docsBlueprintsDir]]) {
52
- const srcDir = path.join(pkgDir, subdir[0]);
53
- if (fs.existsSync(srcDir)) {
54
- for (const file of fs.readdirSync(srcDir)) {
55
- if (file.endsWith(".md")) {
56
- copies.push([path.join(subdir[0], file), path.join(subdir[1], file)]);
57
- }
62
+ ensureDir(targetDir);
63
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
64
+ const sourcePath = path.join(sourceDir, entry.name);
65
+ const targetPath = path.join(targetDir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ copyMarkdownDirectory(path.join(sourceRelativeDir, entry.name), targetPath);
68
+ continue;
69
+ }
70
+ if (entry.isFile() && entry.name.endsWith(".md")) {
71
+ ensureDir(path.dirname(targetPath));
72
+ fs.copyFileSync(sourcePath, targetPath);
73
+ console.log(" + " + targetPath);
58
74
  }
59
75
  }
60
76
  }
61
77
 
62
- for (const [src, dest] of copies) {
63
- fs.copyFileSync(path.join(pkgDir, src), dest);
64
- console.log(" + " + dest);
65
- }
78
+ function copyNodeRuntime(sourceDir, targetDir) {
79
+ removePath(targetDir);
80
+ ensureDir(targetDir);
66
81
 
67
- // Make scripts executable
68
- try {
69
- fs.chmodSync(path.join(deskDir, "init_ralph_desk.zsh"), 0o755);
70
- fs.chmodSync(path.join(deskDir, "run_ralph_desk.zsh"), 0o755);
71
- fs.chmodSync(path.join(deskDir, "lib_ralph_desk.zsh"), 0o755);
72
- } catch (_) {
73
- // chmod may fail on Windows — not critical
82
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
83
+ const sourcePath = path.join(sourceDir, entry.name);
84
+ const targetPath = path.join(targetDir, entry.name);
85
+ if (entry.isDirectory()) {
86
+ copyNodeRuntime(sourcePath, targetPath);
87
+ continue;
88
+ }
89
+ if (entry.isFile()) {
90
+ ensureDir(path.dirname(targetPath));
91
+ fs.copyFileSync(sourcePath, targetPath);
92
+ console.log(" + " + targetPath);
93
+ }
94
+ }
74
95
  }
75
96
 
76
- // Check tmux availability
77
- try {
78
- execSync("which tmux", { stdio: "ignore" });
79
- } catch (_) {
80
- console.log(" [warn] tmux not found. Tmux execution mode (--mode tmux) will not be available.");
81
- console.log(" Install tmux to use lean mode: https://github.com/tmux/tmux/wiki/Installing");
97
+ console.log("");
98
+ console.log(" RLP Desk v" + pkg.version);
99
+ console.log(" ================");
100
+ console.log("");
101
+
102
+ if (!isSupportedNodeVersion(getNodeVersion())) {
103
+ console.log(" [warn] RLP Desk requires Node.js >= 16 for the Node rewrite runtime.");
104
+ console.log(" Existing zsh installation was left unchanged.");
82
105
  console.log("");
106
+ process.exit(0);
83
107
  }
84
108
 
109
+ ensureDir(commandsDir);
110
+ ensureDir(deskDir);
111
+ ensureDir(docsDir);
112
+
113
+ for (const legacyFile of legacyFiles) {
114
+ removePath(legacyFile);
115
+ }
116
+
117
+ for (const [sourcePath, targetPath] of runtimeSources) {
118
+ copyFile(sourcePath, targetPath);
119
+ }
120
+
121
+ copyMarkdownDirectory("docs/internal", path.join(docsDir, "internal"));
122
+ copyMarkdownDirectory("docs/blueprints", path.join(docsDir, "blueprints"));
123
+ copyNodeRuntime(path.join(pkgDir, "src", "node"), nodeDir);
124
+
85
125
  console.log("");
86
126
  console.log(" Done! Open Claude Code and run:");
87
127
  console.log(" /rlp-desk brainstorm \"your task description\"");
@@ -16,22 +16,31 @@ console.log("");
16
16
 
17
17
  const files = [
18
18
  path.join(commandsDir, "rlp-desk.md"),
19
- path.join(deskDir, "init_ralph_desk.zsh"),
20
- path.join(deskDir, "run_ralph_desk.zsh"),
21
- path.join(deskDir, "lib_ralph_desk.zsh"),
22
19
  path.join(deskDir, "governance.md"),
20
+ path.join(deskDir, "model-upgrade-table.md"),
21
+ path.join(deskDir, "README.md"),
22
+ path.join(deskDir, "install.sh"),
23
23
  ];
24
24
 
25
- for (const f of files) {
25
+ for (const targetPath of files) {
26
26
  try {
27
- fs.unlinkSync(f);
28
- console.log(" - " + f);
27
+ fs.rmSync(targetPath, { recursive: true, force: true });
28
+ console.log(" - " + targetPath);
29
29
  } catch (_) {
30
- // File may not exist
30
+ // Ignore missing files.
31
+ }
32
+ }
33
+
34
+ for (const subdir of ["docs", "node"]) {
35
+ const targetPath = path.join(deskDir, subdir);
36
+ try {
37
+ fs.rmSync(targetPath, { recursive: true, force: true });
38
+ console.log(" - " + targetPath);
39
+ } catch (_) {
40
+ // Ignore missing directories.
31
41
  }
32
42
  }
33
43
 
34
- // Remove ralph-desk dir if empty
35
44
  try {
36
45
  const remaining = fs.readdirSync(deskDir);
37
46
  if (remaining.length === 0) {
@@ -39,7 +48,7 @@ try {
39
48
  console.log(" - " + deskDir);
40
49
  }
41
50
  } catch (_) {
42
- // Directory may not exist
51
+ // Directory may not exist.
43
52
  }
44
53
 
45
54
  console.log("");
@@ -23,6 +23,12 @@ Present your suggestion, then wait for the user's confirmation or change.
23
23
  Ask about these items one by one (or in small groups):
24
24
  1. **Slug** — short identifier (e.g., `auth-refactor`). Suggest one, ask if OK.
25
25
  2. **Objective** — what the loop achieves
26
+ 2.5. **Codebase Exploration** — Before proposing user stories, examine the project:
27
+ - Read the project's entry points, key modules, and test structure
28
+ - Identify architectural patterns in use (frameworks, conventions, test setup)
29
+ - Note constraints the Worker will encounter (dependencies, build system, existing code style)
30
+ - Present findings: "I explored the codebase and found: [patterns], [constraints], [existing tests]. This informs the US breakdown below."
31
+ - If the project is new/empty, skip this step and note "greenfield project."
26
32
  3. **User Stories** — discrete units with testable acceptance criteria. Propose a breakdown, ask the user to confirm/modify.
27
33
  - Apply INVEST criteria: each US must be Independent, Negotiable, Valuable, Estimable, Small, Testable.
28
34
  - **Task Sizing (governance §1c)**: Size each US within the Worker's comfortable zone — smaller than what the Worker can handle, not at its ceiling. Max 3-4 ACs, max 2 files. If a US feels "just barely doable" for the target model, split it further.
@@ -128,7 +134,10 @@ Do NOT auto-decide iteration unit — the user MUST explicitly choose.
128
134
  ## `init <slug> [objective]`
129
135
 
130
136
  Run: `~/.claude/ralph-desk/init_ralph_desk.zsh <slug> "<objective>" [--mode fresh|improve]`
131
- If brainstorm was done, auto-fill PRD and test-spec with the results.
137
+ If brainstorm was done, auto-fill:
138
+ - PRD and test-spec with the brainstorm results
139
+ - Campaign memory "Key Decisions" with architectural decisions from brainstorm
140
+ - Campaign memory "Patterns Discovered" with codebase exploration findings (from step 2.5)
132
141
 
133
142
  **After init completes, STOP. Do NOT auto-run the loop.**
134
143
 
@@ -372,7 +381,6 @@ If claude engine (default):
372
381
  ```
373
382
  Agent(
374
383
  description="rlp-desk worker iter-NNN",
375
- subagent_type="executor",
376
384
  model=<worker_model>,
377
385
  mode="bypassPermissions",
378
386
  prompt=<full worker prompt text>
@@ -433,7 +441,6 @@ If claude engine (default):
433
441
  ```
434
442
  Agent(
435
443
  description="rlp-desk verifier iter-NNN (us_id)",
436
- subagent_type="executor",
437
444
  model=<selected_verifier_model>,
438
445
  mode="bypassPermissions",
439
446
  prompt=<full verifier prompt text with US scope>
package/src/governance.md CHANGED
@@ -214,7 +214,7 @@ This is the default behavior, not an optional flag. Without it, IL-1 (Evidence M
214
214
  ### Worker: execution_steps in done-claim.json
215
215
  Worker records what was done, in what order, with command evidence in `done-claim.json`:
216
216
  - Each step includes: what action, which AC, command executed, exit code, summary
217
- - Step types: `write_test`, `verify_red`, `implement`, `verify_green`, `refactor`, `commit`, `verify`, `verify_existing`
217
+ - Step types: `plan`, `write_test`, `verify_red`, `implement`, `verify_green`, `refactor`, `commit`, `verify`, `verify_existing`
218
218
  - This proves the Worker followed test-first approach and did not skip steps
219
219
  - **Existing implementation rule**: When code already exists from a prior iteration/campaign, Worker MAY use `verify_existing` instead of `write_test → verify_red → implement → verify_green`. `verify_existing` requires: run all existing tests, record exit codes, confirm all AC are covered by passing tests. Worker MUST NOT skip recording evidence — `verify_existing` is evidence that existing code satisfies AC, not a shortcut to skip verification.
220
220
 
@@ -267,6 +267,7 @@ Verifier records WHY each judgment was made in `verify-verdict.json`:
267
267
  - Runs commands directly to collect fresh evidence
268
268
  - Campaign Memory is for orientation only — not the source of truth
269
269
  - Writes verdict (`pass` | `fail` | `request_info`) — if uncertain, use `request_info` with specific questions; Leader decides
270
+ - **Verdict output rule**: MUST write verdict JSON as a FILE (not stdout). Leader polls the file path — terminal output is lost. Evidence strings: include key metrics and exit codes only, do NOT quote full command output or logs verbatim.
270
271
  - Delegates deterministic checks (type hints, linting, security) to tools defined in test-spec
271
272
  - Focuses on AC verification, semantic review, and smoke tests
272
273
  - **Must NEVER modify code or write sentinel files**
@@ -0,0 +1,96 @@
1
+ const CLAUDE_BIN = 'claude';
2
+ const CODEX_BIN = 'codex';
3
+ const CLAUDE_MODELS = new Set(['haiku', 'sonnet', 'opus']);
4
+
5
+ function assertTuiMode(mode, builderName) {
6
+ if (mode !== 'tui') {
7
+ throw new Error(`${builderName} unknown mode '${mode}'`);
8
+ }
9
+ }
10
+
11
+ export function buildClaudeCmd(mode, model, options = {}) {
12
+ assertTuiMode(mode, 'buildClaudeCmd');
13
+
14
+ const parts = [
15
+ 'DISABLE_OMC=1',
16
+ CLAUDE_BIN,
17
+ '--model',
18
+ model,
19
+ '--mcp-config',
20
+ '\'{"mcpServers":{}}\'',
21
+ '--strict-mcp-config',
22
+ '--dangerously-skip-permissions',
23
+ ];
24
+
25
+ if (options.effort !== undefined && options.effort !== '') {
26
+ parts.push('--effort', options.effort);
27
+ }
28
+
29
+ return parts.join(' ');
30
+ }
31
+
32
+ export function buildCodexCmd(mode, model, options = {}) {
33
+ assertTuiMode(mode, 'buildCodexCmd');
34
+
35
+ const parts = [
36
+ CODEX_BIN,
37
+ '-m',
38
+ model,
39
+ ];
40
+
41
+ if (options.reasoning !== undefined) {
42
+ parts.push('-c', `model_reasoning_effort="${options.reasoning}"`);
43
+ }
44
+
45
+ parts.push('--disable', 'plugins', '--dangerously-bypass-approvals-and-sandbox');
46
+
47
+ return parts.join(' ');
48
+ }
49
+
50
+ export function parseModelFlag(value, role = 'worker') {
51
+ const colonCount = [...value].filter((character) => character === ':').length;
52
+
53
+ if (colonCount > 1) {
54
+ throw new Error(
55
+ `invalid format for --${role}-model '${value}'. Use 'model:effort' (claude) or 'model:reasoning' (codex).`,
56
+ );
57
+ }
58
+
59
+ if (colonCount === 0) {
60
+ if (!value) {
61
+ throw new Error(`--${role}-model model is required`);
62
+ }
63
+
64
+ return {
65
+ engine: 'claude',
66
+ model: value,
67
+ };
68
+ }
69
+
70
+ const [model, level] = value.split(':');
71
+ if (!model) {
72
+ throw new Error(`--${role}-model model is required`);
73
+ }
74
+
75
+ if (CLAUDE_MODELS.has(model)) {
76
+ return {
77
+ engine: 'claude',
78
+ model,
79
+ effort: level,
80
+ };
81
+ }
82
+
83
+ if (model === 'spark') {
84
+ return {
85
+ engine: 'codex',
86
+ model: 'gpt-5.3-codex-spark',
87
+ reasoning: level,
88
+ };
89
+ }
90
+
91
+ return {
92
+ engine: 'codex',
93
+ model,
94
+ reasoning: level,
95
+ };
96
+ }
@@ -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
+ }