opencode-ralph-rlm 0.1.12 → 0.1.14

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.
Files changed (3) hide show
  1. package/README.md +38 -42
  2. package/dist/ralph-rlm.js +102 -101
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,8 +6,8 @@ New here? Start with [`GETTINGSTARTEDGUIDE.md`](GETTINGSTARTEDGUIDE.md).
6
6
 
7
7
  Two techniques combine to make this work:
8
8
 
9
- - **Ralph** a strategist session spawned fresh per attempt. It reviews what failed, adjusts the plan and instructions, then delegates coding to a worker. It never writes code itself.
10
- - **RLM** (Recursive Language Model worker) a file-first coding session based on [arXiv:2512.24601](https://arxiv.org/abs/2512.24601). Each attempt gets a clean context window and loads all state from files rather than inheriting noise from prior turns.
9
+ - **Ralph** - the main session acting as strategist+supervisor. It reviews what failed, adjusts the plan and instructions, then delegates coding to a worker. It never writes code itself.
10
+ - **RLM** (Recursive Language Model worker) - a file-first coding session based on [arXiv:2512.24601](https://arxiv.org/abs/2512.24601). Each attempt gets a clean context window and loads all state from files rather than inheriting noise from prior turns.
11
11
 
12
12
 
13
13
  ## The problem this solves
@@ -43,9 +43,9 @@ Context windows are session-local and finite. Files are persistent, inspectable,
43
43
 
44
44
  ### Separation of strategy and execution
45
45
 
46
- The Ralph strategist session exists because mixing strategy and execution in the same context is how reasoning degrades. When a session that just wrote failing code is also responsible for diagnosing *why* it failed and planning the next approach, it pattern-matches against its own failed reasoning. It proposes variations on what didn't work rather than stepping back.
46
+ Ralph keeps strategy and execution separate: **you (main session)** handle strategy and delegation, while **workers** implement. When a session that just wrote failing code is also responsible for diagnosing *why* it failed and planning the next approach, it pattern-matches against its own failed reasoning. It proposes variations on what didn't work rather than stepping back.
47
47
 
48
- Ralph's session gets a fresh window. It reads the failure record cold, without the accumulated baggage of having written the code. This mirrors how experienced engineering teams work: the reviewer of a failing PR is often not the one who writes the fix.
48
+ In this mode, the *worker* always gets a fresh window. It reads the failure record cold, without the accumulated baggage of having written the code. This mirrors how experienced engineering teams work: the reviewer of a failing PR is often not the one who writes the fix.
49
49
 
50
50
  ### The verify contract
51
51
 
@@ -63,52 +63,48 @@ The RLM paper demonstrates that full-file reads are expensive and often counterp
63
63
 
64
64
  `NOTES_AND_LEARNINGS.md` and `RLM_INSTRUCTIONS.md` are the loop's long-term memory. They survive context resets and accumulate across attempts. The loop doesn't just retry — it gets smarter with each failure.
65
65
 
66
- `RLM_INSTRUCTIONS.md` is the inner loop's operating manual. The Ralph strategist updates it between attempts when a pattern of failures reveals a gap in guidance. By attempt 10, the instructions encode everything learned from attempts 1-9.
66
+ `RLM_INSTRUCTIONS.md` is the inner loop's operating manual. The main strategist (you) updates it between attempts when a pattern of failures reveals a gap in guidance. By attempt 10, the instructions encode everything learned from attempts 1-9.
67
67
 
68
68
  This is why the approach scales to overnight runs. A fresh worker in attempt 10 starts with the accumulated knowledge of 9 prior attempts, encoded in protocol files, without the accumulated noise.
69
69
 
70
70
 
71
71
  ## How it works
72
72
 
73
- ### Three-level architecture
73
+ ### Two-level architecture (main session = strategist + supervisor)
74
74
 
75
75
  ```
76
- You → main session (thin meta-supervisor your conversation)
76
+ You → main session (supervisor + strategist in one)
77
77
 
78
78
  ├─ attempt 1:
79
- │ ├─ spawns Ralph strategist session R1 ← fresh context
80
- │ R1: ralph_load_context() → review failures update PLAN.md
81
- │ │ → ralph_spawn_worker() → STOP
79
+ │ ├─ strategist (you): ralph_load_context() review failures → update PLAN.md
80
+ ├─ ralph_spawn_worker() → spawns RLM worker session W1
82
81
  │ │
83
- │ └─ spawns RLM worker session W1 ← fresh context
82
+ │ └─ RLM worker session W1 ← fresh context
84
83
  │ W1: ralph_load_context() → code → ralph_verify() → STOP
85
84
 
86
85
  ├─ plugin verifies on W1 idle
87
86
  │ fail → roll state files → spawn attempt 2
88
87
 
89
88
  ├─ attempt 2:
90
- │ ├─ spawns Ralph strategist session R2 ← fresh context again
91
- │ R2: reads AGENT_CONTEXT_FOR_NEXT_RALPH.md adjusts strategy
92
- │ │ → ralph_spawn_worker() → STOP
89
+ │ ├─ strategist (you): reads AGENT_CONTEXT_FOR_NEXT_RALPH.md adjusts strategy
90
+ ├─ ralph_spawn_worker()spawns RLM worker session W2
93
91
  │ │
94
- │ └─ spawns RLM worker session W2 ← fresh context
92
+ │ └─ RLM worker session W2 ← fresh context
95
93
  │ W2: loads compact state from files → code → STOP
96
94
 
97
95
  └─ pass → done toast
98
96
  ```
99
97
 
100
- Each session role has a distinct purpose and **fresh context window**:
98
+ Each role has a distinct purpose and **fresh context window** where applicable:
101
99
 
102
100
  | Role | Session | Context | Responsibility |
103
101
  |---|---|---|---|
104
- | **main** | Your conversation | Persistent | Goal stop. Plugin handles the rest. |
105
- | **ralph** | Per-attempt strategist | Fresh | Review failure, update PLAN.md / RLM_INSTRUCTIONS.md, call `ralph_spawn_worker()`. |
102
+ | **main** | Your conversation | Persistent | Supervisor + strategist. Review failures, update PLAN.md / RLM_INSTRUCTIONS.md, call `ralph_spawn_worker()`. |
106
103
  | **worker** | Per-attempt coder | Fresh | `ralph_load_context()` → code → `ralph_verify()` → stop. |
107
104
 
108
105
  ### Roles and responsibilities (quick map)
109
106
 
110
- - **Supervisor (main session):** orchestration and decisions only. Never edits files or runs code; uses `ralph_*` tools to control lifecycle.
111
- - **Ralph strategist:** updates plan/instructions and delegates to a worker. No direct implementation.
107
+ - **Supervisor+strategist (main session):** orchestration, planning, and delegation. Never edits files or runs code directly.
112
108
  - **RLM worker:** does the actual coding and verification for this attempt. One pass per session.
113
109
  - **Sub-agent:** narrow task helper; updates its own state files under `.opencode/agents/<name>/`.
114
110
 
@@ -116,15 +112,13 @@ Each session role has a distinct purpose and **fresh context window**:
116
112
 
117
113
  ```
118
114
  main idle
119
- └─ spawn Ralph(1)
120
- └─ Ralph(1) calls ralph_spawn_worker()
121
- └─ spawn Worker(1)
122
- └─ Worker(1) calls ralph_verify() and goes idle
123
- └─ plugin runs verify
124
- ├─ passdone
125
- └─ fail roll state files
126
- └─ spawn Ralph(2)
127
- └─ (repeat)
115
+ └─ strategist (you) calls ralph_spawn_worker()
116
+ └─ spawn Worker(1)
117
+ └─ Worker(1) calls ralph_verify() and goes idle
118
+ └─ plugin runs verify
119
+ ├─ pass done
120
+ └─ failroll state files
121
+ └─ strategist (you) handles next attempt
128
122
  ```
129
123
 
130
124
  The plugin drives the loop from `session.idle` events. Neither Ralph nor the worker need to know about the outer loop — they just load context, do their job, and stop.
@@ -228,6 +222,8 @@ Create `.opencode/ralph.json`. All fields are optional — the plugin runs with
228
222
  | `statusVerbosity` | `"normal"` | Supervisor status emission level: `minimal` (warnings/errors), `normal`, or `verbose`. |
229
223
  | `maxAttempts` | `20` | Hard stop after this many failed verify attempts. |
230
224
  | `heartbeatMinutes` | `15` | Warn if active strategist/worker has no progress for this many minutes. |
225
+ | `strategistHandoffMinutes` | `5` | Warn/retry if the strategist does not spawn a worker within this many minutes. |
226
+ | `strategistHandoffMaxRetries` | `2` | Max retries to respawn strategist after a missed handoff. |
231
227
  | `verifyTimeoutMinutes` | `0` | Timeout for verify command in minutes. `0` disables timeouts. |
232
228
  | `verify.command` | - | Shell command to run as an array, e.g. `["bun", "run", "verify"]`. If omitted, verify always returns `unknown`. |
233
229
  | `verify.cwd` | `"."` | Working directory for the verify command, relative to the repo root. |
@@ -293,7 +289,7 @@ This repo now includes project-local agent files under `.opencode/agents/`:
293
289
  - `.opencode/agents/security-auditor.md`
294
290
 
295
291
  These profiles intentionally keep loop ownership in `ralph-rlm`.
296
- Do not model Ralph strategist/worker as OpenCode primary/subagent replacements.
292
+ Do not model the strategist/worker roles as OpenCode primary/subagent replacements.
297
293
 
298
294
 
299
295
  ## Protocol files
@@ -522,9 +518,9 @@ Run the configured verify command. Returns `{ verdict: "pass"|"fail"|"unknown",
522
518
 
523
519
  #### `ralph_spawn_worker()`
524
520
 
525
- **Ralph strategist sessions only.** Spawn a fresh RLM worker session for this attempt. Call this after reviewing protocol files and optionally updating `PLAN.md` / `RLM_INSTRUCTIONS.md`. Then stop — the plugin handles verification and spawns the next Ralph session if needed.
521
+ **Main strategist only.** Spawn a fresh RLM worker session for this attempt. Call this after reviewing protocol files and optionally updating `PLAN.md` / `RLM_INSTRUCTIONS.md`. Then stop — the plugin handles verification and prompts you for the next attempt if needed.
526
522
 
527
- If you call this from the main conversation you will get: `ralph_spawn_worker() can only be called from a Ralph strategist session.` In normal operation the plugin creates strategist sessions automatically on `session.idle`.
523
+ If you call this from an unbound session you will get: `ralph_spawn_worker() must be called from the bound supervisor session.` Bind first with `ralph_create_supervisor_session()`.
528
524
 
529
525
  ### Sub-agents
530
526
 
@@ -546,7 +542,7 @@ List all sub-agents registered in the current session with their name, goal, sta
546
542
 
547
543
  ### Supervisor communication
548
544
 
549
- These tools let spawned sessions (Ralph strategist, RLM worker) communicate back to the main conversation at runtime. State is carried in `.opencode/pending_input.json` for question/response pairs, `SUPERVISOR_LOG.md` for structured status entries, and `CONVERSATION.md` for the readable event timeline.
545
+ These tools let spawned sessions (RLM worker + sub-agents) communicate back to the main conversation at runtime. State is carried in `.opencode/pending_input.json` for question/response pairs, `SUPERVISOR_LOG.md` for structured status entries, and `CONVERSATION.md` for the readable event timeline.
550
546
 
551
547
  User answers to `ralph_ask()` are persisted too: when you reply via `ralph_respond()`, the response is appended to `CONVERSATION.md`.
552
548
 
@@ -653,8 +649,8 @@ RALPH_BOOTSTRAP_RLM_INSTRUCTIONS="@/home/user/prompts/rlm-instructions.md"
653
649
  | `RALPH_CONTEXT_GATE_ERROR` | — | Error message thrown when the agent tries a destructive tool before loading context. |
654
650
  | `RALPH_WORKER_SYSTEM_PROMPT` | — | System prompt injected into every RLM worker session. Describes the one-pass contract. |
655
651
  | `RALPH_WORKER_PROMPT` | `{{attempt}}` | Initial prompt sent to each spawned RLM worker session. |
656
- | `RALPH_SESSION_SYSTEM_PROMPT` | — | System prompt injected into Ralph strategist sessions. |
657
- | `RALPH_SESSION_PROMPT` | `{{attempt}}` | Initial prompt sent to each spawned Ralph strategist session. |
652
+ | `RALPH_SESSION_SYSTEM_PROMPT` | — | Legacy: system prompt for separate strategist sessions (unused in main-as-strategist mode). |
653
+ | `RALPH_SESSION_PROMPT` | `{{attempt}}` | Prompt sent to the main strategist session when an attempt starts. |
658
654
 
659
655
  ### Example: custom continue prompt from a file
660
656
 
@@ -697,7 +693,7 @@ Set `maxAttempts` high (25–50), write a detailed `PLAN.md` with a precise defi
697
693
 
698
694
  1. Make an attempt.
699
695
  2. Run verify.
700
- 3. On failure: roll state, spawn Ralph to diagnose and adjust, spawn the next worker.
696
+ 3. On failure: roll state, prompt the strategist (you) to diagnose and adjust, spawn the next worker.
701
697
  4. Repeat until it passes or hits `maxAttempts`.
702
698
 
703
699
  In the morning, check `SUPERVISOR_LOG.md` and `CONVERSATION.md` for the progress feed, `NOTES_AND_LEARNINGS.md` for what the loop learned, and `AGENT_CONTEXT_FOR_NEXT_RALPH.md` for where it stopped.
@@ -744,19 +740,19 @@ Parent agent:
744
740
 
745
741
  Edit `RLM_INSTRUCTIONS.md` to add project-specific playbooks, register MCP tools, or adjust the debug workflow. Changes persist across attempts. Use `ralph_update_rlm_instructions()` from within a session, or edit the file directly.
746
742
 
747
- The instructions file is the primary lever for improving loop performance. If the loop keeps making the same mistake, add a rule. If it keeps following an inefficient path, add a playbook. The Ralph strategist is responsible for updating these instructions between attempts based on what it observes in the failure record.
743
+ The instructions file is the primary lever for improving loop performance. If the loop keeps making the same mistake, add a rule. If it keeps following an inefficient path, add a playbook. The main strategist (you) updates these instructions between attempts based on what it observes in the failure record.
748
744
 
749
745
 
750
746
  ## Hooks installed
751
747
 
752
748
  | Hook | What it does |
753
749
  |---|---|
754
- | `event: session.idle` | Routes idle events: **worker** → `handleWorkerIdle` (verify + continue loop); **ralph** → `handleRalphSessionIdle` (warn if no worker spawned); **main/other** → `handleMainIdle` (kick off attempt 1). Also emits heartbeat/staleness warnings and supervisor status updates to `SUPERVISOR_LOG.md` and `CONVERSATION.md`. |
755
- | `event: session.created` | Pre-allocates session state for known worker/ralph sessions. |
750
+ | `event: session.idle` | Routes idle events: **worker** → `handleWorkerIdle` (verify + continue loop); **main/other** → `handleMainIdle` (kick off attempt 1). Also emits heartbeat/staleness warnings and supervisor status updates to `SUPERVISOR_LOG.md` and `CONVERSATION.md`. |
751
+ | `event: session.created` | Pre-allocates session state for known worker sessions. |
756
752
  | `event: session.status` | Refreshes heartbeat/progress timestamps for active sessions and surfaces explicit session error statuses to the supervisor feed. |
757
- | `experimental.chat.system.transform` | Three-way routing: **worker** → RLM file-first prompt; **ralph** → Ralph strategist prompt; **main/other** → supervisor prompt. |
753
+ | `experimental.chat.system.transform` | Two-way routing: **worker** → RLM file-first prompt; **main/other** → supervisor+strategist prompt. |
758
754
  | `experimental.session.compacting` | Injects protocol file pointers into compaction context so state survives context compression. |
759
- | `tool.execute.before` | Blocks destructive tools (`write`, `edit`, `bash`, `delete`, `move`, `rename`) in **worker and sub-agent sessions** until `ralph_load_context()` has been called. Ralph strategist sessions are not gated. |
755
+ | `tool.execute.before` | Blocks destructive tools (`write`, `edit`, `bash`, `delete`, `move`, `rename`) in **worker and sub-agent sessions** until `ralph_load_context()` has been called. |
760
756
 
761
757
 
762
758
  ## Background
@@ -765,7 +761,7 @@ The instructions file is the primary lever for improving loop performance. If th
765
761
 
766
762
  The outer loop is named after the [Ralph Wiggum technique](https://www.geoffreyhuntley.com/ralph) — a `while` loop that feeds a prompt to an AI agent until it succeeds. The name reflects the philosophy: persistent, not clever. The loop doesn't try to be smart about when to give up. It tries, records what happened, and tries again with better instructions.
767
763
 
768
- The key addition in this plugin over a naive Ralph implementation is the **separation of the strategist from the worker**. A naive loop re-prompts the same session. This plugin spawns a fresh Ralph strategist to review the failure before spawning the next worker. The strategist's fresh context means it analyses the failure without being anchored to the reasoning that produced it.
764
+ The key addition in this plugin over a naive Ralph implementation is the **separation of the strategist from the worker**. The main session handles strategy and delegation, while each worker gets a fresh context to implement. This keeps planning clean while still benefiting from fresh worker windows.
769
765
 
770
766
  ### The RLM inner loop
771
767
 
package/dist/ralph-rlm.js CHANGED
@@ -23417,6 +23417,8 @@ var RalphConfigSchema = exports_Schema.Struct({
23417
23417
  statusVerbosity: exports_Schema.optional(exports_Schema.Union(exports_Schema.Literal("minimal"), exports_Schema.Literal("normal"), exports_Schema.Literal("verbose"))),
23418
23418
  maxAttempts: exports_Schema.optional(exports_Schema.Number),
23419
23419
  heartbeatMinutes: exports_Schema.optional(exports_Schema.Number),
23420
+ strategistHandoffMinutes: exports_Schema.optional(exports_Schema.Number),
23421
+ strategistHandoffMaxRetries: exports_Schema.optional(exports_Schema.Number),
23420
23422
  verifyTimeoutMinutes: exports_Schema.optional(exports_Schema.Number),
23421
23423
  verify: exports_Schema.optional(VerifyConfigSchema),
23422
23424
  gateDestructiveToolsUntilContextLoaded: exports_Schema.optional(exports_Schema.Boolean),
@@ -23456,6 +23458,8 @@ var CONFIG_DEFAULTS = {
23456
23458
  statusVerbosity: "normal",
23457
23459
  maxAttempts: 20,
23458
23460
  heartbeatMinutes: 15,
23461
+ strategistHandoffMinutes: 5,
23462
+ strategistHandoffMaxRetries: 2,
23459
23463
  verifyTimeoutMinutes: 0,
23460
23464
  gateDestructiveToolsUntilContextLoaded: true,
23461
23465
  maxRlmSliceLines: 200,
@@ -23482,6 +23486,8 @@ function resolveConfig(raw) {
23482
23486
  statusVerbosity: raw.statusVerbosity ?? CONFIG_DEFAULTS.statusVerbosity,
23483
23487
  maxAttempts: toBoundedInt(raw.maxAttempts, CONFIG_DEFAULTS.maxAttempts, 1, 500),
23484
23488
  heartbeatMinutes: toBoundedInt(raw.heartbeatMinutes, CONFIG_DEFAULTS.heartbeatMinutes, 1, 240),
23489
+ strategistHandoffMinutes: toBoundedInt(raw.strategistHandoffMinutes, CONFIG_DEFAULTS.strategistHandoffMinutes, 1, 60),
23490
+ strategistHandoffMaxRetries: toBoundedInt(raw.strategistHandoffMaxRetries, CONFIG_DEFAULTS.strategistHandoffMaxRetries, 0, 10),
23485
23491
  verifyTimeoutMinutes: toBoundedInt(raw.verifyTimeoutMinutes, CONFIG_DEFAULTS.verifyTimeoutMinutes, 0, 240),
23486
23492
  ...verify !== undefined ? { verify } : {},
23487
23493
  gateDestructiveToolsUntilContextLoaded: raw.gateDestructiveToolsUntilContextLoaded ?? CONFIG_DEFAULTS.gateDestructiveToolsUntilContextLoaded,
@@ -23504,9 +23510,9 @@ var DEFAULT_TEMPLATES = {
23504
23510
  systemPrompt: [
23505
23511
  "RALPH SUPERVISOR:",
23506
23512
  "- You are the Ralph supervisor. You orchestrate RLM worker sessions; you do NOT write code yourself.",
23507
- "- When the user gives you a goal, describe the task briefly and stop \u2014 the plugin will spawn an RLM worker automatically.",
23508
- "- You are NOT the Ralph strategist and NOT the RLM worker. Those are separate sessions.",
23509
- " Supervisor = orchestration + decisions; Ralph strategist = planning + delegation; RLM worker = implementation.",
23513
+ "- When the user gives you a goal, describe the task briefly and then act as the strategist: call ralph_spawn_worker() to hand off.",
23514
+ "- You ARE the strategist in the main session, and you are NOT the RLM worker.",
23515
+ " Supervisor+strategist = orchestration + planning + delegation; RLM worker = implementation.",
23510
23516
  "- Workers are spawned per-attempt with a fresh context window. They load state from protocol files.",
23511
23517
  "- Protocol files (PLAN.md, RLM_INSTRUCTIONS.md, etc.) persist across all attempts \u2014 edit them to guide workers.",
23512
23518
  "- After each worker attempt the plugin runs verify and either finishes or spawns the next worker.",
@@ -23514,6 +23520,7 @@ var DEFAULT_TEMPLATES = {
23514
23520
  " When you receive one, call ralph_respond(id, answer) to unblock the session.",
23515
23521
  "- Use ralph_doctor() to check setup, ralph_bootstrap_plan() to generate PLAN/TODOS,",
23516
23522
  " ralph_create_supervisor_session() to bind/start explicitly, ralph_pause_supervision()/ralph_resume_supervision() to control execution, and ralph_end_supervision() to stop.",
23523
+ "- Only call loop-control tools (spawn, pause, resume, end) after the supervisor session is bound via ralph_create_supervisor_session().",
23517
23524
  "- End supervision when verification has passed and the user confirms they are done, or when the user explicitly asks to stop the loop.",
23518
23525
  "- Optional reviewer flow: worker marks readiness with ralph_request_review(); supervisor runs ralph_run_reviewer().",
23519
23526
  "- Monitor progress in SUPERVISOR_LOG.md, CONVERSATION.md, or via toast notifications.",
@@ -23700,6 +23707,7 @@ var DEFAULT_TEMPLATES = {
23700
23707
  "- You do NOT write code yourself; you are not the RLM worker.",
23701
23708
  "- After reviewing state and optionally updating PLAN.md / RLM_INSTRUCTIONS.md,",
23702
23709
  " call ralph_spawn_worker() to hand off to the RLM worker for this attempt.",
23710
+ "- You MUST call ralph_spawn_worker() exactly once per attempt.",
23703
23711
  "- Then STOP. The plugin verifies independently and will spawn the next Ralph session if needed.",
23704
23712
  "",
23705
23713
  "Role boundaries:",
@@ -23720,7 +23728,7 @@ var DEFAULT_TEMPLATES = {
23720
23728
  " guidance for the next worker based on patterns in the failures.",
23721
23729
  "5. Optionally call ralph_set_status('running', 'strategy finalized').",
23722
23730
  "6. Call ralph_report() summarizing strategy changes for this attempt.",
23723
- "7. Call ralph_spawn_worker() to delegate the coding work to a fresh RLM worker.",
23731
+ "7. Call ralph_spawn_worker() to delegate the coding work to a fresh RLM worker (required).",
23724
23732
  "8. STOP \u2014 the plugin handles verification and will spawn attempt {{nextAttempt}} if needed.",
23725
23733
  "",
23726
23734
  "You do not write code. Your value is strategic context adjustment between attempts.",
@@ -23728,7 +23736,10 @@ var DEFAULT_TEMPLATES = {
23728
23736
  "Tool meaning:",
23729
23737
  "- ralph_update_plan / ralph_update_rlm_instructions = durable strategy changes",
23730
23738
  "- ralph_spawn_worker = handoff to implementation session",
23731
- "- ralph_report = visible summary for the supervisor"
23739
+ "- ralph_report = visible summary for the supervisor",
23740
+ "",
23741
+ "Example flow:",
23742
+ '- ralph_load_context() \u2192 ralph_report("Strategy: update PLAN.md with constraint X") \u2192 ralph_spawn_worker() \u2192 STOP'
23732
23743
  ].join(`
23733
23744
  `)
23734
23745
  };
@@ -24279,6 +24290,49 @@ var RalphRLM = async ({ client, $, worktree }) => {
24279
24290
  return false;
24280
24291
  }
24281
24292
  };
24293
+ const promptStrategistInMain = async (attempt) => {
24294
+ if (!supervisor.sessionId) {
24295
+ await notifySupervisor("supervisor", "Cannot prompt strategist: supervisor session not bound. Run ralph_create_supervisor_session().", "warning", true);
24296
+ return;
24297
+ }
24298
+ supervisor.awaitingStrategist = true;
24299
+ if (supervisor.ralphHandoffAttempt !== attempt) {
24300
+ supervisor.ralphHandoffAttempt = attempt;
24301
+ supervisor.ralphHandoffRetries = 0;
24302
+ }
24303
+ const promptText = interpolate(templates.ralphSessionPrompt, {
24304
+ attempt: String(attempt),
24305
+ nextAttempt: String(attempt + 1)
24306
+ });
24307
+ const ok = await sendPromptWithFallback(supervisor.sessionId, promptText, `Strategist prompt (attempt ${attempt})`, supervisor.sessionId);
24308
+ if (!ok) {
24309
+ supervisor.paused = true;
24310
+ await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist prompt failed; supervision paused. Retry with ralph_create_supervisor_session(restart_if_done=true).", "error", true, supervisor.sessionId);
24311
+ return;
24312
+ }
24313
+ if (supervisor.ralphHandoffTimer) {
24314
+ clearTimeout(supervisor.ralphHandoffTimer);
24315
+ }
24316
+ const cfg = await run(getConfig());
24317
+ const timeoutMs = Math.max(cfg.strategistHandoffMinutes, 1) * 60000;
24318
+ supervisor.ralphHandoffTimer = setTimeout(async () => {
24319
+ if (supervisor.done || supervisor.paused)
24320
+ return;
24321
+ if (!supervisor.awaitingStrategist)
24322
+ return;
24323
+ if (supervisor.ralphHandoffAttempt !== attempt)
24324
+ return;
24325
+ const retries = supervisor.ralphHandoffRetries ?? 0;
24326
+ if (retries < cfg.strategistHandoffMaxRetries) {
24327
+ supervisor.ralphHandoffRetries = retries + 1;
24328
+ await notifySupervisor(`supervisor/attempt-${attempt}`, `Strategist did not hand off; re-prompting main session (${retries + 1}/${cfg.strategistHandoffMaxRetries}).`, "warning", true, supervisor.sessionId);
24329
+ await promptStrategistInMain(attempt);
24330
+ return;
24331
+ }
24332
+ await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist did not hand off after retries; supervision paused. Use ralph_create_supervisor_session(restart_if_done=true) to retry.", "error", true, supervisor.sessionId);
24333
+ supervisor.paused = true;
24334
+ }, timeoutMs);
24335
+ };
24282
24336
  const detectProjectDefaults = (root) => exports_Effect.gen(function* () {
24283
24337
  const j = (f) => NodePath.join(root, f);
24284
24338
  const hasBunLock = (yield* fileExists(j("bun.lockb"))) || (yield* fileExists(j("bun.lock")));
@@ -25122,7 +25176,7 @@ No pending questions found.`));
25122
25176
  if (supervisor.done && args2.restart_if_done === true) {
25123
25177
  supervisor.done = false;
25124
25178
  supervisor.paused = false;
25125
- supervisor.currentRalphSessionId = undefined;
25179
+ supervisor.awaitingStrategist = false;
25126
25180
  supervisor.currentWorkerSessionId = undefined;
25127
25181
  supervisor.activeReviewerName = undefined;
25128
25182
  supervisor.activeReviewerAttempt = undefined;
@@ -25131,19 +25185,19 @@ No pending questions found.`));
25131
25185
  await persistReviewerState();
25132
25186
  await notifySupervisor("supervisor", "Supervisor done-state reset for a new run.", "info", true, sessionID);
25133
25187
  }
25134
- if (supervisor.currentRalphSessionId || supervisor.currentWorkerSessionId || supervisor.done) {
25188
+ if (supervisor.currentWorkerSessionId || supervisor.done) {
25135
25189
  return JSON.stringify({
25136
25190
  ok: true,
25137
25191
  started: false,
25138
25192
  message: "Loop is already running or completed for this process.",
25139
- currentRalphSessionId: supervisor.currentRalphSessionId,
25140
25193
  currentWorkerSessionId: supervisor.currentWorkerSessionId,
25141
25194
  done: supervisor.done
25142
25195
  }, null, 2);
25143
25196
  }
25144
25197
  supervisor.attempt = 1;
25198
+ supervisor.awaitingStrategist = false;
25145
25199
  await notifySupervisor("supervisor", "Starting Ralph loop at attempt 1 (manual start).", "info", true, sessionID);
25146
- await spawnRalphSession(1);
25200
+ await promptStrategistInMain(1);
25147
25201
  return JSON.stringify({ ok: true, started: true, attempt: 1 }, null, 2);
25148
25202
  }
25149
25203
  });
@@ -25159,6 +25213,7 @@ No pending questions found.`));
25159
25213
  const reason = args2.reason?.trim();
