ralph-prd 1.1.2 → 3.0.1
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 +93 -55
- package/bin/cli.mjs +72 -0
- package/bin/install.mjs +49 -15
- package/package.json +7 -2
- package/ralph/index.mjs +10 -0
- package/ralph/lib/committer.mjs +29 -7
- package/ralph/lib/config.mjs +26 -5
- package/ralph/lib/phase-executor.mjs +16 -2
- package/ralph/lib/plan-parser.mjs +104 -1
- package/ralph/lib/prompts/commit.md +12 -0
- package/ralph/lib/prompts/implementation.md +2 -2
- package/ralph/lib/prompts/implementation_closing_commit.md +18 -1
- package/ralph/lib/prompts/repair.md +1 -1
- package/ralph/lib/prompts/verification.md +1 -1
- package/ralph/lib/state.mjs +6 -2
- package/ralph/lib/transport.mjs +3 -3
- package/ralph/lib/verifier.mjs +17 -5
- package/ralph/ralph-claude.mjs +237 -122
- package/ralph/test/committer.test.mjs +19 -12
- package/ralph/test/config.test.mjs +4 -2
- package/ralph/test/e2e.test.mjs +18 -18
- package/ralph/test/git-coordinator.test.mjs +8 -4
- package/ralph/test/phase-executor.test.mjs +13 -9
package/ralph/ralph-claude.mjs
CHANGED
|
@@ -187,6 +187,29 @@ function ts() {
|
|
|
187
187
|
return new Date().toTimeString().slice(0, 8);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Gather the last N commits (oneline) from each primary repo.
|
|
192
|
+
* Returns a markdown string suitable for injection into prompts.
|
|
193
|
+
*
|
|
194
|
+
* @param {import('./lib/config.mjs').Repo[]} repos
|
|
195
|
+
* @param {number} [count=5]
|
|
196
|
+
* @returns {string}
|
|
197
|
+
*/
|
|
198
|
+
function gatherRecentCommits(repos, count = 5) {
|
|
199
|
+
const primaryRepos = repos.filter(r => !r.writableOnly);
|
|
200
|
+
const sections = [];
|
|
201
|
+
for (const repo of primaryRepos) {
|
|
202
|
+
const result = spawnSync('git', ['log', `--oneline`, `-${count}`], {
|
|
203
|
+
cwd: repo.path, encoding: 'utf8',
|
|
204
|
+
});
|
|
205
|
+
const log = (result.stdout ?? '').trim();
|
|
206
|
+
if (log) {
|
|
207
|
+
sections.push(`### ${repo.name}\n\`\`\`\n${log}\n\`\`\``);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return sections.length > 0 ? sections.join('\n\n') : '';
|
|
211
|
+
}
|
|
212
|
+
|
|
190
213
|
/** Pause execution until the user presses Enter. */
|
|
191
214
|
function waitForUser(message = 'Press Enter to continue…') {
|
|
192
215
|
return new Promise(resolve => {
|
|
@@ -202,12 +225,17 @@ function waitForUser(message = 'Press Enter to continue…') {
|
|
|
202
225
|
* @param {string} message
|
|
203
226
|
*/
|
|
204
227
|
function notify(title, message) {
|
|
205
|
-
if (process.platform
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
if (process.platform === 'darwin') {
|
|
229
|
+
const safeTitle = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
230
|
+
const safeMsg = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
231
|
+
spawnSync('osascript', ['-e', `display notification "${safeMsg}" with title "${safeTitle}"`], {
|
|
232
|
+
stdio: 'ignore',
|
|
233
|
+
});
|
|
234
|
+
} else if (process.platform === 'linux') {
|
|
235
|
+
spawnSync('notify-send', [title, message], { stdio: 'ignore' });
|
|
236
|
+
} else {
|
|
237
|
+
process.stderr.write('\x07');
|
|
238
|
+
}
|
|
211
239
|
}
|
|
212
240
|
|
|
213
241
|
/**
|
|
@@ -335,6 +363,23 @@ async function main() {
|
|
|
335
363
|
process.exit(1);
|
|
336
364
|
}
|
|
337
365
|
|
|
366
|
+
// Resolve PRD content from the plan's `> Source PRD:` line (if present)
|
|
367
|
+
let prdContent = '';
|
|
368
|
+
const prdMatch = planContent.match(/^>\s*Source PRD:\s*(.+)$/m);
|
|
369
|
+
if (prdMatch) {
|
|
370
|
+
const prdRelPath = prdMatch[1].trim();
|
|
371
|
+
const prdAbsPath = resolve(dirname(planPath), prdRelPath);
|
|
372
|
+
if (existsSync(prdAbsPath)) {
|
|
373
|
+
prdContent = readFileSync(prdAbsPath, 'utf8');
|
|
374
|
+
} else {
|
|
375
|
+
// Try relative to plan's parent directory (docs/<id>/PRD.md next to plan.md)
|
|
376
|
+
const siblingPath = resolve(dirname(planPath), 'PRD.md');
|
|
377
|
+
if (existsSync(siblingPath)) {
|
|
378
|
+
prdContent = readFileSync(siblingPath, 'utf8');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
338
383
|
// Resolve repos + config flags (validates config + git repos)
|
|
339
384
|
let repos;
|
|
340
385
|
let configFlags;
|
|
@@ -504,62 +549,199 @@ async function main() {
|
|
|
504
549
|
|
|
505
550
|
console.log(`\n[${ts()}] Phase ${phaseNum}/${phases.length}: ${phase.title}`);
|
|
506
551
|
|
|
552
|
+
const tasks = phase.tasks ?? [{ index: 0, description: phase.body, acceptanceCriteria: phase.acceptanceCriteria }];
|
|
553
|
+
const isMultiTask = tasks.length > 1;
|
|
554
|
+
|
|
507
555
|
// ── Check for mid-phase checkpoint (crash recovery) ─────────────────────
|
|
508
556
|
const cp = state.checkpoint;
|
|
509
557
|
const resuming = cp && cp.phaseIndex === phase.index;
|
|
510
558
|
const resumeAfter = resuming ? cp.step : null; // step already completed
|
|
559
|
+
const resumeTaskIndex = resuming ? (cp.completedTaskIndex ?? -1) : -1;
|
|
511
560
|
if (resuming) {
|
|
512
561
|
taskNum = cp.taskNum ?? taskNum;
|
|
513
562
|
console.log(` [${ts()}] resuming after "${resumeAfter}" (checkpoint found)`);
|
|
514
563
|
}
|
|
515
564
|
|
|
516
|
-
// ──
|
|
565
|
+
// ── Task-level implementation + commit loop ─────────────────────────────
|
|
566
|
+
// Each task gets a focused implementation session + commit. Verification
|
|
567
|
+
// and ship-check run once per phase after all tasks are done.
|
|
517
568
|
let implementationOutput = resuming ? (cp.implementationOutput ?? '') : '';
|
|
518
|
-
|
|
519
|
-
// No checkpoint — run implementation from scratch
|
|
520
|
-
process.stdout.write(` [${ts()}] implementation… `);
|
|
521
|
-
try {
|
|
522
|
-
implementationOutput = await runImplementation({
|
|
523
|
-
planContent,
|
|
524
|
-
phase,
|
|
525
|
-
repos,
|
|
526
|
-
safetyHeader,
|
|
527
|
-
logWriter,
|
|
528
|
-
phaseNum,
|
|
529
|
-
taskNum: taskNum++,
|
|
530
|
-
send,
|
|
531
|
-
isDryRun: false,
|
|
532
|
-
selfCommit: !iDidThis,
|
|
533
|
-
});
|
|
534
|
-
console.log('ok');
|
|
535
|
-
} catch (err) {
|
|
536
|
-
console.log('failed');
|
|
537
|
-
const msg = err instanceof PhaseExecutorError
|
|
538
|
-
? `Phase "${err.phaseName}" failed at step "${err.step}": ${err.message}`
|
|
539
|
-
: `Unexpected error: ${err.message}`;
|
|
540
|
-
console.error(`\n${msg}`);
|
|
541
|
-
console.error(`Logs: ${logsDir}`);
|
|
542
|
-
notify('Ralph — failed', msg);
|
|
543
|
-
process.exit(1);
|
|
544
|
-
}
|
|
569
|
+
let allTasksDone = resumeAfter === 'verification' || resumeAfter === 'commit';
|
|
545
570
|
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
phaseIndex: phase.index,
|
|
550
|
-
step: 'implementation',
|
|
551
|
-
implementationOutput,
|
|
552
|
-
taskNum,
|
|
553
|
-
});
|
|
571
|
+
if (!allTasksDone) {
|
|
572
|
+
if (isMultiTask) {
|
|
573
|
+
console.log(` [${ts()}] ${tasks.length} tasks in this phase`);
|
|
554
574
|
}
|
|
555
|
-
|
|
556
|
-
|
|
575
|
+
|
|
576
|
+
for (let ti = 0; ti < tasks.length; ti++) {
|
|
577
|
+
const task = tasks[ti];
|
|
578
|
+
|
|
579
|
+
// Skip already-completed tasks (crash recovery)
|
|
580
|
+
if (ti <= resumeTaskIndex) {
|
|
581
|
+
if (isMultiTask) console.log(` [${ts()}] task ${ti + 1}/${tasks.length}… skipped (checkpoint)`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
// If resuming on the task after the last completed one and implementation
|
|
585
|
+
// was checkpointed, skip the implementation step for this task
|
|
586
|
+
const isResumeTask = resuming && ti === resumeTaskIndex + 1;
|
|
587
|
+
const skipImplementation = isResumeTask && resumeAfter === 'implementation';
|
|
588
|
+
|
|
589
|
+
if (isMultiTask) {
|
|
590
|
+
const taskPreview = task.description.split('\n')[0].slice(0, 80);
|
|
591
|
+
console.log(` [${ts()}] task ${ti + 1}/${tasks.length}: ${taskPreview}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Implementation ────────────────────────────────────────────────
|
|
595
|
+
if (!skipImplementation) {
|
|
596
|
+
process.stdout.write(` [${ts()}] implementation… `);
|
|
597
|
+
try {
|
|
598
|
+
const recentCommits = gatherRecentCommits(repos);
|
|
599
|
+
// Build a focused phase-like object for this task
|
|
600
|
+
const taskPhaseBody = isMultiTask
|
|
601
|
+
? `${phase.body}\n\n---\n\n**Current task (${ti + 1}/${tasks.length}) — implement ONLY this task:**\n\n${task.description}`
|
|
602
|
+
: phase.body;
|
|
603
|
+
|
|
604
|
+
implementationOutput = await runImplementation({
|
|
605
|
+
planContent,
|
|
606
|
+
phase: { ...phase, body: taskPhaseBody },
|
|
607
|
+
repos,
|
|
608
|
+
safetyHeader,
|
|
609
|
+
logWriter,
|
|
610
|
+
phaseNum,
|
|
611
|
+
taskNum: taskNum++,
|
|
612
|
+
send,
|
|
613
|
+
isDryRun: false,
|
|
614
|
+
selfCommit: !iDidThis,
|
|
615
|
+
prdContent,
|
|
616
|
+
recentCommits,
|
|
617
|
+
});
|
|
618
|
+
console.log('ok');
|
|
619
|
+
} catch (err) {
|
|
620
|
+
console.log('failed');
|
|
621
|
+
const msg = err instanceof PhaseExecutorError
|
|
622
|
+
? `Phase "${err.phaseName}" failed at step "${err.step}": ${err.message}`
|
|
623
|
+
: `Unexpected error: ${err.message}`;
|
|
624
|
+
console.error(`\n${msg}`);
|
|
625
|
+
console.error(`Logs: ${logsDir}`);
|
|
626
|
+
notify('Ralph — failed', msg);
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Checkpoint: task implementation done
|
|
631
|
+
if (onlyPhase === null) {
|
|
632
|
+
saveCheckpoint(planPath, {
|
|
633
|
+
phaseIndex: phase.index,
|
|
634
|
+
step: 'implementation',
|
|
635
|
+
implementationOutput,
|
|
636
|
+
taskNum,
|
|
637
|
+
completedTaskIndex: ti - 1,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
console.log(` [${ts()}] implementation… skipped (checkpoint)`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Per-task commit ───────────────────────────────────────────────
|
|
645
|
+
if (!iDidThis) {
|
|
646
|
+
const uncommitted = await scanChangedRepos(repos);
|
|
647
|
+
if (uncommitted.length === 0) {
|
|
648
|
+
console.log(` [${ts()}] commit… done by Claude`);
|
|
649
|
+
} else {
|
|
650
|
+
console.log(` [${ts()}] commit… Claude did not commit, falling back to structured commit`);
|
|
651
|
+
try {
|
|
652
|
+
const { nextTaskNum, anyCommitted } = await runCommitStep({
|
|
653
|
+
phase,
|
|
654
|
+
repos,
|
|
655
|
+
safetyHeader,
|
|
656
|
+
logWriter,
|
|
657
|
+
phaseNum,
|
|
658
|
+
taskNum,
|
|
659
|
+
send,
|
|
660
|
+
});
|
|
661
|
+
taskNum = nextTaskNum;
|
|
662
|
+
if (anyCommitted) {
|
|
663
|
+
console.log(` [${ts()}] fallback commit… ok`);
|
|
664
|
+
} else {
|
|
665
|
+
console.log(` [${ts()}] fallback commit… skipped (no changes)`);
|
|
666
|
+
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
const msg = err instanceof CommitError
|
|
669
|
+
? `Phase "${err.phaseName}" fallback commit failed: ${err.message}`
|
|
670
|
+
: `Unexpected error during fallback commit: ${err.message}`;
|
|
671
|
+
console.error(`\n${msg}`);
|
|
672
|
+
console.error(`Logs: ${logsDir}`);
|
|
673
|
+
notify('Ralph — failed', msg);
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
if (waitForIt) {
|
|
679
|
+
await waitForUser(`\n [wait-for-it] Task ${ti + 1}/${tasks.length} ready to commit. Press Enter to proceed… `);
|
|
680
|
+
}
|
|
681
|
+
process.stdout.write(` [${ts()}] commit… `);
|
|
682
|
+
try {
|
|
683
|
+
const { nextTaskNum, anyCommitted } = await runCommitStep({
|
|
684
|
+
phase,
|
|
685
|
+
repos,
|
|
686
|
+
safetyHeader,
|
|
687
|
+
logWriter,
|
|
688
|
+
phaseNum,
|
|
689
|
+
taskNum,
|
|
690
|
+
send,
|
|
691
|
+
});
|
|
692
|
+
taskNum = nextTaskNum;
|
|
693
|
+
if (anyCommitted) {
|
|
694
|
+
console.log('ok');
|
|
695
|
+
} else {
|
|
696
|
+
console.log('skipped (no changes)');
|
|
697
|
+
}
|
|
698
|
+
} catch (err) {
|
|
699
|
+
console.log('failed');
|
|
700
|
+
const msg = err instanceof CommitError
|
|
701
|
+
? `Phase "${err.phaseName}" commit failed: ${err.message}`
|
|
702
|
+
: `Unexpected error during commit: ${err.message}`;
|
|
703
|
+
console.error(`\n${msg}`);
|
|
704
|
+
console.error(`Logs: ${logsDir}`);
|
|
705
|
+
notify('Ralph — failed', msg);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── afterCommit hook (per task) ───────────────────────────────────
|
|
711
|
+
if (hooks?.afterCommit) {
|
|
712
|
+
const primaryRepo = repos.find(r => !r.writableOnly);
|
|
713
|
+
process.stdout.write(` [${ts()}] afterCommit hook… `);
|
|
714
|
+
const hookResult = spawnSync(hooks.afterCommit, {
|
|
715
|
+
shell: true,
|
|
716
|
+
cwd: primaryRepo.path,
|
|
717
|
+
encoding: 'utf8',
|
|
718
|
+
stdio: 'inherit',
|
|
719
|
+
});
|
|
720
|
+
if (hookResult.status !== 0) {
|
|
721
|
+
const hookMsg = `afterCommit hook exited with code ${hookResult.status}: ${hooks.afterCommit}`;
|
|
722
|
+
console.error(`\n${hookMsg}`);
|
|
723
|
+
notify('Ralph — failed', hookMsg);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
console.log('ok');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Checkpoint: task fully complete
|
|
730
|
+
if (onlyPhase === null) {
|
|
731
|
+
saveCheckpoint(planPath, {
|
|
732
|
+
phaseIndex: phase.index,
|
|
733
|
+
step: 'implementation',
|
|
734
|
+
implementationOutput,
|
|
735
|
+
taskNum,
|
|
736
|
+
completedTaskIndex: ti,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
} // end task loop
|
|
557
740
|
}
|
|
558
741
|
|
|
559
|
-
// ──
|
|
742
|
+
// ── Phase-level verification + repair (after all tasks) ─────────────────
|
|
560
743
|
let repairCount = 0;
|
|
561
744
|
if (resumeAfter === 'verification' || resumeAfter === 'commit') {
|
|
562
|
-
// Verification already passed in a previous run
|
|
563
745
|
console.log(` [${ts()}] verification… skipped (checkpoint)`);
|
|
564
746
|
} else if (phase.hasVerification) {
|
|
565
747
|
process.stdout.write(` [${ts()}] verification… `);
|
|
@@ -575,6 +757,7 @@ async function main() {
|
|
|
575
757
|
startTaskNum: taskNum,
|
|
576
758
|
send,
|
|
577
759
|
maxRepairs: configFlags.maxRepairs,
|
|
760
|
+
prdContent,
|
|
578
761
|
}));
|
|
579
762
|
console.log('ok');
|
|
580
763
|
} catch (err) {
|
|
@@ -600,7 +783,6 @@ async function main() {
|
|
|
600
783
|
}
|
|
601
784
|
}
|
|
602
785
|
|
|
603
|
-
// Checkpoint: verification done
|
|
604
786
|
if (onlyPhase === null) {
|
|
605
787
|
saveCheckpoint(planPath, {
|
|
606
788
|
phaseIndex: phase.index,
|
|
@@ -613,94 +795,27 @@ async function main() {
|
|
|
613
795
|
console.log(` [${ts()}] verification… skipped (no acceptance criteria)`);
|
|
614
796
|
}
|
|
615
797
|
|
|
616
|
-
// ──
|
|
617
|
-
if (resumeAfter
|
|
618
|
-
console.log(` [${ts()}] commit… skipped (checkpoint)`);
|
|
619
|
-
} else if (!iDidThis) {
|
|
620
|
-
// Claude was asked to self-commit during implementation.
|
|
621
|
-
// Verify it actually happened — if uncommitted changes remain, fall back
|
|
622
|
-
// to the structured commit step so work is never silently lost.
|
|
798
|
+
// ── Phase-level final commit (verification repairs may have changed files) ─
|
|
799
|
+
if (resumeAfter !== 'commit') {
|
|
623
800
|
const uncommitted = await scanChangedRepos(repos);
|
|
624
|
-
if (uncommitted.length
|
|
625
|
-
|
|
626
|
-
} else {
|
|
627
|
-
console.log(` [${ts()}] commit… Claude did not commit, falling back to structured commit`);
|
|
801
|
+
if (uncommitted.length > 0) {
|
|
802
|
+
process.stdout.write(` [${ts()}] post-verification commit… `);
|
|
628
803
|
try {
|
|
629
804
|
const { nextTaskNum, anyCommitted } = await runCommitStep({
|
|
630
|
-
phase,
|
|
631
|
-
repos,
|
|
632
|
-
safetyHeader,
|
|
633
|
-
logWriter,
|
|
634
|
-
phaseNum,
|
|
635
|
-
taskNum,
|
|
636
|
-
send,
|
|
805
|
+
phase, repos, safetyHeader, logWriter, phaseNum, taskNum, send,
|
|
637
806
|
});
|
|
638
807
|
taskNum = nextTaskNum;
|
|
639
|
-
|
|
640
|
-
console.log(` [${ts()}] fallback commit… ok`);
|
|
641
|
-
} else {
|
|
642
|
-
console.log(` [${ts()}] fallback commit… skipped (no changes)`);
|
|
643
|
-
}
|
|
808
|
+
console.log(anyCommitted ? 'ok' : 'skipped (no changes)');
|
|
644
809
|
} catch (err) {
|
|
645
810
|
const msg = err instanceof CommitError
|
|
646
|
-
? `Phase "${err.phaseName}"
|
|
647
|
-
: `Unexpected error
|
|
811
|
+
? `Phase "${err.phaseName}" post-verification commit failed: ${err.message}`
|
|
812
|
+
: `Unexpected error: ${err.message}`;
|
|
648
813
|
console.error(`\n${msg}`);
|
|
649
814
|
console.error(`Logs: ${logsDir}`);
|
|
650
815
|
notify('Ralph — failed', msg);
|
|
651
816
|
process.exit(1);
|
|
652
817
|
}
|
|
653
818
|
}
|
|
654
|
-
} else {
|
|
655
|
-
if (waitForIt) {
|
|
656
|
-
await waitForUser(`\n [wait-for-it] Phase ${phaseNum} ready to commit. Press Enter to proceed… `);
|
|
657
|
-
}
|
|
658
|
-
process.stdout.write(` [${ts()}] commit… `);
|
|
659
|
-
try {
|
|
660
|
-
const { nextTaskNum, anyCommitted } = await runCommitStep({
|
|
661
|
-
phase,
|
|
662
|
-
repos,
|
|
663
|
-
safetyHeader,
|
|
664
|
-
logWriter,
|
|
665
|
-
phaseNum,
|
|
666
|
-
taskNum,
|
|
667
|
-
send,
|
|
668
|
-
});
|
|
669
|
-
taskNum = nextTaskNum;
|
|
670
|
-
if (anyCommitted) {
|
|
671
|
-
console.log('ok');
|
|
672
|
-
} else {
|
|
673
|
-
console.log('skipped (no changes)');
|
|
674
|
-
}
|
|
675
|
-
} catch (err) {
|
|
676
|
-
console.log('failed');
|
|
677
|
-
const msg = err instanceof CommitError
|
|
678
|
-
? `Phase "${err.phaseName}" commit failed: ${err.message}`
|
|
679
|
-
: `Unexpected error during commit: ${err.message}`;
|
|
680
|
-
console.error(`\n${msg}`);
|
|
681
|
-
console.error(`Logs: ${logsDir}`);
|
|
682
|
-
notify('Ralph — failed', msg);
|
|
683
|
-
process.exit(1);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// ── afterCommit hook ──────────────────────────────────────────────────────
|
|
688
|
-
if (hooks?.afterCommit) {
|
|
689
|
-
const primaryRepo = repos.find(r => !r.writableOnly);
|
|
690
|
-
process.stdout.write(` [${ts()}] afterCommit hook… `);
|
|
691
|
-
const hookResult = spawnSync(hooks.afterCommit, {
|
|
692
|
-
shell: true,
|
|
693
|
-
cwd: primaryRepo.path,
|
|
694
|
-
encoding: 'utf8',
|
|
695
|
-
stdio: 'inherit',
|
|
696
|
-
});
|
|
697
|
-
if (hookResult.status !== 0) {
|
|
698
|
-
const hookMsg = `afterCommit hook exited with code ${hookResult.status}: ${hooks.afterCommit}`;
|
|
699
|
-
console.error(`\n${hookMsg}`);
|
|
700
|
-
notify('Ralph — failed', hookMsg);
|
|
701
|
-
process.exit(1);
|
|
702
|
-
}
|
|
703
|
-
console.log('ok');
|
|
704
819
|
}
|
|
705
820
|
|
|
706
821
|
// ── Ship-check ────────────────────────────────────────────────────────────
|
|
@@ -24,18 +24,19 @@ describe('runCommitStep', () => {
|
|
|
24
24
|
let sendCalled = false;
|
|
25
25
|
const send = async () => { sendCalled = true; return ''; };
|
|
26
26
|
|
|
27
|
-
const { anyCommitted,
|
|
27
|
+
const { anyCommitted, nextTaskNum } = await runCommitStep({
|
|
28
28
|
phase: PHASE,
|
|
29
29
|
repos: [{ name: 'r', path: repoDir }],
|
|
30
30
|
safetyHeader: '',
|
|
31
31
|
logWriter: lw,
|
|
32
|
-
|
|
32
|
+
phaseNum: 1,
|
|
33
|
+
taskNum: 3,
|
|
33
34
|
send,
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
assert.equal(anyCommitted, false);
|
|
37
38
|
assert.equal(sendCalled, false);
|
|
38
|
-
assert.equal(
|
|
39
|
+
assert.equal(nextTaskNum, 3); // unchanged when skipped
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
test('changed repo → commit session runs, file is committed', async () => {
|
|
@@ -53,7 +54,8 @@ describe('runCommitStep', () => {
|
|
|
53
54
|
repos: [{ name: 'myrepo', path: repoDir }],
|
|
54
55
|
safetyHeader: '',
|
|
55
56
|
logWriter: lw,
|
|
56
|
-
|
|
57
|
+
phaseNum: 1,
|
|
58
|
+
taskNum: 1,
|
|
57
59
|
send,
|
|
58
60
|
});
|
|
59
61
|
|
|
@@ -75,7 +77,8 @@ describe('runCommitStep', () => {
|
|
|
75
77
|
repos: [{ name: 'myrepo', path: repoDir }],
|
|
76
78
|
safetyHeader: '',
|
|
77
79
|
logWriter: lw,
|
|
78
|
-
|
|
80
|
+
phaseNum: 1,
|
|
81
|
+
taskNum: 1,
|
|
79
82
|
send,
|
|
80
83
|
});
|
|
81
84
|
|
|
@@ -98,7 +101,8 @@ describe('runCommitStep', () => {
|
|
|
98
101
|
repos: [{ name: 'r', path: repoDir }],
|
|
99
102
|
safetyHeader: '',
|
|
100
103
|
logWriter: lw,
|
|
101
|
-
|
|
104
|
+
phaseNum: 1,
|
|
105
|
+
taskNum: 1,
|
|
102
106
|
send,
|
|
103
107
|
});
|
|
104
108
|
|
|
@@ -125,7 +129,8 @@ describe('runCommitStep', () => {
|
|
|
125
129
|
],
|
|
126
130
|
safetyHeader: '',
|
|
127
131
|
logWriter: lw,
|
|
128
|
-
|
|
132
|
+
phaseNum: 1,
|
|
133
|
+
taskNum: 1,
|
|
129
134
|
send,
|
|
130
135
|
});
|
|
131
136
|
|
|
@@ -154,7 +159,8 @@ describe('runCommitStep', () => {
|
|
|
154
159
|
repos: [{ name: 'r', path: repoDir }],
|
|
155
160
|
safetyHeader: '',
|
|
156
161
|
logWriter: lw,
|
|
157
|
-
|
|
162
|
+
phaseNum: 1,
|
|
163
|
+
taskNum: 1,
|
|
158
164
|
send,
|
|
159
165
|
}),
|
|
160
166
|
(err) => {
|
|
@@ -165,7 +171,7 @@ describe('runCommitStep', () => {
|
|
|
165
171
|
);
|
|
166
172
|
});
|
|
167
173
|
|
|
168
|
-
test('
|
|
174
|
+
test('nextTaskNum increments by 1 after commit session', async () => {
|
|
169
175
|
const repoDir = makeTempRepo();
|
|
170
176
|
writeFileSync(join(repoDir, 'f.txt'), 'f\n');
|
|
171
177
|
|
|
@@ -175,16 +181,17 @@ describe('runCommitStep', () => {
|
|
|
175
181
|
'REPO: r\nFILES:\n- f.txt\nCOMMIT: ralph: commit f',
|
|
176
182
|
]);
|
|
177
183
|
|
|
178
|
-
const {
|
|
184
|
+
const { nextTaskNum } = await runCommitStep({
|
|
179
185
|
phase: PHASE,
|
|
180
186
|
repos: [{ name: 'r', path: repoDir }],
|
|
181
187
|
safetyHeader: '',
|
|
182
188
|
logWriter: lw,
|
|
183
|
-
|
|
189
|
+
phaseNum: 1,
|
|
190
|
+
taskNum: 5,
|
|
184
191
|
send,
|
|
185
192
|
});
|
|
186
193
|
|
|
187
|
-
assert.equal(
|
|
194
|
+
assert.equal(nextTaskNum, 6);
|
|
188
195
|
});
|
|
189
196
|
|
|
190
197
|
});
|
|
@@ -76,10 +76,12 @@ describe('config', () => {
|
|
|
76
76
|
assert.equal(writableDirs[0].path, docsDir);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
test('config with empty repos section →
|
|
79
|
+
test('config with empty repos section → falls back to cwd', () => {
|
|
80
80
|
const configDir = makeTempDir();
|
|
81
81
|
writeFileSync(join(configDir, 'ralph.config.yaml'), 'repos:\n', 'utf8');
|
|
82
|
-
|
|
82
|
+
const { repos } = resolveRepos(configDir);
|
|
83
|
+
assert.equal(repos.length, 1);
|
|
84
|
+
assert.equal(repos[0].path, process.cwd());
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
test('no config file → flags all default to false', () => {
|
package/ralph/test/e2e.test.mjs
CHANGED
|
@@ -40,28 +40,28 @@ Create a hello.txt file.
|
|
|
40
40
|
* Run one complete phase (implementation → verification → commit → mark state).
|
|
41
41
|
* Returns { success, error } instead of calling process.exit().
|
|
42
42
|
*/
|
|
43
|
-
async function runPhase({ phase, planPath, planContent, repos, logWriter, send,
|
|
43
|
+
async function runPhase({ phase, planPath, planContent, repos, logWriter, send, phaseNum = 1, taskNum = 1 }) {
|
|
44
44
|
const safetyHeader = '';
|
|
45
|
-
let si =
|
|
45
|
+
let si = taskNum;
|
|
46
46
|
|
|
47
47
|
// Implementation
|
|
48
48
|
const implOutput = await runImplementation({
|
|
49
49
|
planContent, phase, repos, safetyHeader,
|
|
50
|
-
logWriter,
|
|
50
|
+
logWriter, phaseNum, taskNum: si++, send, isDryRun: false,
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
// Verification (if applicable)
|
|
54
54
|
if (phase.hasVerification) {
|
|
55
|
-
({
|
|
55
|
+
({ nextTaskNum: si } = await runVerificationLoop({
|
|
56
56
|
planContent, phase, repos, safetyHeader,
|
|
57
57
|
implementationOutput: implOutput,
|
|
58
|
-
logWriter,
|
|
58
|
+
logWriter, phaseNum, startTaskNum: si, send,
|
|
59
59
|
}));
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// Commit
|
|
63
|
-
const {
|
|
64
|
-
phase, repos, safetyHeader, logWriter,
|
|
63
|
+
const { nextTaskNum: nextSi } = await runCommitStep({
|
|
64
|
+
phase, repos, safetyHeader, logWriter, phaseNum, taskNum: si, send,
|
|
65
65
|
});
|
|
66
66
|
si = nextSi;
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ async function runPhase({ phase, planPath, planContent, repos, logWriter, send,
|
|
|
69
69
|
if (phase.hasVerification) mutateCheckboxes(planPath, phase);
|
|
70
70
|
markPhaseComplete(planPath, phase.index);
|
|
71
71
|
|
|
72
|
-
return {
|
|
72
|
+
return { nextTaskNum: si };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
describe('e2e: full single-phase run with fake transport', () => {
|
|
@@ -116,9 +116,9 @@ describe('e2e: full single-phase run with fake transport', () => {
|
|
|
116
116
|
assert.ok(gitLog.includes('ralph:'), 'git log should contain ralph commit');
|
|
117
117
|
|
|
118
118
|
// Logs: step files exist
|
|
119
|
-
assert.ok(existsSync(join(logDir, '
|
|
120
|
-
assert.ok(existsSync(join(logDir, '
|
|
121
|
-
assert.ok(existsSync(join(logDir, '
|
|
119
|
+
assert.ok(existsSync(join(logDir, 'phase-1-implementation.log')));
|
|
120
|
+
assert.ok(existsSync(join(logDir, 'phase-1-verification.log')));
|
|
121
|
+
assert.ok(existsSync(join(logDir, 'phase-1-commit.log')));
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
});
|
|
@@ -173,10 +173,10 @@ describe('e2e: repair-loop run (fail → repair → pass)', () => {
|
|
|
173
173
|
assert.ok(state.completedPhases.includes(0));
|
|
174
174
|
|
|
175
175
|
// All session logs exist
|
|
176
|
-
assert.ok(existsSync(join(logDir, '
|
|
177
|
-
assert.ok(existsSync(join(logDir, '
|
|
178
|
-
assert.ok(existsSync(join(logDir, '
|
|
179
|
-
assert.ok(existsSync(join(logDir, '
|
|
176
|
+
assert.ok(existsSync(join(logDir, 'phase-1-implementation.log')));
|
|
177
|
+
assert.ok(existsSync(join(logDir, 'phase-1-verification.log')));
|
|
178
|
+
assert.ok(existsSync(join(logDir, 'phase-1-repair-1.log')));
|
|
179
|
+
assert.ok(existsSync(join(logDir, 'phase-1-re-verification-1.log')));
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
});
|
|
@@ -227,9 +227,9 @@ describe('e2e: double-fail run exits non-zero and preserves logs', () => {
|
|
|
227
227
|
assert.ok(!state.completedPhases.includes(0), 'phase should not be marked complete');
|
|
228
228
|
|
|
229
229
|
// Logs must be preserved on disk
|
|
230
|
-
assert.ok(existsSync(join(logDir, '
|
|
231
|
-
assert.ok(existsSync(join(logDir, '
|
|
232
|
-
assert.ok(existsSync(join(logDir, '
|
|
230
|
+
assert.ok(existsSync(join(logDir, 'phase-1-implementation.log')), 'impl log preserved');
|
|
231
|
+
assert.ok(existsSync(join(logDir, 'phase-1-verification.log')), 'verify log preserved');
|
|
232
|
+
assert.ok(existsSync(join(logDir, 'phase-1-repair-1.log')), 'repair log preserved');
|
|
233
233
|
});
|
|
234
234
|
|
|
235
235
|
test('resume from first incomplete phase after partial failure', async () => {
|
|
@@ -53,13 +53,17 @@ describe('prepareBranch', () => {
|
|
|
53
53
|
|
|
54
54
|
test('no-op when already on the target branch', async () => {
|
|
55
55
|
const repoPath = makeTempRepo();
|
|
56
|
+
// Switch to a non-protected branch first (default branch may be main/master)
|
|
57
|
+
execSync('git checkout -b feature-test', { cwd: repoPath, stdio: 'pipe' });
|
|
58
|
+
|
|
59
|
+
const repos = [{ name: 'r', path: repoPath }];
|
|
60
|
+
// Should not throw — already on feature-test
|
|
61
|
+
await prepareBranch(repos, 'feature-test');
|
|
62
|
+
|
|
56
63
|
const current = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
57
64
|
cwd: repoPath, encoding: 'utf8',
|
|
58
65
|
}).trim();
|
|
59
|
-
|
|
60
|
-
const repos = [{ name: 'r', path: repoPath }];
|
|
61
|
-
// Should not throw
|
|
62
|
-
await prepareBranch(repos, current);
|
|
66
|
+
assert.equal(current, 'feature-test');
|
|
63
67
|
});
|
|
64
68
|
|
|
65
69
|
test('throws GitCoordinatorError for invalid repo path', async () => {
|