@lnilluv/pi-ralph-loop 1.0.0 → 1.2.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.
package/src/runner.ts CHANGED
@@ -21,9 +21,12 @@ import {
21
21
  type RunnerStatusFile,
22
22
  appendIterationRecord,
23
23
  appendRunnerEvent,
24
+ checkCancelSignal,
24
25
  checkStopSignal,
26
+ clearCancelSignal,
25
27
  clearRunnerDir,
26
28
  clearStopSignal,
29
+ createCancelSignal,
27
30
  ensureRunnerDir,
28
31
  readActiveLoopRegistry,
29
32
  readIterationRecords,
@@ -52,6 +55,8 @@ export type RunnerConfig = {
52
55
  cwd: string;
53
56
  timeout: number;
54
57
  maxIterations: number;
58
+ /** Error policy: true = stop on error (default), false = continue on error */
59
+ stopOnError?: boolean;
55
60
  /** Completion promise string from RALPH.md */
56
61
  completionPromise?: string;
57
62
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
@@ -412,6 +417,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
412
417
  pi,
413
418
  runtimeArgs: initialRuntimeArgs = {},
414
419
  } = config;
420
+ let currentStopOnError = config.stopOnError ?? true;
415
421
  const runtimeArgs = initialRuntimeArgs;
416
422
 
417
423
  const taskDir = dirname(ralphPath);
@@ -495,6 +501,13 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
495
501
  break;
496
502
  }
497
503
 
504
+ if (checkCancelSignal(taskDir)) {
505
+ recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
506
+ clearCancelSignal(taskDir);
507
+ finalStatus = "cancelled";
508
+ break;
509
+ }
510
+
498
511
  // Re-parse RALPH.md every iteration (live editing support)
