ralph-prd 1.0.5 → 1.0.6

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 CHANGED
@@ -22,12 +22,12 @@ cd your-project && ../ralph-prd/install.sh
22
22
 
23
23
  This installs:
24
24
  - `.claude/ralph/` — the phased runner
25
- - `.claude/skills/` — 6 Claude Code skills for the full PRD-to-ship workflow
25
+ - `.claude/skills/` — 7 Claude Code skills fetched from [`tahaJemmali/skills`](https://github.com/tahaJemmali/skills) via `npx skills add`
26
26
  - On first install, adds `.claude/ralph/` and `.claude/skills/` to `.gitignore` (skipped if `.claude/` already exists, so shared setups are respected)
27
27
 
28
28
  ### Updating
29
29
 
30
- To update Ralph in any repo, just re-run the install command:
30
+ To update Ralph and re-fetch skills, just re-run the install command:
31
31
 
32
32
  ```bash
33
33
  npx ralph-prd
@@ -35,6 +35,12 @@ npx ralph-prd
35
35
 
36
36
  Ralph also checks for updates automatically on every run. If a newer version is available, you'll see a notice in the console output.
37
37
 
38
+ To update only the skills without reinstalling:
39
+
40
+ ```bash
41
+ node .claude/ralph/ralph-claude.mjs --update-skills
42
+ ```
43
+
38
44
  ## Requirements
39
45
 
40
46
  - Node.js 18+
@@ -88,6 +94,7 @@ Options:
88
94
  --i-did-this Skip Claude self-commit; run separate commit step
89
95
  --send-it Push branch + open PR when all phases complete
90
96
  --wait-for-it Pause before each commit for review
97
+ --update-skills Re-fetch skills from tahaJemmali/skills and exit
91
98
  --version, -v Print installed version and exit
92
99
  ```
93
100
 
@@ -114,10 +121,13 @@ node .claude/ralph/ralph-claude.mjs docs/auth-rework/plan.md --send-it
114
121
  | `/grill-me` | Stress-test a plan by walking every branch of the decision tree |
115
122
  | `/write-a-prd` | Interview-driven PRD creation with codebase exploration |
116
123
  | `/prd-to-plan` | Turn a PRD into phased vertical-slice implementation plan |
124
+ | `/reality-check` | Brutally honest architectural critique and assumption stress-test |
117
125
  | `/review-changes` | Review recent changes against project guidelines |
118
126
  | `/repo-doc-maintainer` | Decide if AGENTS.md or docs need updating after changes |
119
127
  | `/ship-check` | End-of-task validation: review + doc maintenance check |
120
128
 
129
+ Skills are fetched from [`tahaJemmali/skills`](https://github.com/tahaJemmali/skills) during install. You can also install or update them independently with `npx skills add tahaJemmali/skills`.
130
+
121
131
  ## Multi-Repo Support
122
132
 
123
133
  Create `ralph.config.yaml` in `.claude/ralph/` for monorepo setups:
package/bin/install.mjs CHANGED
@@ -3,12 +3,14 @@
3
3
  /**
4
4
  * ralph-prd installer — called via `npx ralph-prd` or `npx ralph-prd init`
5
5
  *
6
- * Copies ralph/ and skills/ into .claude/ of the current project.
6
+ * Copies ralph/ into .claude/ of the current project, then fetches skills
7
+ * from the skills repo via `npx skills add tahaJemmali/skills`.
7
8
  */
8
9
 
9
- import { existsSync, mkdirSync, cpSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'fs';
10
- import { resolve, dirname, basename } from 'path';
10
+ import { existsSync, mkdirSync, cpSync, rmSync, readFileSync, writeFileSync } from 'fs';
11
+ import { resolve, dirname } from 'path';
11
12
  import { fileURLToPath } from 'url';
13
+ import { spawnSync } from 'child_process';
12
14
 
13
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
16
  const PKG_ROOT = resolve(__dirname, '..');
@@ -60,35 +62,46 @@ const pkg = JSON.parse(readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8'));
60
62
  writeFileSync(resolve(ralphDst, '.ralph-version'), pkg.version + '\n', 'utf8');
61
63
  ok(`Installed ralph runner v${pkg.version} -> .claude/ralph/`);
62
64
 
63
- // Copy skills
64
- const skillsSrc = resolve(PKG_ROOT, 'skills');
65
- const skillsDst = resolve(claudeDir, 'skills');
66
- mkdirSync(skillsDst, { recursive: true });
67
-
68
- for (const skillName of readdirSync(skillsSrc)) {
69
- const src = resolve(skillsSrc, skillName);
70
- const dst = resolve(skillsDst, skillName);
71
- if (existsSync(dst)) {
72
- info(`Updating existing skill: ${skillName}`);
73
- rmSync(dst, { recursive: true });
74
- }
75
- cpSync(src, dst, { recursive: true });
76
- ok(`Installed skill: ${skillName}`);
65
+ // Install skills via skills.sh
66
+ info('Installing skills from tahaJemmali/skills');
67
+ const REQUIRED_SKILLS = [
68
+ 'grill-me',
69
+ 'write-a-prd',
70
+ 'prd-to-plan',
71
+ 'reality-check',
72
+ 'ship-check',
73
+ 'review-changes',
74
+ 'repo-doc-maintainer',
75
+ ];
76
+ const skillsResult = spawnSync(
77
+ 'npx',
78
+ ['skills', 'add', 'tahaJemmali/skills', ...REQUIRED_SKILLS.flatMap(s => ['--skill', s]), '-a', 'claude-code', '-y'],
79
+ { cwd: projectRoot, stdio: 'inherit', encoding: 'utf8' },
80
+ );
81
+ if (skillsResult.status !== 0) {
82
+ fail('Skills installation failed — aborting. ralph-prd requires all skills to be installed.\n Retry by running: npx ralph-prd');
77
83
  }
84
+ ok('Installed skills -> .claude/skills/');
85
+
86
+ const gitignorePath = resolve(projectRoot, '.gitignore');
87
+ let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
88
+ const lines = gitignoreContent.split('\n').map(l => l.trim());
78
89
 
79
- // Only add to .gitignore on first install if .claude/ already existed,
90
+ // skills-lock.json is always a generated fileadd to gitignore unconditionally.
91
+ const alwaysIgnore = ['skills-lock.json'];
92
+ const alwaysMissing = alwaysIgnore.filter(e => !lines.includes(e));
93
+
94
+ // Only add .claude/ entries on first install — if .claude/ already existed,
80
95
  // the user may be sharing it via git intentionally.
81
- if (isFirstInstall) {
82
- const gitignorePath = resolve(projectRoot, '.gitignore');
83
- const ignoreEntries = ['.claude/ralph/', '.claude/skills/'];
84
- let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
85
- const missing = ignoreEntries.filter(entry => !gitignoreContent.split('\n').some(line => line.trim() === entry));
86
- if (missing.length > 0) {
87
- const block = (gitignoreContent.length > 0 && !gitignoreContent.endsWith('\n') ? '\n' : '') +
88
- '\n# ralph-prd (installed via npx ralph-prd)\n' + missing.join('\n') + '\n';
89
- writeFileSync(gitignorePath, gitignoreContent + block, 'utf8');
90
- ok(`Added ${missing.join(', ')} to .gitignore`);
91
- }
96
+ const conditionalIgnore = isFirstInstall ? ['.claude/ralph/', '.claude/skills/'] : [];
97
+ const conditionalMissing = conditionalIgnore.filter(e => !lines.includes(e));
98
+
99
+ const missing = [...alwaysMissing, ...conditionalMissing];
100
+ if (missing.length > 0) {
101
+ const block = (gitignoreContent.length > 0 && !gitignoreContent.endsWith('\n') ? '\n' : '') +
102
+ '\n# ralph-prd (installed via npx ralph-prd)\n' + missing.join('\n') + '\n';
103
+ writeFileSync(gitignorePath, gitignoreContent + block, 'utf8');
104
+ ok(`Added ${missing.join(', ')} to .gitignore`);
92
105
  }
93
106
 
94
107
  // Summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-prd",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "AI-powered phased implementation runner for Claude Code — from PRD to shipped code",
5
5
  "bin": {
6
6
  "ralph-prd": "./bin/install.mjs"
@@ -23,7 +23,6 @@
23
23
  "files": [
24
24
  "bin/",
25
25
  "ralph/",
26
- "skills/",
27
26
  "install.sh",
28
27
  "README.md"
29
28
  ],
@@ -23,12 +23,13 @@ function isGitRepo(dirPath) {
23
23
 
24
24
  /**
25
25
  * @typedef {Object} RalphFlags
26
- * @property {boolean} iDidThis - Claude skips self-commit; separate commit step runs instead
27
- * @property {boolean} sendIt - Push branch and open a PR after all phases complete
28
- * @property {boolean} waitForIt - Pause for user confirmation before each commit step
29
- * @property {number} maxRepairs - Max repair attempts per phase before hard-stopping (default 3)
30
- * @property {number|null} onlyPhase - When set, only this 1-based phase index is run (force re-run)
31
- * @property {string} logLevel - "none" | "necessary" | "dump" (default "necessary")
26
+ * @property {boolean} iDidThis - Claude skips self-commit; separate commit step runs instead
27
+ * @property {boolean} sendIt - Push branch and open a PR after all phases complete
28
+ * @property {boolean} waitForIt - Pause for user confirmation before each commit step
29
+ * @property {number} maxRepairs - Max repair attempts per phase before hard-stopping (default 3)
30
+ * @property {number|null} onlyPhase - When set, only this 1-based phase index is run (force re-run)
31
+ * @property {string} logLevel - "none" | "necessary" | "dump" (default "necessary")
32
+ * @property {boolean} skipShipCheck - Skip the post-commit ship-check step for every phase
32
33
  */
33
34
 
34
35
  /**
@@ -59,7 +60,7 @@ function isGitRepo(dirPath) {
59
60
  function parseConfigYaml(content) {
60
61
  const repos = [];
61
62
  const writableDirs = [];
62
- const flags = { iDidThis: false, sendIt: false, waitForIt: false, maxRepairs: 3, onlyPhase: null, logLevel: 'necessary' };
63
+ const flags = { iDidThis: false, sendIt: false, waitForIt: false, maxRepairs: 3, onlyPhase: null, logLevel: 'necessary', skipShipCheck: false };
63
64
  const hooks = { afterCommit: null };
64
65
  let section = null;
65
66
  let current = null;
@@ -159,7 +160,7 @@ function parseConfigYaml(content) {
159
160
  */
160
161
  export function resolveRepos(runnerDir) {
161
162
  const configPath = join(runnerDir, CONFIG_FILENAME);
162
- const defaultFlags = { iDidThis: false, sendIt: false, waitForIt: false, maxRepairs: 3, onlyPhase: null };
163
+ const defaultFlags = { iDidThis: false, sendIt: false, waitForIt: false, maxRepairs: 3, onlyPhase: null, skipShipCheck: false };
163
164
  const defaultHooks = { afterCommit: null };
164
165
 
165
166
  if (!existsSync(configPath)) {
@@ -0,0 +1,319 @@
1
+ /**
2
+ * lib/ship-checker.mjs
3
+ *
4
+ * Ship-Check coordinator — runs after commit, before markPhaseComplete.
5
+ *
6
+ * Flow:
7
+ * 1. Read .claude/skills/ship-check/SKILL.md, strip YAML frontmatter.
8
+ * 2. Build ship-check prompt: skill body + phase context + repo state.
9
+ * 3. Run ship-check session via transport.
10
+ * 4. Parse VERDICT: APPROVED or VERDICT: REMARKS (case-insensitive).
11
+ * 5. APPROVED → return normally.
12
+ * 6. REMARKS → extract findings, run one repair session, re-run ship-check.
13
+ * 7. Second REMARKS → throw ShipCheckError with findings attached.
14
+ *
15
+ * Public API:
16
+ * class ShipCheckError extends Error
17
+ * .phaseName: string
18
+ * .findings: string
19
+ *
20
+ * runShipCheck({
21
+ * phase, repoState, logWriter, phaseNum, startTaskNum, send,
22
+ * _skillsBase // optional base dir override for testing
23
+ * }) → Promise<{ nextTaskNum: number }>
24
+ * throws ShipCheckError on second REMARKS verdict
25
+ */
26
+
27
+ import { readFileSync } from 'fs';
28
+ import { join } from 'path';
29
+
30
+ // ─── Error type ───────────────────────────────────────────────────────────────
31
+
32
+ export class ShipCheckError extends Error {
33
+ /**
34
+ * @param {string} message
35
+ * @param {string} phaseName
36
+ * @param {string} findings
37
+ */
38
+ constructor(message, phaseName, findings) {
39
+ super(message);
40
+ this.name = 'ShipCheckError';
41
+ this.phaseName = phaseName;
42
+ this.findings = findings;
43
+ }
44
+ }
45
+
46
+ // ─── SKILL.md loading ─────────────────────────────────────────────────────────
47
+
48
+ const SKILL_REL_PATH = join('.claude', 'skills', 'ship-check', 'SKILL.md');
49
+
50
+ /**
51
+ * Strip YAML frontmatter (--- ... ---) from skill content and return the body.
52
+ *
53
+ * @param {string} content
54
+ * @returns {string}
55
+ */
56
+ function stripFrontmatter(content) {
57
+ if (!content.startsWith('---')) return content;
58
+ const end = content.indexOf('\n---', 3);
59
+ if (end === -1) return content;
60
+ return content.slice(end + 4).trimStart();
61
+ }
62
+
63
+ /**
64
+ * Read and parse the ship-check SKILL.md.
65
+ *
66
+ * @param {string} baseDir - Repo root (or test override via _skillsBase)
67
+ * @returns {string} Skill body with frontmatter stripped
68
+ * @throws {Error} with a clear message if the file is missing
69
+ */
70
+ function loadSkill(baseDir) {
71
+ const skillPath = join(baseDir, SKILL_REL_PATH);
72
+ let raw;
73
+ try {
74
+ raw = readFileSync(skillPath, 'utf8');
75
+ } catch (err) {
76
+ throw new Error(
77
+ `Ship-check skill not found at ${skillPath}. ` +
78
+ `Create ${SKILL_REL_PATH} in your repository to enable ship-check. ` +
79
+ `(${err.code})`
80
+ );
81
+ }
82
+ return stripFrontmatter(raw);
83
+ }
84
+
85
+ // ─── Verdict parsing ──────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Scan response text for the first VERDICT line and extract findings on REMARKS.
89
+ *
90
+ * Findings may be delimited by FINDINGS_START / FINDINGS_END; if the model
91
+ * omits the delimiters, everything after the VERDICT line is used as findings.
92
+ *
93
+ * @param {string} text
94
+ * @returns {{ verdict: 'APPROVED'|'REMARKS'|'UNKNOWN', findings: string }}
95
+ */
96
+ function parseVerdict(text) {
97
+ const lines = text.split('\n');
98
+
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const match = lines[i].trim().match(/^VERDICT:\s+(APPROVED|REMARKS)$/i);
101
+ if (!match) continue;
102
+
103
+ const verdict = match[1].toUpperCase();
104
+
105
+ if (verdict === 'APPROVED') {
106
+ return { verdict: 'APPROVED', findings: '' };
107
+ }
108
+
109
+ // REMARKS — collect findings
110
+ const findingsLines = [];
111
+ let inFindings = false;
112
+
113
+ for (let j = i + 1; j < lines.length; j++) {
114
+ const t = lines[j].trim();
115
+ if (t === 'FINDINGS_START') { inFindings = true; continue; }
116
+ if (t === 'FINDINGS_END') break;
117
+ if (inFindings) findingsLines.push(lines[j]);
118
+ }
119
+
120
+ // Fallback: no delimiters — take everything after the verdict line
121
+ if (findingsLines.length === 0) {
122
+ for (let j = i + 1; j < lines.length; j++) {
123
+ findingsLines.push(lines[j]);
124
+ }
125
+ }
126
+
127
+ return { verdict: 'REMARKS', findings: findingsLines.join('\n').trim() };
128
+ }
129
+
130
+ return { verdict: 'UNKNOWN', findings: '' };
131
+ }
132
+
133
+ // ─── Prompt builders ──────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Build the ship-check prompt by combining the skill body with phase context
137
+ * and the current repository state.
138
+ *
139
+ * @param {string} skillBody - Skill content with frontmatter stripped
140
+ * @param {import('./plan-parser.mjs').Phase} phase
141
+ * @param {string} repoState - Pre-computed git status / diff summary
142
+ * @returns {string}
143
+ */
144
+ function buildShipCheckPrompt(skillBody, phase, repoState) {
145
+ return [
146
+ skillBody,
147
+ '',
148
+ '---',
149
+ '',
150
+ `## Phase under review: ${phase.title}`,
151
+ '',
152
+ phase.body.trim(),
153
+ '',
154
+ '## Current repository state',
155
+ '',
156
+ repoState,
157
+ ].join('\n');
158
+ }
159
+
160
+ /**
161
+ * Build the repair prompt sent after a REMARKS verdict.
162
+ *
163
+ * @param {import('./plan-parser.mjs').Phase} phase
164
+ * @param {string} repoState
165
+ * @param {string} findings - Findings text from the ship-check session
166
+ * @returns {string}
167
+ */
168
+ function buildRepairPrompt(phase, repoState, findings) {
169
+ return [
170
+ `The ship-check reviewer found issues with the implementation of phase "${phase.title}".`,
171
+ 'Please address the following remarks, then the phase will be re-checked.',
172
+ '',
173
+ '## Reviewer findings',
174
+ '',
175
+ findings || '(No specific findings provided.)',
176
+ '',
177
+ '## Phase being reviewed',
178
+ '',
179
+ phase.body.trim(),
180
+ '',
181
+ '## Current repository state',
182
+ '',
183
+ repoState,
184
+ ].join('\n');
185
+ }
186
+
187
+ // ─── Session helper ───────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Open a step log, send a prompt, stream the response, close the log.
191
+ *
192
+ * @param {object} opts
193
+ * @param {string} opts.stepName
194
+ * @param {string} opts.phaseName
195
+ * @param {string} opts.prompt
196
+ * @param {import('./log-writer.mjs').LogWriter} opts.logWriter
197
+ * @param {number} opts.phaseNum
198
+ * @param {number} opts.taskNum
199
+ * @param {Function} opts.send
200
+ * @returns {Promise<string>} full response text
201
+ */
202
+ async function runSession({ stepName, phaseName, prompt, logWriter, phaseNum, taskNum, send }) {
203
+ const step = logWriter.openStep(phaseNum, taskNum, stepName, phaseName);
204
+ step.writeHeader();
205
+
206
+ const started = Date.now();
207
+ let fullText = '';
208
+
209
+ try {
210
+ fullText = await send(prompt, {
211
+ onChunk(text) {
212
+ step.writeChunk(text);
213
+ },
214
+ });
215
+ step.writeFooter(true, Date.now() - started);
216
+ } catch (err) {
217
+ step.writeChunk(`\n\n[error] ${err.message}\n`);
218
+ step.writeFooter(false, Date.now() - started);
219
+ throw err;
220
+ }
221
+
222
+ return fullText;
223
+ }
224
+
225
+ // ─── Public API ───────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Run the full ship-check (+ optional repair) cycle for a single phase.
229
+ *
230
+ * Sessions written (each gets its own step log):
231
+ * startTaskNum+0 ship-check (initial check)
232
+ * startTaskNum+1 ship-check-repair (only on first REMARKS)
233
+ * startTaskNum+2 ship-check-re (re-check after repair)
234
+ *
235
+ * @param {object} opts
236
+ * @param {import('./plan-parser.mjs').Phase} opts.phase
237
+ * @param {string} opts.repoState - Pre-computed repo state string
238
+ * @param {import('./log-writer.mjs').LogWriter} opts.logWriter
239
+ * @param {number} opts.phaseNum
240
+ * @param {number} opts.startTaskNum
241
+ * @param {Function} opts.send
242
+ * @param {string} [opts._skillsBase] - Base dir for SKILL.md lookup (testing only)
243
+ * @returns {Promise<{ nextTaskNum: number }>}
244
+ * @throws {ShipCheckError} when re-check also returns REMARKS
245
+ */
246
+ export async function runShipCheck({
247
+ phase,
248
+ repoState,
249
+ logWriter,
250
+ phaseNum,
251
+ startTaskNum,
252
+ send,
253
+ _skillsBase = process.cwd(),
254
+ }) {
255
+ const skillBody = loadSkill(_skillsBase);
256
+ let taskNum = startTaskNum;
257
+
258
+ // ── Initial ship-check ─────────────────────────────────────────────────────
259
+
260
+ const initialPrompt = buildShipCheckPrompt(skillBody, phase, repoState);
261
+
262
+ const initialText = await runSession({
263
+ stepName: 'ship-check',
264
+ phaseName: phase.title,
265
+ prompt: initialPrompt,
266
+ logWriter,
267
+ phaseNum,
268
+ taskNum: taskNum++,
269
+ send,
270
+ });
271
+
272
+ const { verdict: initialVerdict, findings: initialFindings } = parseVerdict(initialText);
273
+
274
+ if (initialVerdict === 'APPROVED') {
275
+ return { nextTaskNum: taskNum };
276
+ }
277
+
278
+ const findings = initialFindings;
279
+
280
+ // ── Repair session ─────────────────────────────────────────────────────────
281
+
282
+ const repairPrompt = buildRepairPrompt(phase, repoState, findings);
283
+
284
+ await runSession({
285
+ stepName: 'ship-check-repair',
286
+ phaseName: phase.title,
287
+ prompt: repairPrompt,
288
+ logWriter,
289
+ phaseNum,
290
+ taskNum: taskNum++,
291
+ send,
292
+ });
293
+
294
+ // ── Re-check ───────────────────────────────────────────────────────────────
295
+
296
+ const reCheckText = await runSession({
297
+ stepName: 'ship-check-re',
298
+ phaseName: phase.title,
299
+ prompt: buildShipCheckPrompt(skillBody, phase, repoState),
300
+ logWriter,
301
+ phaseNum,
302
+ taskNum: taskNum++,
303
+ send,
304
+ });
305
+
306
+ const { verdict: reVerdict, findings: reFindings } = parseVerdict(reCheckText);
307
+
308
+ if (reVerdict === 'APPROVED') {
309
+ return { nextTaskNum: taskNum };
310
+ }
311
+
312
+ // ── Second REMARKS → hard stop ─────────────────────────────────────────────
313
+
314
+ throw new ShipCheckError(
315
+ `Phase "${phase.title}" failed ship-check after one repair attempt. No further action will be taken.`,
316
+ phase.title,
317
+ reFindings || findings
318
+ );
319
+ }
@@ -62,7 +62,7 @@ export class VerificationError extends Error {
62
62
  * @param {import('./config.mjs').Repo[]} repos
63
63
  * @returns {string}
64
64
  */
65
- function gatherRepoState(repos) {
65
+ export function gatherRepoState(repos) {
66
66
  const primaryRepos = repos.filter(r => !r.writableOnly);
67
67
  const parts = [];
68
68
 
@@ -23,8 +23,9 @@ import { prepareBranch, scanChangedRepos, GitCoordinatorError } from './lib/git-
23
23
  import { LogWriter } from './lib/log-writer.mjs';
24
24
  import { loadSafetyHeader } from './lib/safety.mjs';
25
25
  import { runImplementation, PhaseExecutorError } from './lib/phase-executor.mjs';
26
- import { runVerificationLoop, VerificationError } from './lib/verifier.mjs';
26
+ import { runVerificationLoop, VerificationError, gatherRepoState } from './lib/verifier.mjs';
27
27
  import { runCommitStep, CommitError } from './lib/committer.mjs';
28
+ import { runShipCheck, ShipCheckError } from './lib/ship-checker.mjs';
28
29
  import { mutateCheckboxes } from './lib/plan-mutator.mjs';
29
30
  import { deriveBranchName } from './lib/utils.mjs';
30
31
 
@@ -91,6 +92,36 @@ if (args.includes('--version') || args.includes('-v')) {
91
92
  process.exit(0);
92
93
  }
93
94
 
95
+ // ─── --update-skills ──────────────────────────────────────────────────────────
96
+ // Re-fetch skills from the skills repo and exit. Useful after an upgrade.
97
+
98
+ if (args.includes('--update-skills')) {
99
+ console.log('[ralph] Updating skills via npx skills add tahaJemmali/skills…');
100
+ const result = spawnSync('npx', ['skills', 'add', 'tahaJemmali/skills'], {
101
+ stdio: 'inherit',
102
+ encoding: 'utf8',
103
+ });
104
+ if (result.status !== 0) {
105
+ console.error('[ralph] Failed to update skills. See output above.');
106
+ process.exit(1);
107
+ }
108
+ console.log('[ralph] Skills updated.');
109
+ process.exit(0);
110
+ }
111
+
112
+ // ─── Skills preflight ─────────────────────────────────────────────────────────
113
+ // Fail fast if skills are not installed — gives a clear actionable message.
114
+
115
+ const skillsDir = resolve(__dirname, '..', 'skills');
116
+ if (!existsSync(skillsDir)) {
117
+ console.error(
118
+ '[ralph] Skills not installed. Run: npx ralph-prd to reinstall.\n' +
119
+ '[ralph] If you recently upgraded ralph-prd, re-run the installer to fetch\n' +
120
+ '[ralph] skills from the new source: npx ralph-prd'
121
+ );
122
+ process.exit(1);
123
+ }
124
+
94
125
  const planArg = args.find(a => !a.startsWith('--'));
95
126
  const isDryRun = args.includes('--dry-run');
96
127
  const isReset = args.includes('--reset');
@@ -101,6 +132,8 @@ const iDidThisArg = args.includes('--i-did-this');
101
132
  const sendItArg = args.includes('--send-it');
102
133
  // Pause and wait for your eyes before each commit. Trust issues? Fair enough.
103
134
  const waitForItArg = args.includes('--wait-for-it');
135
+ // Skip the post-commit ship-check step. Use when you don't have a ship-check skill.
136
+ const skipShipCheckArg = args.includes('--skip-ship-check');
104
137
  // Run only one specific phase (1-based), force re-run even if already complete.
105
138
  const onlyPhaseArg = (() => {
106
139
  const idx = args.indexOf('--only-phase');
@@ -121,7 +154,7 @@ const logLevelArg = (() => {
121
154
  if (!planArg) {
122
155
  console.error(
123
156
  'Usage: node ralph-claude.mjs <plan-file.md> ' +
124
- '[--reset|--dry-run|--i-did-this|--send-it|--wait-for-it|--only-phase N|--log-level none|necessary|dump|--version]'
157
+ '[--reset|--dry-run|--i-did-this|--send-it|--wait-for-it|--skip-ship-check|--only-phase N|--log-level none|necessary|dump|--update-skills|--version]'
125
158
  );
126
159
  process.exit(1);
127
160
  }
@@ -315,6 +348,7 @@ async function main() {
315
348
  const iDidThis = iDidThisArg || configFlags.iDidThis;
316
349
  const sendIt = sendItArg || configFlags.sendIt;
317
350
  const waitForIt = waitForItArg || configFlags.waitForIt;
351
+ const skipShipCheck = skipShipCheckArg || configFlags.skipShipCheck;
318
352
  const onlyPhase = onlyPhaseArg ?? configFlags.onlyPhase ?? null;
319
353
  const logLevel = logLevelArg ?? configFlags.logLevel ?? 'necessary';
320
354
 
@@ -658,6 +692,40 @@ async function main() {
658
692
  console.log('ok');
659
693
  }
660
694
 
695
+ // ── Ship-check ────────────────────────────────────────────────────────────
696
+ if (skipShipCheck) {
697
+ console.log(` [${ts()}] ship-check… skipped`);
698
+ } else {
699
+ const repoState = gatherRepoState(repos);
700
+ const shipCheckStart = Date.now();
701
+ process.stdout.write(` [${ts()}] ship-check… `);
702
+ try {
703
+ ({ nextTaskNum: taskNum } = await runShipCheck({
704
+ phase,
705
+ repoState,
706
+ logWriter,
707
+ phaseNum,
708
+ startTaskNum: taskNum,
709
+ send,
710
+ }));
711
+ const dur = ((Date.now() - shipCheckStart) / 1000).toFixed(1);
712
+ console.log(`VERDICT: APPROVED (${dur}s)`);
713
+ } catch (err) {
714
+ const dur = ((Date.now() - shipCheckStart) / 1000).toFixed(1);
715
+ if (err instanceof ShipCheckError) {
716
+ console.log(`VERDICT: REMARKS (${dur}s)`);
717
+ console.error(`\nShip-check failed for phase "${err.phaseName}":`);
718
+ if (err.findings) console.error(err.findings);
719
+ } else {
720
+ console.log('failed');
721
+ console.error(`\nUnexpected error during ship-check: ${err.message}`);
722
+ }
723
+ console.error(`Logs: ${logsDir}`);
724
+ notify('Ralph — failed', `Ship-check failed for "${phase.title}"`);
725
+ process.exit(1);
726
+ }
727
+ }
728
+
661
729
  // ── Checkbox mutation + state persistence ────────────────────────────────
662
730
  // Skip state writes when --only-phase is active (it's a force re-run, not a progression).
663
731
  if (onlyPhase === null) {
@@ -7,12 +7,12 @@
7
7
  # ── Repositories ──────────────────────────────────────────────────────────────
8
8
  # List every git repo Ralph is allowed to read and write.
9
9
  # Paths are relative to this config file.
10
+ # repos:
11
+ # - name: backend
12
+ # path: ../backend
13
+ # - name: frontend
14
+ # path: ../frontend
10
15
 
11
- repos:
12
- - name: backend
13
- path: ../../ebchat-saas-backend
14
- - name: dashboard
15
- path: ../../ebchat-saas-dashboard
16
16
 
17
17
  # ── Extra writable directories ────────────────────────────────────────────────
18
18
  # Directories that are not git roots but Ralph may write to (e.g. shared docs).
@@ -48,3 +48,8 @@ flags:
48
48
  # necessary — verdicts (pass/fail), missed criteria, phase progress (default)
49
49
  # dump — full streamed model output (verbose)
50
50
  logLevel: necessary
51
+
52
+ # Skip the post-commit ship-check step for every phase. Set to true if you
53
+ # have not installed a ship-check skill or want to disable the review gate.
54
+ # CLI equivalent: --skip-ship-check
55
+ skipShipCheck: false