25160
25214
  supervisor.done = true;
25161
25215
  supervisor.paused = true;
25216
+ supervisor.awaitingStrategist = false;
25162
25217
  const sessionsToAbort = Array.from(sessionMap.keys());
25163
25218
  for (const id of sessionsToAbort) {
25164
25219
  await client.session.abort({ path: { id } }).catch(() => {});
@@ -25168,7 +25223,6 @@ No pending questions found.`));
25168
25223
  }
25169
25224
  stopAllCommands("supervision-ended");
25170
25225
  sessionMap.clear();
25171
- supervisor.currentRalphSessionId = undefined;
25172
25226
  supervisor.currentWorkerSessionId = undefined;
25173
25227
  supervisor.activeReviewerName = undefined;
25174
25228
  supervisor.activeReviewerAttempt = undefined;
@@ -25192,7 +25246,7 @@ No pending questions found.`));
25192
25246
  }
25193
25247
  });
25194
25248
  const tool_ralph_supervision_status = tool({
25195
- description: "Get current supervision state (binding, attempt, active strategist/worker, done flag).",
25249
+ description: "Get current supervision state (binding, attempt, awaiting strategist/worker, done flag).",
25196
25250
  args: {},
25197
25251
  async execute(_args, _ctx) {
25198
25252
  return JSON.stringify({
@@ -25201,7 +25255,7 @@ No pending questions found.`));
25201
25255
  attempt: supervisor.attempt,
25202
25256
  done: supervisor.done,
25203
25257
  paused: supervisor.paused ?? false,
25204
- currentRalphSessionId: supervisor.currentRalphSessionId ?? null,
25258
+ awaitingStrategist: supervisor.awaitingStrategist ?? false,
25205
25259
  currentWorkerSessionId: supervisor.currentWorkerSessionId ?? null,
25206
25260
  activeReviewerName: supervisor.activeReviewerName ?? null,
25207
25261
  activeReviewerAttempt: supervisor.activeReviewerAttempt ?? null,
@@ -25247,11 +25301,11 @@ No pending questions found.`));
25247
25301
  message: "Loop is marked done. Use ralph_create_supervisor_session(restart_if_done=true)."
25248
25302
  }, null, 2);
25249
25303
  }