499
512
  if (!existsSync(ralphPath)) {
500
513
  onNotify?.(`RALPH.md not found at ${ralphPath}, stopping runner`, "error");
@@ -523,6 +536,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
523
536
  currentRequiredOutputs = fm.requiredOutputs ?? [];
524
537
  currentInterIterationDelay = fm.interIterationDelay;
525
538
  currentGuardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
539
+ currentStopOnError = config.stopOnError ?? fm.stopOnError;
526
540
 
527
541
  // Update status to running
528
542
  const runningStatus: RunnerStatusFile = {
@@ -540,6 +554,7 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
540
554
  onIterationStart?.(i, currentMaxIterations);
541
555
 
542
556
  const iterStartMs = Date.now();
557
+ const iterationAbortController = new AbortController();
543
558
  const completionRecord = currentCompletionPromise ? createCompletionRecord() : undefined;
544
559
  logRunnerEvent(taskDir, {
545
560
  type: "iteration.started",
@@ -577,29 +592,75 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
577
592
  // Run RPC iteration
578
593
  onNotify?.(`Iteration ${i}/${currentMaxIterations} starting`, "info");
579
594
 
580
- const rpcResult = await runRpcIteration({
581
- prompt,
582
- cwd,
583
- timeoutMs: currentTimeout * 1000,
584
- spawnCommand,
585
- spawnArgs,
586
- env: {
587
- RALPH_RUNNER_TASK_DIR: taskDir,
588
- RALPH_RUNNER_CWD: cwd,
589
- RALPH_RUNNER_LOOP_TOKEN: loopToken,
590
- RALPH_RUNNER_CURRENT_ITERATION: String(i),
591
- RALPH_RUNNER_MAX_ITERATIONS: String(currentMaxIterations),
592
- RALPH_RUNNER_NO_PROGRESS_STREAK: String(noProgressStreak),
593
- RALPH_RUNNER_GUARDRAILS: JSON.stringify(currentGuardrails),
594
- },
595
- modelPattern: config.modelPattern,
596
- provider: config.provider,
597
- modelId: config.modelId,
598
- thinkingLevel: config.thinkingLevel,
599
- });
595
+ const cancelPollInterval = setInterval(() => {
596
+ if (checkCancelSignal(taskDir)) {
597
+ iterationAbortController.abort();
598
+ }
599
+ }, 500);
600
+
601
+ let rpcResult: RpcSubprocessResult;
602
+ try {
603
+ rpcResult = await runRpcIteration({
604
+ prompt,
605
+ cwd,
606
+ timeoutMs: currentTimeout * 1000,
607
+ spawnCommand,
608
+ spawnArgs,
609
+ env: {
610
+ RALPH_RUNNER_TASK_DIR: taskDir,
611
+ RALPH_RUNNER_CWD: cwd,
612
+ RALPH_RUNNER_LOOP_TOKEN: loopToken,
613
+ RALPH_RUNNER_CURRENT_ITERATION: String(i),
614
+ RALPH_RUNNER_MAX_ITERATIONS: String(currentMaxIterations),
615
+ RALPH_RUNNER_NO_PROGRESS_STREAK: String(noProgressStreak),
616
+ RALPH_RUNNER_GUARDRAILS: JSON.stringify(currentGuardrails),
617
+ },
618
+ modelPattern: config.modelPattern,
619
+ provider: config.provider,
620
+ modelId: config.modelId,
621
+ thinkingLevel: config.thinkingLevel,
622
+ signal: iterationAbortController.signal,
623
+ });
624
+ } finally {
625
+ clearInterval(cancelPollInterval);
626
+ }
600
627
 
601
628
  const iterEndMs = Date.now();
602
629
 
630
+ if (rpcResult.cancelled) {
631
+ const iterRecord: IterationRecord = {
632
+ iteration: i,
633
+ status: "cancelled",
634
+ startedAt: new Date(iterStartMs).toISOString(),
635
+ completedAt: new Date(iterEndMs).toISOString(),
636
+ durationMs: iterEndMs - iterStartMs,
637
+ progress: false,
638
+ changedFiles: [],
639
+ noProgressStreak: noProgressStreak + 1,
640
+ loopToken,
641
+ rpcTelemetry: rpcResult.telemetry,
642
+ };
643
+ iterations.push(iterRecord);
644
+ appendIterationRecord(taskDir, iterRecord);
645
+ writeIterationTranscriptSafe(iterRecord, rpcResult.lastAssistantText, "Iteration cancelled by operator");
646
+ logRunnerEvent(taskDir, {
647
+ type: "iteration.completed",
648
+ timestamp: new Date(iterEndMs).toISOString(),
649
+ iteration: i,
650
+ loopToken,
651
+ status: "cancelled",
652
+ progress: iterRecord.progress,
653
+ changedFiles: [],
654
+ noProgressStreak: iterRecord.noProgressStreak,
655
+ reason: "operator-cancel",
656
+ });
657
+ recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
658
+ clearCancelSignal(taskDir);
659
+ finalStatus = "cancelled";
660
+ onIterationComplete?.(iterRecord);
661
+ break;
662
+ }
663
+
603
664
  // Handle RPC failure
604
665
  if (!rpcResult.success) {
605
666
  const iterRecord: IterationRecord = {
@@ -639,13 +700,29 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
639
700
 
640
701
  if (rpcResult.timedOut) {
641
702
  onNotify?.(`Iteration ${i} timed out after ${currentTimeout}s`, "warning");
642
- finalStatus = "timeout";
703
+ if (currentStopOnError) {
704
+ finalStatus = "timeout";
705
+ onIterationComplete?.(iterRecord);
706
+ break;
707
+ } else {
708
+ noProgressStreak += 1;
709
+ onNotify?.(`Continuing (stop_on_error=false).`, "warning");
710
+ onIterationComplete?.(iterRecord);
711
+ continue;
712
+ }
643
713
  } else {
644
714
  onNotify?.(`Iteration ${i} error: ${rpcResult.error ?? "unknown"}`, "error");
645
- finalStatus = "error";
715
+ if (currentStopOnError) {
716
+ finalStatus = "error";
717
+ onIterationComplete?.(iterRecord);
718
+ break;
719
+ } else {
720
+ noProgressStreak += 1;
721
+ onNotify?.(`Continuing (stop_on_error=false).`, "warning");
722
+ onIterationComplete?.(iterRecord);
723
+ continue;
724
+ }
646
725
  }
647
- onIterationComplete?.(iterRecord);
648
- break;
649
726
  }
650
727
 
651
728
  // After snapshot
@@ -878,6 +955,14 @@ export async function runRalphLoop(config: RunnerConfig): Promise<RunnerResult>
878
955
 
879
956
  onNotify?.(`Iteration ${i} complete (${Math.round((iterEndMs - iterStartMs) / 1000)}s)`, "info");
880
957
 
958
+ // Quick cancel check before delay
959
+ if (checkCancelSignal(taskDir)) {
960
+ recordActiveLoopStopObservation(cwd, taskDir, new Date().toISOString());
961
+ clearCancelSignal(taskDir);
962
+ finalStatus = "cancelled";
963
+ break;
964
+ }
965
+
881
966
  if (i < currentMaxIterations && currentInterIterationDelay > 0) {
882
967
  const stoppedDuringDelay = await waitForInterIterationDelay(taskDir, cwd, currentInterIterationDelay);
883
968
  if (stoppedDuringDelay) {
@@ -1,11 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
- import registerRalphCommands, { runCommands } from "../src/index.ts";
6
+ import registerRalphCommands, { parseLogExportArgs, runCommands } from "../src/index.ts";
7
7
  import { SECRET_PATH_POLICY_TOKEN } from "../src/secret-paths.ts";
8
- import { generateDraft, parseRalphMarkdown, slugifyTask, validateFrontmatter, type DraftPlan, type DraftTarget } from "../src/ralph.ts";
8
+ import { generateDraft, inspectDraftContent, parseRalphMarkdown, slugifyTask, validateFrontmatter, type DraftPlan, type DraftTarget } from "../src/ralph.ts";
9
9
  import type { StrengthenDraftRuntime } from "../src/ralph-draft-llm.ts";
10
10
  import type { RunnerConfig, RunnerResult } from "../src/runner.ts";
11
11
  import { runRalphLoop as realRunRalphLoop, captureTaskDirectorySnapshot, assessTaskDirectoryProgress, summarizeChangedFiles } from "../src/runner.ts";
@@ -398,7 +398,7 @@ test("registerRalphCommands is idempotent for the same extension API instance",
398
398
  registerRalphCommands(pi, {} as any);
399
399
  registerRalphCommands(pi, {} as any);
400
400
 
401
- assert.deepEqual(registeredCommands, ["ralph", "ralph-draft", "ralph-stop"]);
401
+ assert.deepEqual(registeredCommands, ["ralph", "ralph-draft", "ralph-stop", "ralph-cancel", "ralph-scaffold", "ralph-logs"]);
402
402
  assert.deepEqual(registeredEvents, [
403
403
  "tool_call",
404
404
  "tool_execution_start",
@@ -574,6 +574,462 @@ test("/ralph-stop writes the durable stop flag from persisted active loop state
574
574
  assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
575
575
  });
576
576
 
577
+ test("/ralph-cancel writes the cancel flag from persisted active loop state after reload", async (t) => {
578
+ const cwd = createTempDir();
579
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
580
+
581
+ const taskDir = join(cwd, "persisted-loop-task");
582
+ mkdirSync(taskDir, { recursive: true });
583
+ const persistedState = {
584
+ active: true,
585
+ loopToken: "persisted-loop-token",
586
+ cwd,
587
+ taskDir,
588
+ iteration: 3,
589
+ maxIterations: 5,
590
+ noProgressStreak: 0,
591
+ iterationSummaries: [],
592
+ guardrails: { blockCommands: [], protectedFiles: [] },
593
+ stopRequested: false,
594
+ };
595
+ writeStatusFile(taskDir, {
596
+ loopToken: persistedState.loopToken,
597
+ ralphPath: join(taskDir, "RALPH.md"),
598
+ taskDir,
599
+ cwd,
600
+ status: "running",
601
+ currentIteration: 3,
602
+ maxIterations: 5,
603
+ timeout: 300,
604
+ startedAt: new Date().toISOString(),
605
+ guardrails: { blockCommands: [], protectedFiles: [] },
606
+ });
607
+ const notifications: Array<{ message: string; level: string }> = [];
608
+ const harness = createHarness();
609
+ const handler = harness.handler("ralph-cancel");
610
+ let ctx: any;
611
+ ctx = {
612
+ cwd,
613
+ hasUI: true,
614
+ ui: {
615
+ notify: (message: string, level: string) => notifications.push({ message, level }),
616
+ select: async () => undefined,
617
+ input: async () => undefined,
618
+ editor: async () => undefined,
619
+ setStatus: () => undefined,
620
+ },
621
+ sessionManager: createSessionManager([
622
+ {
623
+ type: "custom",
624
+ customType: "ralph-loop-state",
625
+ data: persistedState,
626
+ },
627
+ ], "session-a"),
628
+ getRuntimeCtx: () => ctx,
629
+ };
630
+
631
+ await handler("", ctx);
632
+
633
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), true);
634
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "stop.flag")), false);
635
+ assert.ok(notifications.some(({ message }) => message.includes("Cancel requested. The active iteration will be terminated immediately.")));
636
+ assert.equal(notifications.some(({ message }) => message.includes("No active ralph loop")), false);
637
+ });
638
+
639
+ test("/ralph-cancel refuses when the loop already finished", async (t) => {
640
+ const cwd = createTempDir();
641
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
642
+
643
+ const taskDir = join(cwd, "finished-loop-task");
644
+ mkdirSync(taskDir, { recursive: true });
645
+ writeStatusFile(taskDir, {
646
+ loopToken: "finished-loop-token",
647
+ ralphPath: join(taskDir, "RALPH.md"),
648
+ taskDir,
649
+ cwd,
650
+ status: "complete",
651
+ currentIteration: 3,
652
+ maxIterations: 5,
653
+ timeout: 300,
654
+ startedAt: new Date().toISOString(),
655
+ completedAt: new Date().toISOString(),
656
+ guardrails: { blockCommands: [], protectedFiles: [] },
657
+ });
658
+ const notifications: Array<{ message: string; level: string }> = [];
659
+ const harness = createHarness();
660
+ const handler = harness.handler("ralph-cancel");
661
+ let ctx: any;
662
+ ctx = {
663
+ cwd,
664
+ hasUI: true,
665
+ ui: {
666
+ notify: (message: string, level: string) => notifications.push({ message, level }),
667
+ select: async () => undefined,
668
+ input: async () => undefined,
669
+ editor: async () => undefined,
670
+ setStatus: () => undefined,
671
+ },
672
+ sessionManager: createSessionManager([
673
+ {
674
+ type: "custom",
675
+ customType: "ralph-loop-state",
676
+ data: {
677
+ active: true,
678
+ loopToken: "finished-loop-token",
679
+ cwd,
680
+ taskDir,
681
+ iteration: 3,
682
+ maxIterations: 5,
683
+ noProgressStreak: 0,
684
+ iterationSummaries: [],
685
+ guardrails: { blockCommands: [], protectedFiles: [] },
686
+ stopRequested: false,
687
+ },
688
+ },
689
+ ], "session-a"),
690
+ getRuntimeCtx: () => ctx,
691
+ };
692
+
693
+ await handler("", ctx);
694
+
695
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
696
+ assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("The loop already ended with status: complete.")));
697
+ });
698
+
699
+ test("/ralph-cancel refuses when no status.json exists", async (t) => {
700
+ const cwd = createTempDir();
701
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
702
+
703
+ const taskDir = join(cwd, "missing-status-task");
704
+ mkdirSync(taskDir, { recursive: true });
705
+ const notifications: Array<{ message: string; level: string }> = [];
706
+ const harness = createHarness();
707
+ const handler = harness.handler("ralph-cancel");
708
+ let ctx: any;
709
+ ctx = {
710
+ cwd,
711
+ hasUI: true,
712
+ ui: {
713
+ notify: (message: string, level: string) => notifications.push({ message, level }),
714
+ select: async () => undefined,
715
+ input: async () => undefined,
716
+ editor: async () => undefined,
717
+ setStatus: () => undefined,
718
+ },
719
+ sessionManager: createSessionManager([
720
+ {
721
+ type: "custom",
722
+ customType: "ralph-loop-state",
723
+ data: {
724
+ active: true,
725
+ loopToken: "missing-status-loop-token",
726
+ cwd,
727
+ taskDir,
728
+ iteration: 3,
729
+ maxIterations: 5,
730
+ noProgressStreak: 0,
731
+ iterationSummaries: [],
732
+ guardrails: { blockCommands: [], protectedFiles: [] },
733
+ stopRequested: false,
734
+ },
735
+ },
736
+ ], "session-a"),
737
+ getRuntimeCtx: () => ctx,
738
+ };
739
+
740
+ await handler("", ctx);
741
+
742
+ assert.equal(existsSync(join(taskDir, ".ralph-runner", "cancel.flag")), false);
743
+ assert.ok(notifications.some(({ message, level }) => level === "warning" && message.includes("No run data exists.")));
744
+ });
745
+
746
+ test("/ralph-scaffold creates a parseable scaffold from a task name", async (t) => {
747
+ const cwd = createTempDir();
748
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
749
+
750
+ const notifications: Array<{ message: string; level: string }> = [];
751
+ const harness = createHarness();
752
+ const handler = harness.handler("ralph-scaffold");
753
+ const ctx = {
754
+ cwd,
755
+ hasUI: true,
756
+ ui: {
757
+ notify: (message: string, level: string) => notifications.push({ message, level }),
758
+ select: async () => undefined,
759
+ input: async () => undefined,
760
+ editor: async () => undefined,
761
+ setStatus: () => undefined,
762
+ },
763
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
764
+ };
765
+
766
+ await handler("my-task", ctx);
767
+
768
+ const ralphPath = join(cwd, "my-task", "RALPH.md");
769
+ assert.equal(existsSync(ralphPath), true);
770
+ const inspection = inspectDraftContent(readFileSync(ralphPath, "utf8"));
771
+ assert.equal(inspection.error, undefined);
772
+ assert.equal(inspection.parsed?.frontmatter.maxIterations, 10);
773
+ assert.equal(inspection.parsed?.frontmatter.timeout, 120);
774
+ assert.deepEqual(inspection.parsed?.frontmatter.commands, []);
775
+ assert.ok(notifications.some(({ message, level }) => level === "info" && message.includes("Scaffolded")));
776
+ });
777
+
778
+ test("/ralph-scaffold accepts path-style arguments", async (t) => {
779
+ const cwd = createTempDir();
780
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
781
+
782
+ const harness = createHarness();
783
+ const handler = harness.handler("ralph-scaffold");
784
+ const ctx = {
785
+ cwd,
786
+ hasUI: true,
787
+ ui: {
788
+ notify: () => undefined,
789
+ select: async () => undefined,
790
+ input: async () => undefined,
791
+ editor: async () => undefined,
792
+ setStatus: () => undefined,
793
+ },
794
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
795
+ };
796
+
797
+ await handler("feature/new-task", ctx);
798
+
799
+ assert.equal(existsSync(join(cwd, "feature", "new-task", "RALPH.md")), true);
800
+ });
801
+
802
+ test("/ralph-scaffold rejects paths outside the current working directory", async (t) => {
803
+ const cwd = createTempDir();
804
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
805
+
806
+ const escapedTaskDir = join(cwd, "..", "escape");
807
+ t.after(() => rmSync(escapedTaskDir, { recursive: true, force: true }));
808
+
809
+ const notifications: Array<{ message: string; level: string }> = [];
810
+ const harness = createHarness();
811
+ const handler = harness.handler("ralph-scaffold");
812
+ const ctx = {
813
+ cwd,
814
+ hasUI: true,
815
+ ui: {
816
+ notify: (message: string, level: string) => notifications.push({ message, level }),
817
+ select: async () => undefined,
818
+ input: async () => undefined,
819
+ editor: async () => undefined,
820
+ setStatus: () => undefined,
821
+ },
822
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
823
+ };
824
+
825
+ await handler("../escape", ctx);
826
+
827
+ assert.equal(existsSync(join(escapedTaskDir, "RALPH.md")), false);
828
+ assert.deepEqual(notifications, [{ message: "Task path must be within the current working directory.", level: "error" }]);
829
+ });
830
+
831
+ test("/ralph-scaffold refuses to overwrite an existing RALPH.md", async (t) => {
832
+ const cwd = createTempDir();
833
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
834
+
835
+ const taskDir = join(cwd, "my-task");
836
+ mkdirSync(taskDir, { recursive: true });
837
+ const ralphPath = join(taskDir, "RALPH.md");
838
+ writeFileSync(ralphPath, "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
839
+
840
+ const notifications: Array<{ message: string; level: string }> = [];
841
+ const harness = createHarness();
842
+ const handler = harness.handler("ralph-scaffold");
843
+ const ctx = {
844
+ cwd,
845
+ hasUI: true,
846
+ ui: {
847
+ notify: (message: string, level: string) => notifications.push({ message, level }),
848
+ select: async () => undefined,
849
+ input: async () => undefined,
850
+ editor: async () => undefined,
851
+ setStatus: () => undefined,
852
+ },
853
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
854
+ };
855
+
856
+ await handler("my-task", ctx);
857
+
858
+ assert.equal(readFileSync(ralphPath, "utf8"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n");
859
+ assert.ok(notifications.some(({ message, level }) => level === "error" && message.includes("already exists at")));
860
+ });
861
+
862
+ test("/ralph-scaffold requires a task name", async (t) => {
863
+ const cwd = createTempDir();
864
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
865
+
866
+ const notifications: Array<{ message: string; level: string }> = [];
867
+ const harness = createHarness();
868
+ const handler = harness.handler("ralph-scaffold");
869
+ const ctx = {
870
+ cwd,
871
+ hasUI: true,
872
+ ui: {
873
+ notify: (message: string, level: string) => notifications.push({ message, level }),
874
+ select: async () => undefined,
875
+ input: async () => undefined,
876
+ editor: async () => undefined,
877
+ setStatus: () => undefined,
878
+ },
879
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
880
+ };
881
+
882
+ await handler(" ", ctx);
883
+
884
+ assert.deepEqual(notifications, [{ message: "/ralph-scaffold expects a task name or path.", level: "error" }]);
885
+ });
886
+
887
+ test("/ralph-logs exports artifacts from a task with .ralph-runner/", async (t) => {
888
+ const cwd = createTempDir();
889
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
890
+
891
+ const taskDir = join(cwd, "my-task");
892
+ mkdirSync(join(taskDir, ".ralph-runner", "transcripts"), { recursive: true });
893
+ writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
894
+ writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
895
+ writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n{\"iteration\":2}\n", "utf8");
896
+ writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
897
+ writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "one.txt"), "one", "utf8");
898
+ writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "two.txt"), "two", "utf8");
899
+
900
+ const notifications: Array<{ message: string; level: string }> = [];
901
+ const harness = createHarness();
902
+ const handler = harness.handler("ralph-logs");
903
+ const ctx = {
904
+ cwd,
905
+ hasUI: true,
906
+ ui: {
907
+ notify: (message: string, level: string) => notifications.push({ message, level }),
908
+ select: async () => undefined,
909
+ input: async () => undefined,
910
+ editor: async () => undefined,
911
+ setStatus: () => undefined,
912
+ },
913
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
914
+ };
915
+
916
+ await handler("my-task --dest exported", ctx);
917
+
918
+ const exportedDir = join(cwd, "exported");
919
+ assert.equal(existsSync(join(exportedDir, "status.json")), true);
920
+ assert.equal(readFileSync(join(exportedDir, "iterations.jsonl"), "utf8"), "{\"iteration\":1}\n{\"iteration\":2}\n");
921
+ assert.equal(readFileSync(join(exportedDir, "events.jsonl"), "utf8"), "{\"event\":1}\n");
922
+ assert.equal(readFileSync(join(exportedDir, "transcripts", "one.txt"), "utf8"), "one");
923
+ assert.equal(readFileSync(join(exportedDir, "transcripts", "two.txt"), "utf8"), "two");
924
+ assert.ok(notifications.some(({ message, level }) => level === "info" && message.includes("Exported 2 iteration records, 1 events, 2 transcripts to ./exported")));
925
+ });
926
+
927
+ test("/ralph-logs fails when no .ralph-runner/ exists", async (t) => {
928
+ const cwd = createTempDir();
929
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
930
+
931
+ const taskDir = join(cwd, "my-task");
932
+ mkdirSync(taskDir, { recursive: true });
933
+ writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
934
+
935
+ const notifications: Array<{ message: string; level: string }> = [];
936
+ const harness = createHarness();
937
+ const handler = harness.handler("ralph-logs");
938
+ const ctx = {
939
+ cwd,
940
+ hasUI: true,
941
+ ui: {
942
+ notify: (message: string, level: string) => notifications.push({ message, level }),
943
+ select: async () => undefined,
944
+ input: async () => undefined,
945
+ editor: async () => undefined,
946
+ setStatus: () => undefined,
947
+ },
948
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
949
+ };
950
+
951
+ await handler("my-task", ctx);
952
+
953
+ assert.ok(notifications.some(({ message, level }) => level === "error" && message.startsWith("Log export failed: No .ralph-runner directory found at ")));
954
+ });
955
+
956
+ test("parseLogExportArgs parses --dest correctly", () => {
957
+ assert.deepEqual(parseLogExportArgs("my-task --dest exported"), { path: "my-task", dest: "exported" });
958
+ });
959
+
960
+ test("/ralph-logs excludes runtime control files", async (t) => {
961
+ const cwd = createTempDir();
962
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
963
+
964
+ const taskDir = join(cwd, "my-task");
965
+ mkdirSync(join(taskDir, ".ralph-runner", "active-loops"), { recursive: true });
966
+ writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
967
+ writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
968
+ writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n", "utf8");
969
+ writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
970
+ writeFileSync(join(taskDir, ".ralph-runner", "stop.flag"), "", "utf8");
971
+ writeFileSync(join(taskDir, ".ralph-runner", "cancel.flag"), "", "utf8");
972
+ writeFileSync(join(taskDir, ".ralph-runner", "active-loops", "nested.txt"), "skip me", "utf8");
973
+
974
+ const harness = createHarness();
975
+ const handler = harness.handler("ralph-logs");
976
+ const ctx = {
977
+ cwd,
978
+ hasUI: true,
979
+ ui: {
980
+ notify: () => undefined,
981
+ select: async () => undefined,
982
+ input: async () => undefined,
983
+ editor: async () => undefined,
984
+ setStatus: () => undefined,
985
+ },
986
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
987
+ };
988
+
989
+ await handler("my-task --dest exported", ctx);
990
+
991
+ const exportedDir = join(cwd, "exported");
992
+ assert.equal(existsSync(join(exportedDir, "stop.flag")), false);
993
+ assert.equal(existsSync(join(exportedDir, "cancel.flag")), false);
994
+ assert.equal(existsSync(join(exportedDir, "active-loops")), false);
995
+ });
996
+
997
+ test("/ralph-logs skips symlinked transcript entries", async (t) => {
998
+ const cwd = createTempDir();
999
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
1000
+
1001
+ const taskDir = join(cwd, "my-task");
1002
+ mkdirSync(join(taskDir, ".ralph-runner", "transcripts"), { recursive: true });
1003
+ writeFileSync(join(taskDir, "RALPH.md"), "---\nmax_iterations: 10\ntimeout: 120\ncommands: []\n---\n# my-task\n", "utf8");
1004
+ writeFileSync(join(taskDir, ".ralph-runner", "status.json"), JSON.stringify({ status: "running" }), "utf8");
1005
+ writeFileSync(join(taskDir, ".ralph-runner", "iterations.jsonl"), "{\"iteration\":1}\n", "utf8");
1006
+ writeFileSync(join(taskDir, ".ralph-runner", "events.jsonl"), "{\"event\":1}\n", "utf8");
1007
+ writeFileSync(join(taskDir, "secret.txt"), "top secret", "utf8");
1008
+ writeFileSync(join(taskDir, ".ralph-runner", "transcripts", "good.txt"), "good", "utf8");
1009
+ symlinkSync(join(taskDir, "secret.txt"), join(taskDir, ".ralph-runner", "transcripts", "leak.txt"));
1010
+
1011
+ const harness = createHarness();
1012
+ const handler = harness.handler("ralph-logs");
1013
+ const ctx = {
1014
+ cwd,
1015
+ hasUI: true,
1016
+ ui: {
1017
+ notify: () => undefined,
1018
+ select: async () => undefined,
1019
+ input: async () => undefined,
1020
+ editor: async () => undefined,
1021
+ setStatus: () => undefined,
1022
+ },
1023
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
1024
+ };
1025
+
1026
+ await handler("my-task --dest exported", ctx);
1027
+
1028
+ const exportedDir = join(cwd, "exported");
1029
+ assert.equal(existsSync(join(exportedDir, "transcripts", "good.txt")), true);
1030
+ assert.equal(existsSync(join(exportedDir, "transcripts", "leak.txt")), false);
1031
+ });
1032
+
577
1033
  test("/ralph reverse engineer this app with an injected llm-strengthened draft still shows review before start", async (t) => {
578
1034
  const cwd = createTempDir();
579
1035
  t.after(() => rmSync(cwd, { recursive: true, force: true }));