25250
- if (supervisor.currentRalphSessionId || supervisor.currentWorkerSessionId) {
25304
+ if (supervisor.currentWorkerSessionId) {
25251
25305
  return JSON.stringify({ ok: true, resumed: true, started: false, message: "Loop already running." }, null, 2);
25252
25306
  }
25253
25307
  supervisor.attempt = Math.max(1, supervisor.attempt || 1);
25254
- await spawnRalphSession(supervisor.attempt);
25308
+ await promptStrategistInMain(supervisor.attempt);
25255
25309
  return JSON.stringify({ ok: true, resumed: true, started: true, attempt: supervisor.attempt }, null, 2);
25256
25310
  }
25257
25311
  });
@@ -25337,8 +25391,14 @@ Set a new goal and run again.
25337
25391
  supervisor.done = false;
25338
25392
  supervisor.paused = false;
25339
25393
  supervisor.attempt = 0;
25340
- supervisor.currentRalphSessionId = undefined;
25341
25394
  supervisor.currentWorkerSessionId = undefined;
25395
+ supervisor.awaitingStrategist = false;
25396
+ supervisor.ralphHandoffRetries = 0;
25397
+ supervisor.ralphHandoffAttempt = undefined;
25398
+ if (supervisor.ralphHandoffTimer) {
25399
+ clearTimeout(supervisor.ralphHandoffTimer);
25400
+ supervisor.ralphHandoffTimer = undefined;
25401
+ }
25342
25402
  supervisor.activeReviewerName = undefined;
25343
25403
  supervisor.activeReviewerAttempt = undefined;
25344
25404
  supervisor.activeReviewerSessionId = undefined;
@@ -25428,7 +25488,7 @@ Set a new goal and run again.
25428
25488
  supervisor.done = false;
25429
25489
  supervisor.paused = false;
25430
25490
  supervisor.attempt = 1;
25431
- await spawnRalphSession(1);
25491
+ await promptStrategistInMain(1);
25432
25492
  actions.push("Started loop at attempt 1");
25433
25493
  }
25434
25494
  await appendConversationEntry("supervisor", `Quickstart completed for goal: ${args2.goal}`);
@@ -25613,18 +25673,12 @@ Set a new goal and run again.
25613
25673
  s.lastProgressAt = now2;
25614
25674
  });
25615
25675
  };
25616
- await maybeWarn(supervisor.currentRalphSessionId, "Strategist");
25617
25676
  await maybeWarn(supervisor.currentWorkerSessionId, "Worker");
25618
25677
  };
25619
25678
  const clearSessionTracking = async (sessionId, reason) => {
25620
25679
  const st = sessionMap.get(sessionId);
25621
25680
  sessionMap.delete(sessionId);
25622
25681
  let didUpdate = false;
25623
- const clearedRalph = supervisor.currentRalphSessionId === sessionId;
25624
- if (clearedRalph) {
25625
- supervisor.currentRalphSessionId = undefined;
25626
- didUpdate = true;
25627
- }
25628
25682
  if (supervisor.currentWorkerSessionId === sessionId) {
25629
25683
  supervisor.currentWorkerSessionId = undefined;
25630
25684
  didUpdate = true;
@@ -25637,7 +25691,7 @@ Set a new goal and run again.
25637
25691
  didUpdate = true;
25638
25692
  await persistReviewerState();
25639
25693
  }
25640
- if (supervisor.ralphHandoffTimer && clearedRalph) {
25694
+ if (supervisor.ralphHandoffTimer && supervisor.sessionId === sessionId) {
25641
25695
  clearTimeout(supervisor.ralphHandoffTimer);
25642
25696
  supervisor.ralphHandoffTimer = undefined;
25643
25697
  }
@@ -25719,61 +25773,30 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25719
25773
  tool_ralph_spawn_worker_impl = async (_args, ctx) => {
25720
25774
  const sessionID = ctx.sessionID ?? "";
25721
25775
  const st = sessionMap.get(sessionID);
25722
- if (st?.role !== "ralph") {
25723
- throw new Error("ralph_spawn_worker() can only be called from a Ralph strategist session.");
25776
+ const isMainStrategist = st?.role === "main";
25777
+ if (!isMainStrategist && st?.role !== "ralph") {
25778
+ throw new Error("ralph_spawn_worker() must be called from the main strategist session.");
25724
25779
  }
25725
- if (st.workerSpawned) {
25726
- throw new Error("ralph_spawn_worker() has already been called for this attempt.");
25780
+ if (isMainStrategist && supervisor.sessionId && supervisor.sessionId !== sessionID) {
25781
+ throw new Error("ralph_spawn_worker() must be called from the bound supervisor session.");
25782
+ }
25783
+ if (supervisor.attempt < 1) {
25784
+ throw new Error("No active attempt. Start with ralph_create_supervisor_session(start_loop=true).");
25727
25785
  }
25728
25786
  if (supervisor.ralphHandoffTimer) {
25729
25787
  clearTimeout(supervisor.ralphHandoffTimer);
25730
25788
  supervisor.ralphHandoffTimer = undefined;
25731
25789
  }
25732
- const workerId = await spawnRlmWorker(st.attempt);
25790
+ supervisor.awaitingStrategist = false;
25791
+ const attempt = isMainStrategist ? supervisor.attempt : st.attempt;
25792
+ const workerId = await spawnRlmWorker(attempt);
25733
25793
  mutateSession(sessionID, (s) => {
25734
25794
  s.workerSpawned = true;
25735
25795
  });
25736
- await notifySupervisor(`ralph/attempt-${st.attempt}`, `Delegated coding to worker session ${workerId}.`, "info", true, sessionID);
25737
- return JSON.stringify({ ok: true, workerSessionId: workerId, attempt: st.attempt }, null, 2);
25738
- };
25739
- const spawnRalphSession = async (attempt) => {
25740
- const result = await client.session.create({
25741
- body: { title: `ralph-strategist-attempt-${attempt}` }
25742
- });
25743
- const ralphId = result.data?.id ?? `ralph-${Date.now()}`;
25744
- supervisor.currentRalphSessionId = ralphId;
25745
- sessionMap.set(ralphId, freshSession("ralph", attempt));
25746
- mutateSession(ralphId, (s) => {
25747
- s.lastProgressAt = Date.now();
25748
- });
25749
- const promptText = interpolate(templates.ralphSessionPrompt, {
25750
- attempt: String(attempt),
25751
- nextAttempt: String(attempt + 1)
25752
- });
25753
- const promptOk = await sendPromptWithFallback(ralphId, promptText, `Strategist prompt (attempt ${attempt})`, ralphId);
25754
- if (!promptOk) {
25755
- supervisor.currentRalphSessionId = undefined;
25756
- await client.session.abort({ path: { id: ralphId } }).catch(() => {});
25757
- await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist prompt failed; supervision paused. Retry with ralph_create_supervisor_session(restart_if_done=true).", "error", true);
25758
- supervisor.paused = true;
25759
- return;
25760
- }
25761
- if (supervisor.ralphHandoffTimer) {
25762
- clearTimeout(supervisor.ralphHandoffTimer);
25763
- }
25764
- const cfg = await run(getConfig());
25765
- const timeoutMs = Math.max(cfg.heartbeatMinutes, 1) * 60000;
25766
- supervisor.ralphHandoffTimer = setTimeout(async () => {
25767
- if (supervisor.done || supervisor.paused)
25768
- return;
25769
- if (supervisor.currentRalphSessionId !== ralphId)
25770
- return;
25771
- const st = sessionMap.get(ralphId);
25772
- if (st?.workerSpawned)
25773
- return;
25774
- await notifySupervisor(`ralph/attempt-${attempt}`, "Strategist did not hand off to a worker within the heartbeat window. Re-check prompt delivery or restart supervision.", "warning", true, ralphId);
25775
- }, timeoutMs);
25776
- await notifySupervisor(`supervisor/attempt-${attempt}`, `Spawned Ralph strategist session ${ralphId}.`, "info", true);
25796
+ supervisor.ralphHandoffRetries = 0;
25797
+ supervisor.ralphHandoffAttempt = attempt;
25798
+ await notifySupervisor(`supervisor/attempt-${attempt}`, `Delegated coding to worker session ${workerId}.`, "info", true, sessionID);
25799
+ return JSON.stringify({ ok: true, workerSessionId: workerId, attempt }, null, 2);
25777
25800
  };
25778
25801
  const handleWorkerIdle = async (workerSessionId) => {
25779
25802
  if (supervisor.currentWorkerSessionId !== workerSessionId)
@@ -25792,6 +25815,11 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25792
25815
  const { verdict, details } = await runAndParseVerify();
25793
25816
  if (verdict === "pass") {
25794
25817
  supervisor.done = true;
25818
+ supervisor.awaitingStrategist = false;
25819
+ if (supervisor.ralphHandoffTimer) {
25820
+ clearTimeout(supervisor.ralphHandoffTimer);
25821
+ supervisor.ralphHandoffTimer = undefined;
25822
+ }
25795
25823
  await run(writeFile(NodePath.join(worktree, FILES.NEXT_RALPH), interpolate(templates.doneFileContent, { timestamp: nowISO() })));
25796
25824
  await client.tui.showToast({
25797
25825
  body: { title: "Ralph: Done", message: "Verification passed. Loop complete.", variant: "success" }
@@ -25813,38 +25841,17 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25813
25841
  await notifySupervisor(`worker/attempt-${supervisor.attempt}`, `Verification ${verdict}. Preparing next attempt.`, verdict === "fail" ? "warning" : "info", true, workerSessionId);
25814
25842
  supervisor.attempt += 1;
25815
25843
  await rolloverState(supervisor.attempt - 1, verdict, details);
25816
- await spawnRalphSession(supervisor.attempt);
25817
- };
25818
- const handleRalphSessionIdle = async (ralphSessionId) => {
25819
- if (supervisor.currentRalphSessionId !== ralphSessionId)
25820
- return;
25821
- if (supervisor.done)
25822
- return;
25823
- supervisor.currentRalphSessionId = undefined;
25824
- const st = sessionMap.get(ralphSessionId);
25825
- if (st && !st.reportedStatus) {
25826
- await notifySupervisor(`ralph/attempt-${st.attempt}`, "No explicit strategist status reported before idle.", "info", true, ralphSessionId);
25827
- }
25828
- if (!st?.workerSpawned) {
25829
- await client.tui.showToast({
25830
- body: {
25831
- title: "Ralph: no worker spawned",
25832
- message: `Ralph session for attempt ${st?.attempt ?? supervisor.attempt} ended without calling ralph_spawn_worker().`,
25833
- variant: "warning"
25834
- }
25835
- }).catch(() => {});
25836
- await notifySupervisor(`ralph/attempt-${st?.attempt ?? supervisor.attempt}`, "Strategist went idle without spawning a worker.", "warning", true, ralphSessionId);
25837
- }
25844
+ await promptStrategistInMain(supervisor.attempt);
25838
25845
  };
25839
25846
  const handleMainIdle = async (sessionID) => {
25840
25847
  if (supervisor.done)
25841
25848
  return;
25842
25849
  if (supervisor.paused)
25843
25850
  return;
25844
- if (supervisor.currentRalphSessionId)
25845
- return;
25846
25851
  if (supervisor.currentWorkerSessionId)
25847
25852
  return;
25853
+ if (supervisor.awaitingStrategist)
25854
+ return;
25848
25855
  const cfg = await run(getConfig());
25849
25856
  if (!cfg.enabled)
25850
25857
  return;
@@ -25865,7 +25872,7 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25865
25872
  }
25866
25873
  supervisor.attempt = 1;
25867
25874
  await notifySupervisor("supervisor", "Starting Ralph loop at attempt 1.", "info", true, sessionID);
25868
- await spawnRalphSession(1);
25875
+ await promptStrategistInMain(1);
25869
25876
  };
25870
25877
  return {
25871
25878
  tool: {
@@ -25904,7 +25911,7 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25904
25911
  output.system = output.system ?? [];
25905
25912
  const sessionID = input.sessionID ?? input.session_id ?? input.session?.id;
25906
25913
  const role = sessionMap.get(sessionID ?? "")?.role;
25907
- const base = role === "worker" || role === "subagent" ? templates.workerSystemPrompt : role === "ralph" ? templates.ralphSessionSystemPrompt : templates.systemPrompt;
25914
+ const base = role === "worker" || role === "subagent" ? templates.workerSystemPrompt : templates.systemPrompt;
25908
25915
  const full = templates.systemPromptAppend ? `${base}
25909
25916
  ${templates.systemPromptAppend}` : base;
25910
25917
  output.system.push(full);
@@ -25946,8 +25953,6 @@ ${templates.systemPromptAppend}` : base;
25946
25953
  const state = sessionMap.get(sessionID);
25947
25954
  if (state?.role === "worker" && supervisor.currentWorkerSessionId !== sessionID)
25948
25955
  return;
25949
- if (state?.role === "ralph" && supervisor.currentRalphSessionId !== sessionID)
25950
- return;
25951
25956
  if (state) {
25952
25957
  mutateSession(sessionID, (s) => {
25953
25958
  s.lastProgressAt = Date.now();
@@ -25968,10 +25973,6 @@ ${templates.systemPromptAppend}` : base;
25968
25973
  await handleWorkerIdle(sessionID).catch((err) => {
25969
25974
  appLog("error", "handleWorkerIdle error", { error: String(err), sessionID });
25970
25975
  });
25971
- } else if (supervisor.currentRalphSessionId === sessionID) {
25972
- await handleRalphSessionIdle(sessionID).catch((err) => {
25973
- appLog("error", "handleRalphSessionIdle error", { error: String(err), sessionID });
25974
- });
25975
25976
  } else {
25976
25977
  await handleMainIdle(sessionID).catch((err) => {
25977
25978
  appLog("error", "handleMainIdle error", { error: String(err), sessionID });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ralph-rlm",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "OpenCode plugin: Ralph outer loop + RLM inner loop. Iterative AI development with file-first discipline and sub-agent support.",
5
5
  "type": "module",
6
6
  "main": "./dist/ralph-rlm.js",