ralph-lisa-loop 0.3.9 → 0.3.11

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/dist/commands.js CHANGED
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.generateSessionName = generateSessionName;
41
+ exports.runGate = runGate;
41
42
  exports.cmdInit = cmdInit;
42
43
  exports.cmdWhoseTurn = cmdWhoseTurn;
43
44
  exports.cmdSubmitRalph = cmdSubmitRalph;
@@ -46,16 +47,24 @@ exports.cmdStatus = cmdStatus;
46
47
  exports.cmdRead = cmdRead;
47
48
  exports.cmdRecap = cmdRecap;
48
49
  exports.cmdStep = cmdStep;
50
+ exports.parseSubtasks = parseSubtasks;
51
+ exports.cmdSubtask = cmdSubtask;
49
52
  exports.cmdHistory = cmdHistory;
50
53
  exports.cmdArchive = cmdArchive;
51
54
  exports.cmdClean = cmdClean;
55
+ exports.cmdStop = cmdStop;
52
56
  exports.cmdUpdateTask = cmdUpdateTask;
57
+ exports.cmdForceTurn = cmdForceTurn;
58
+ exports.executeForceTurn = executeForceTurn;
53
59
  exports.cmdUninit = cmdUninit;
54
60
  exports.cmdInitProject = cmdInitProject;
55
61
  exports.cmdStart = cmdStart;
56
62
  exports.cmdAuto = cmdAuto;
57
63
  exports.cmdPolicy = cmdPolicy;
58
64
  exports.cmdLogs = cmdLogs;
65
+ exports.cmdRemote = cmdRemote;
66
+ exports.cmdStateDir = cmdStateDir;
67
+ exports.cmdAddContext = cmdAddContext;
59
68
  exports.cmdDoctor = cmdDoctor;
60
69
  const fs = __importStar(require("node:fs"));
61
70
  const path = __importStar(require("node:path"));
@@ -128,6 +137,60 @@ function getFilesChanged() {
128
137
  return [];
129
138
  }
130
139
  }
140
+ /**
141
+ * V4-01: Ralph Auto Gate — run test/lint commands before CODE/FIX submission.
142
+ * Config via env vars only (config file deferred to future version):
143
+ * RL_RALPH_GATE=true — enable gate (default: false)
144
+ * RL_GATE_COMMANDS=cmd1|cmd2 — pipe-separated commands
145
+ * RL_GATE_MODE=warn|block — warn (default) or block on failure
146
+ * Gate results are NOT written to work.md (prevents Lisa anchoring).
147
+ */
148
+ function runGate(tag) {
149
+ if (tag !== "CODE" && tag !== "FIX")
150
+ return;
151
+ if (process.env.RL_RALPH_GATE !== "true")
152
+ return;
153
+ const commands = (process.env.RL_GATE_COMMANDS || "").split("|").filter(Boolean);
154
+ if (commands.length === 0)
155
+ return;
156
+ const mode = process.env.RL_GATE_MODE === "block" ? "block" : "warn";
157
+ const failures = [];
158
+ console.log("Running gate checks...");
159
+ for (const cmd of commands) {
160
+ try {
161
+ (0, node_child_process_1.execSync)(cmd, { stdio: "pipe", timeout: 120000 });
162
+ console.log(` PASS ${cmd}`);
163
+ }
164
+ catch (e) {
165
+ const stderr = e.stderr ? e.stderr.toString().trim().split("\n").slice(-3).join("\n") : "exit code " + e.status;
166
+ failures.push({ cmd, error: stderr });
167
+ console.log(` FAIL ${cmd}`);
168
+ }
169
+ }
170
+ if (failures.length === 0) {
171
+ console.log("Gate: all checks passed.");
172
+ console.log("");
173
+ return;
174
+ }
175
+ console.log("");
176
+ console.log(line());
177
+ if (mode === "block") {
178
+ console.error("Gate BLOCKED submission:");
179
+ for (const f of failures) {
180
+ console.error(` - ${f.cmd}: ${f.error}`);
181
+ }
182
+ console.error(line());
183
+ process.exit(1);
184
+ }
185
+ else {
186
+ console.log("Gate warnings (submission proceeds):");
187
+ for (const f of failures) {
188
+ console.log(` - ${f.cmd}: ${f.error}`);
189
+ }
190
+ console.log(line());
191
+ console.log("");
192
+ }
193
+ }
131
194
  // ─── init ────────────────────────────────────────
132
195
  function cmdInit(args) {
133
196
  const task = args.join(" ");
@@ -203,11 +266,42 @@ function cmdSubmitRalph(args) {
203
266
  console.error(line());
204
267
  process.exit(1);
205
268
  }
269
+ // NEEDS_WORK response enforcement (Proposal §3.2)
270
+ const dir = (0, state_js_1.stateDir)();
271
+ const reviewContent = (0, state_js_1.readFile)(path.join(dir, "review.md"));
272
+ const lastLisaTag = extractLastTag(reviewContent);
273
+ const { getPolicyMode } = require("./policy.js");
274
+ const nwMode = getPolicyMode();
275
+ if (nwMode !== "off") {
276
+ const nwViolations = (0, policy_js_1.checkNeedsWorkResponse)(tag, lastLisaTag);
277
+ if (nwViolations.length > 0) {
278
+ if (nwMode === "block") {
279
+ console.error(line());
280
+ console.error("Submission BLOCKED — must respond to NEEDS_WORK:");
281
+ for (const v of nwViolations) {
282
+ console.error(` - ${v.message}`);
283
+ }
284
+ console.error(line());
285
+ process.exit(1);
286
+ }
287
+ else {
288
+ console.log(line());
289
+ console.log("Warning — submitting after NEEDS_WORK without addressing it:");
290
+ for (const v of nwViolations) {
291
+ console.log(` - ${v.message}`);
292
+ }
293
+ console.log(line());
294
+ console.log("");
295
+ }
296
+ violations.push(...nwViolations);
297
+ }
298
+ }
299
+ // V4-01: Auto gate — run test/lint before CODE/FIX submission
300
+ runGate(tag);
206
301
  const round = (0, state_js_1.getRound)();
207
302
  const step = (0, state_js_1.getStep)();
208
303
  const ts = (0, state_js_1.timestamp)();
209
304
  const summary = (0, state_js_1.extractSummary)(content);
210
- const dir = (0, state_js_1.stateDir)();
211
305
  // Auto-inject task context so Lisa always sees the task goal
212
306
  // Use last meaningful line (update-task appends new directions at the end)
213
307
  const taskFile = (0, state_js_1.readFile)(path.join(dir, "task.md"));
@@ -221,8 +315,21 @@ function cmdSubmitRalph(args) {
221
315
  filesChangedSection = `**Files Changed**:\n${files.map((f) => `- ${f}`).join("\n")}\n\n`;
222
316
  }
223
317
  }
318
+ // Auto-inject context.md so Lisa sees runtime directives (Proposal §3.5)
319
+ let contextSection = "";
320
+ const contextPath = path.join(dir, "context.md");
321
+ if (fs.existsSync(contextPath)) {
322
+ const ctxContent = (0, state_js_1.readFile)(contextPath);
323
+ if (ctxContent && !ctxContent.includes("(visible to Lisa).\n\n\n")) {
324
+ // Only inject if context has actual entries (not just the header)
325
+ const ctxLines = ctxContent.split("\n").filter((l) => l.startsWith("- ["));
326
+ if (ctxLines.length > 0) {
327
+ contextSection = `**Context**: ${ctxLines.join("; ")}\n`;
328
+ }
329
+ }
330
+ }
224
331
  const taskLine = taskContext ? `**Task**: ${taskContext}\n` : "";
225
- (0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n${taskLine}**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
332
+ (0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n${taskLine}${contextSection}**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
226
333
  // External sources (--file/--stdin) get compact history to reduce context bloat
227
334
  const historyContent = external
228
335
  ? `[${tag}] ${summary}\n\n(Full content in work.md)`
@@ -230,6 +337,26 @@ function cmdSubmitRalph(args) {
230
337
  (0, state_js_1.appendHistory)("Ralph", historyContent);
231
338
  (0, state_js_1.updateLastAction)("Ralph", content);
232
339
  (0, state_js_1.setTurn)("lisa");
340
+ // CONSENSUS actions (Proposal §3.7 + §3.6)
341
+ if (tag === "CONSENSUS") {
342
+ // Clean .dual-agent/tests/ on Ralph CONSENSUS (covers single-round closure:
343
+ // Lisa [PASS] + Ralph [CONSENSUS] — Lisa never submits [CONSENSUS] in this path)
344
+ const testsDir = path.join(dir, "tests");
345
+ if (fs.existsSync(testsDir)) {
346
+ fs.rmSync(testsDir, { recursive: true, force: true });
347
+ console.log("Cleaned .dual-agent/tests/ (topic closed)");
348
+ }
349
+ // Subtask reminder (explicit, not auto-mark)
350
+ const taskContent = (0, state_js_1.readFile)(path.join(dir, "task.md"));
351
+ const incomplete = parseSubtasks(taskContent).filter((s) => !s.done);
352
+ if (incomplete.length > 0) {
353
+ console.log("");
354
+ console.log("Hint: If a subtask was completed, run: ralph-lisa subtask done <N>");
355
+ for (const s of incomplete) {
356
+ console.log(` - [ ] #${s.index} ${s.text}`);
357
+ }
358
+ }
359
+ }
233
360
  console.log(line());
234
361
  if (violations.length > 0) {
235
362
  console.log(`Submitted OK (with warnings): [${tag}] ${summary}`);
@@ -315,6 +442,41 @@ function cmdSubmitLisa(args) {
315
442
  (0, state_js_1.appendHistory)("Lisa", historyContent);
316
443
  (0, state_js_1.updateLastAction)("Lisa", content);
317
444
  (0, state_js_1.setTurn)("ralph");
445
+ // Deadlock counter: track consecutive NEEDS_WORK rounds (Proposal §3.2)
446
+ const nwCountPath = path.join(dir, "needs_work_count.txt");
447
+ if (tag === "NEEDS_WORK") {
448
+ const currentCount = parseInt((0, state_js_1.readFile)(nwCountPath) || "0", 10);
449
+ const newCount = currentCount + 1;
450
+ (0, state_js_1.writeFile)(nwCountPath, String(newCount));
451
+ if (newCount >= 5) {
452
+ // Trigger deadlock — write flag for watcher to detect
453
+ const deadlockPath = path.join(dir, "deadlock.txt");
454
+ (0, state_js_1.writeFile)(deadlockPath, `DEADLOCK at round ${round}: ${newCount} consecutive NEEDS_WORK rounds\nTimestamp: ${ts}\nAction: Watcher will pause. User intervention required.`);
455
+ console.log("");
456
+ console.log(line("!", 40));
457
+ console.log(`DEADLOCK: ${newCount} consecutive NEEDS_WORK rounds.`);
458
+ console.log("Watcher will pause for user intervention.");
459
+ console.log("To resolve: ralph-lisa scope-update or ralph-lisa force-turn");
460
+ console.log(line("!", 40));
461
+ }
462
+ }
463
+ else {
464
+ // Reset counter on non-NEEDS_WORK review
465
+ (0, state_js_1.writeFile)(nwCountPath, "0");
466
+ const deadlockPath = path.join(dir, "deadlock.txt");
467
+ try {
468
+ fs.unlinkSync(deadlockPath);
469
+ }
470
+ catch { }
471
+ }
472
+ // Clean .dual-agent/tests/ on CONSENSUS (Proposal §3.6 — evidence preserved until closure)
473
+ if (tag === "CONSENSUS") {
474
+ const testsDir = path.join(dir, "tests");
475
+ if (fs.existsSync(testsDir)) {
476
+ fs.rmSync(testsDir, { recursive: true, force: true });
477
+ console.log("Cleaned .dual-agent/tests/ (topic closed)");
478
+ }
479
+ }
318
480
  // Increment round
319
481
  const nextRound = (parseInt(round, 10) || 0) + 1;
320
482
  (0, state_js_1.setRound)(nextRound);
@@ -508,21 +670,32 @@ function extractLastTag(fileContent) {
508
670
  // ─── step ────────────────────────────────────────
509
671
  function cmdStep(args) {
510
672
  (0, state_js_1.checkSession)();
511
- // Parse --force flag
673
+ // Parse flags
512
674
  const forceIdx = args.indexOf("--force");
513
675
  const force = forceIdx !== -1;
514
- const filteredArgs = force
515
- ? args.filter((_, i) => i !== forceIdx)
516
- : args;
676
+ const taskIdx = args.indexOf("--task");
677
+ let taskDesc = "";
678
+ const skipIndices = new Set();
679
+ if (forceIdx !== -1)
680
+ skipIndices.add(forceIdx);
681
+ if (taskIdx !== -1) {
682
+ skipIndices.add(taskIdx);
683
+ if (taskIdx + 1 < args.length) {
684
+ taskDesc = args[taskIdx + 1];
685
+ skipIndices.add(taskIdx + 1);
686
+ }
687
+ }
688
+ const filteredArgs = args.filter((_, i) => !skipIndices.has(i));
517
689
  const stepName = filteredArgs.join(" ");
518
690
  if (!stepName) {
519
691
  console.error('Usage: ralph-lisa step "step name"');
692
+ console.error(' ralph-lisa step "step name" --task "first task"');
520
693
  console.error(" ralph-lisa step --force \"step name\" (skip consensus check)");
521
694
  process.exit(1);
522
695
  }
696
+ const dir = (0, state_js_1.stateDir)();
523
697
  // Check consensus before allowing step transition
524
698
  if (!force) {
525
- const dir = (0, state_js_1.stateDir)();
526
699
  const workContent = (0, state_js_1.readFile)(path.join(dir, "work.md"));
527
700
  const reviewContent = (0, state_js_1.readFile)(path.join(dir, "review.md"));
528
701
  const workTag = extractLastTag(workContent);
@@ -539,14 +712,135 @@ function cmdStep(args) {
539
712
  console.error('Use --force to skip this check: ralph-lisa step --force "step name"');
540
713
  process.exit(1);
541
714
  }
715
+ // Check subtask completion before step transition (Proposal §3.4)
716
+ const taskContent = (0, state_js_1.readFile)(path.join(dir, "task.md"));
717
+ const incomplete = parseSubtasks(taskContent).filter((s) => !s.done);
718
+ if (incomplete.length > 0) {
719
+ console.error("Error: Incomplete subtasks remain. Cannot proceed to next step.");
720
+ for (const s of incomplete) {
721
+ console.error(` - [ ] #${s.index} ${s.text}`);
722
+ }
723
+ console.error("");
724
+ console.error("Complete all subtasks first, or use --force to skip this check.");
725
+ process.exit(1);
726
+ }
542
727
  }
543
728
  (0, state_js_1.setStep)(stepName);
544
729
  (0, state_js_1.setRound)(1);
545
- const dir = (0, state_js_1.stateDir)();
730
+ (0, state_js_1.setTurn)("ralph");
731
+ // Reset work/review to prevent stale consensus tags from carrying over
732
+ (0, state_js_1.writeFile)(path.join(dir, "work.md"), "# Ralph Work\n\n(Waiting for Ralph to submit)\n");
733
+ (0, state_js_1.writeFile)(path.join(dir, "review.md"), "# Lisa Review\n\n(Waiting for Lisa to respond)\n");
546
734
  const ts = (0, state_js_1.timestamp)();
735
+ // Initialize task.md for new step (Proposal §3.3 + §3.4)
736
+ let taskContent = `# ${stepName}\n\n## Subtasks\n`;
737
+ if (taskDesc) {
738
+ taskContent += `- [ ] #1 ${taskDesc}\n`;
739
+ }
740
+ taskContent += `\n---\nCreated: ${ts}\n`;
741
+ (0, state_js_1.writeFile)(path.join(dir, "task.md"), taskContent);
547
742
  const entry = `\n---\n\n# Step: ${stepName}\n\nStarted: ${ts}\n\n`;
548
743
  fs.appendFileSync(path.join(dir, "history.md"), entry, "utf-8");
549
744
  console.log(`Entered step: ${stepName} (round reset to 1)`);
745
+ if (taskDesc) {
746
+ console.log(` Subtask #1: ${taskDesc}`);
747
+ }
748
+ }
749
+ function parseSubtasks(taskContent) {
750
+ const subtasks = [];
751
+ const lines = taskContent.split("\n");
752
+ for (const line of lines) {
753
+ const doneMatch = line.match(/^- \[x\] #(\d+)\s+(.*)/i);
754
+ if (doneMatch) {
755
+ subtasks.push({ index: parseInt(doneMatch[1], 10), text: doneMatch[2], done: true, line });
756
+ continue;
757
+ }
758
+ const todoMatch = line.match(/^- \[ \] #(\d+)\s+(.*)/);
759
+ if (todoMatch) {
760
+ subtasks.push({ index: parseInt(todoMatch[1], 10), text: todoMatch[2], done: false, line });
761
+ }
762
+ }
763
+ return subtasks;
764
+ }
765
+ // ─── subtask ─────────────────────────────────────
766
+ function cmdSubtask(args) {
767
+ (0, state_js_1.checkSession)();
768
+ const subcmd = args[0] || "";
769
+ const rest = args.slice(1);
770
+ switch (subcmd) {
771
+ case "add": {
772
+ const desc = rest.join(" ");
773
+ if (!desc) {
774
+ console.error('Usage: ralph-lisa subtask add "task description"');
775
+ process.exit(1);
776
+ }
777
+ const dir = (0, state_js_1.stateDir)();
778
+ const taskPath = path.join(dir, "task.md");
779
+ const content = (0, state_js_1.readFile)(taskPath);
780
+ const existing = parseSubtasks(content);
781
+ const nextIndex = existing.length > 0 ? Math.max(...existing.map((s) => s.index)) + 1 : 1;
782
+ const newLine = `- [ ] #${nextIndex} ${desc}`;
783
+ // Insert before the --- separator if present, otherwise append
784
+ const lines = content.split("\n");
785
+ const sepIdx = lines.findIndex((l) => l.startsWith("---"));
786
+ if (sepIdx !== -1) {
787
+ lines.splice(sepIdx, 0, newLine);
788
+ }
789
+ else {
790
+ lines.push(newLine);
791
+ }
792
+ (0, state_js_1.writeFile)(taskPath, lines.join("\n"));
793
+ console.log(`Added subtask #${nextIndex}: ${desc}`);
794
+ break;
795
+ }
796
+ case "done": {
797
+ const num = parseInt(rest[0], 10);
798
+ if (isNaN(num)) {
799
+ console.error("Usage: ralph-lisa subtask done <number>");
800
+ process.exit(1);
801
+ }
802
+ const dir = (0, state_js_1.stateDir)();
803
+ const taskPath = path.join(dir, "task.md");
804
+ const content = (0, state_js_1.readFile)(taskPath);
805
+ const subtasks = parseSubtasks(content);
806
+ const target = subtasks.find((s) => s.index === num);
807
+ if (!target) {
808
+ console.error(`Error: Subtask #${num} not found.`);
809
+ process.exit(1);
810
+ }
811
+ if (target.done) {
812
+ console.log(`Subtask #${num} already completed.`);
813
+ break;
814
+ }
815
+ const updated = content.replace(target.line, target.line.replace("- [ ]", "- [x]"));
816
+ (0, state_js_1.writeFile)(taskPath, updated);
817
+ (0, state_js_1.appendHistory)("System", `[SUBTASK] Completed #${num}: ${target.text}`);
818
+ console.log(`Completed subtask #${num}: ${target.text}`);
819
+ break;
820
+ }
821
+ case "list": {
822
+ const dir = (0, state_js_1.stateDir)();
823
+ const content = (0, state_js_1.readFile)(path.join(dir, "task.md"));
824
+ const subtasks = parseSubtasks(content);
825
+ if (subtasks.length === 0) {
826
+ console.log("No subtasks defined.");
827
+ break;
828
+ }
829
+ for (const s of subtasks) {
830
+ const mark = s.done ? "x" : " ";
831
+ console.log(` [${mark}] #${s.index} ${s.text}`);
832
+ }
833
+ const done = subtasks.filter((s) => s.done).length;
834
+ console.log(`\n${done}/${subtasks.length} completed`);
835
+ break;
836
+ }
837
+ default:
838
+ console.error("Usage: ralph-lisa subtask <add|done|list> [args]");
839
+ console.error(' subtask add "description" Add a new subtask');
840
+ console.error(" subtask done <number> Mark subtask as complete");
841
+ console.error(" subtask list List all subtasks");
842
+ process.exit(1);
843
+ }
550
844
  }
551
845
  // ─── history ─────────────────────────────────────
552
846
  function cmdHistory() {
@@ -576,12 +870,214 @@ function cmdClean() {
576
870
  console.log("Session cleaned");
577
871
  }
578
872
  }
579
- // ─── update-task ─────────────────────────────────
873
+ // ─── stop ─────────────────────────────────────────
874
+ /**
875
+ * Check if a PID belongs to our watcher/wrapper process by matching
876
+ * the exact watcher script path in the process args.
877
+ */
878
+ function isOurProcess(pid, dir) {
879
+ const watcherScriptPath = path.join(dir, "watcher.sh");
880
+ try {
881
+ const args = (0, node_child_process_1.execSync)(`ps -p ${pid} -o args= 2>/dev/null || true`)
882
+ .toString()
883
+ .trim();
884
+ return args !== "" && args.includes(watcherScriptPath);
885
+ }
886
+ catch {
887
+ return false;
888
+ }
889
+ }
890
+ /**
891
+ * Send a signal to a PID after verifying it belongs to our project.
892
+ * Returns true if signal was sent, false if skipped.
893
+ */
894
+ function signalOurProcess(pid, dir, signal, label) {
895
+ if (!isOurProcess(pid, dir)) {
896
+ console.log(` ${label}: PID ${pid} is not ours (stale PID file), skipping signal`);
897
+ return false;
898
+ }
899
+ try {
900
+ process.kill(Number(pid), signal);
901
+ console.log(` ${label}: sent ${signal} to PID ${pid}`);
902
+ return true;
903
+ }
904
+ catch {
905
+ console.log(` ${label}: PID ${pid} already dead`);
906
+ return false;
907
+ }
908
+ }
909
+ /**
910
+ * Clean stale PID files and orphaned state files.
911
+ */
912
+ function cleanStaleFiles(dir) {
913
+ const staleFiles = [
914
+ "watcher.pid",
915
+ "watcher_wrapper.pid",
916
+ "deadlock.txt",
917
+ ".checkpoint_ack",
918
+ "control.txt",
919
+ ".turn_changed",
920
+ ];
921
+ for (const f of staleFiles) {
922
+ const fp = path.join(dir, f);
923
+ try {
924
+ fs.unlinkSync(fp);
925
+ }
926
+ catch {
927
+ // file didn't exist
928
+ }
929
+ }
930
+ }
931
+ function cmdStop(args) {
932
+ const force = args.includes("--force");
933
+ const noArchive = args.includes("--no-archive");
934
+ const projectDir = process.cwd();
935
+ const dir = (0, state_js_1.stateDir)(projectDir);
936
+ const sessionName = generateSessionName(projectDir);
937
+ console.log(line());
938
+ console.log(force ? "Stopping (force)..." : "Stopping gracefully...");
939
+ console.log(line());
940
+ // Read PIDs before any action
941
+ const wrapperPidFile = path.join(dir, "watcher_wrapper.pid");
942
+ const watcherPidFile = path.join(dir, "watcher.pid");
943
+ const wrapperPid = fs.existsSync(wrapperPidFile)
944
+ ? (0, state_js_1.readFile)(wrapperPidFile).trim()
945
+ : "";
946
+ const watcherPid = fs.existsSync(watcherPidFile)
947
+ ? (0, state_js_1.readFile)(watcherPidFile).trim()
948
+ : "";
949
+ if (force) {
950
+ // -- Force stop: SIGKILL everything --
951
+ if (wrapperPid) {
952
+ signalOurProcess(wrapperPid, dir, "SIGKILL", "Wrapper");
953
+ }
954
+ if (watcherPid && isOurProcess(watcherPid, dir)) {
955
+ // Only collect children after confirming ownership to avoid
956
+ // killing children of an unrelated process (PID reuse safety)
957
+ let childPids = [];
958
+ try {
959
+ childPids = (0, node_child_process_1.execSync)(`pgrep -P ${watcherPid} 2>/dev/null || true`)
960
+ .toString()
961
+ .trim()
962
+ .split("\n")
963
+ .filter(Boolean);
964
+ }
965
+ catch {
966
+ // no children
967
+ }
968
+ signalOurProcess(watcherPid, dir, "SIGKILL", "Watcher");
969
+ for (const cpid of childPids) {
970
+ try {
971
+ process.kill(Number(cpid), "SIGKILL");
972
+ console.log(` Accelerator child: killed PID ${cpid}`);
973
+ }
974
+ catch {
975
+ // already dead
976
+ }
977
+ }
978
+ }
979
+ else if (watcherPid) {
980
+ console.log(` Watcher: PID ${watcherPid} is not ours (stale PID file), skipping signal`);
981
+ }
982
+ }
983
+ else {
984
+ // -- Graceful stop: SIGTERM + wait --
985
+ // 1. Stop wrapper first (prevent watcher restart)
986
+ if (wrapperPid) {
987
+ signalOurProcess(wrapperPid, dir, "SIGTERM", "Wrapper");
988
+ }
989
+ // 2. Stop watcher (triggers cleanup trap)
990
+ // Signal graceful stop so watcher clears .watcher_state (step39)
991
+ (0, state_js_1.writeFile)(path.join(dir, ".graceful_stop"), "1");
992
+ if (watcherPid) {
993
+ signalOurProcess(watcherPid, dir, "SIGTERM", "Watcher");
994
+ // Wait for watcher to clean up (up to 5s)
995
+ const deadline = Date.now() + 5000;
996
+ while (Date.now() < deadline && fs.existsSync(watcherPidFile)) {
997
+ (0, node_child_process_1.execSync)("sleep 0.5");
998
+ }
999
+ if (fs.existsSync(watcherPidFile)) {
1000
+ console.log(" Watcher did not exit cleanly, forcing...");
1001
+ signalOurProcess(watcherPid, dir, "SIGKILL", "Watcher (force)");
1002
+ }
1003
+ }
1004
+ // 3. Send /exit to agent panes
1005
+ try {
1006
+ (0, node_child_process_1.execSync)(`tmux has-session -t "${sessionName}" 2>/dev/null`);
1007
+ for (const pane of ["0.0", "0.1"]) {
1008
+ try {
1009
+ (0, node_child_process_1.execSync)(`tmux send-keys -t "${sessionName}:${pane}" "/exit" Enter 2>/dev/null`);
1010
+ console.log(` Sent /exit to pane ${pane}`);
1011
+ }
1012
+ catch {
1013
+ // pane doesn't exist
1014
+ }
1015
+ }
1016
+ // Wait for agents to exit (up to 10s)
1017
+ // Check pane process count rather than session existence,
1018
+ // since session stays alive until we explicitly kill it.
1019
+ const agentDeadline = Date.now() + 10000;
1020
+ while (Date.now() < agentDeadline) {
1021
+ try {
1022
+ // List pane PIDs — if only shells remain (no claude/codex), agents exited
1023
+ const paneProcs = (0, node_child_process_1.execSync)(`tmux list-panes -t "${sessionName}" -F "#{pane_pid}" 2>/dev/null || true`).toString().trim();
1024
+ if (!paneProcs)
1025
+ break;
1026
+ // Check if any pane still has agent child processes
1027
+ const pids = paneProcs.split("\n").filter(Boolean);
1028
+ let agentRunning = false;
1029
+ for (const pp of pids) {
1030
+ const children = (0, node_child_process_1.execSync)(`pgrep -P ${pp} 2>/dev/null || true`).toString().trim();
1031
+ if (children) {
1032
+ agentRunning = true;
1033
+ break;
1034
+ }
1035
+ }
1036
+ if (!agentRunning)
1037
+ break;
1038
+ (0, node_child_process_1.execSync)("sleep 1");
1039
+ }
1040
+ catch {
1041
+ break;
1042
+ }
1043
+ }
1044
+ }
1045
+ catch {
1046
+ // session doesn't exist
1047
+ }
1048
+ }
1049
+ // Kill tmux session if still alive
1050
+ try {
1051
+ (0, node_child_process_1.execSync)(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
1052
+ console.log(` Killed tmux session: ${sessionName}`);
1053
+ }
1054
+ catch {
1055
+ console.log(` No tmux session: ${sessionName}`);
1056
+ }
1057
+ // Clean state files
1058
+ cleanStaleFiles(dir);
1059
+ console.log(" Cleaned state files");
1060
+ // Archive
1061
+ if (!noArchive) {
1062
+ try {
1063
+ const archiveName = `stop-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
1064
+ cmdArchive([archiveName]);
1065
+ }
1066
+ catch {
1067
+ console.log(" Archive skipped (no session data)");
1068
+ }
1069
+ }
1070
+ console.log(line());
1071
+ console.log("Stopped.");
1072
+ console.log(line());
1073
+ }
1074
+ // ─── scope-update / update-task ──────────────────
580
1075
  function cmdUpdateTask(args) {
581
1076
  (0, state_js_1.checkSession)();
582
1077
  const newTask = args.join(" ");
583
1078
  if (!newTask) {
584
- console.error('Usage: ralph-lisa update-task "new task description"');
1079
+ console.error('Usage: ralph-lisa scope-update "new scope description"');
1080
+ console.error(' ralph-lisa update-task "new scope description" (alias)');
585
1081
  process.exit(1);
586
1082
  }
587
1083
  const dir = (0, state_js_1.stateDir)();
@@ -589,8 +1085,66 @@ function cmdUpdateTask(args) {
589
1085
  const ts = (0, state_js_1.timestamp)();
590
1086
  // Append new direction with timestamp (preserves history)
591
1087
  fs.appendFileSync(taskPath, `\n---\nUpdated: ${ts}\n\n${newTask}\n`, "utf-8");
592
- console.log(`Task updated: ${newTask}`);
593
- console.log(`(Appended to ${taskPath})`);
1088
+ // Log scope change to history (Proposal §3.1)
1089
+ (0, state_js_1.appendHistory)("System", `[SCOPE] Task scope updated: ${newTask}`);
1090
+ (0, state_js_1.updateLastAction)("System", `[SCOPE] ${newTask}`);
1091
+ // Clear deadlock flag — scope change resolves deadlocks (Proposal §3.2)
1092
+ const deadlockPath = path.join(dir, "deadlock.txt");
1093
+ try {
1094
+ fs.unlinkSync(deadlockPath);
1095
+ }
1096
+ catch { }
1097
+ const nwCountPath = path.join(dir, "needs_work_count.txt");
1098
+ (0, state_js_1.writeFile)(nwCountPath, "0");
1099
+ console.log(`Scope updated: ${newTask}`);
1100
+ console.log(`(Appended to ${taskPath}, logged to history.md)`);
1101
+ }
1102
+ // ─── force-turn ─────────────────────────────────
1103
+ function cmdForceTurn(args, confirmed = false) {
1104
+ (0, state_js_1.checkSession)();
1105
+ const agent = args[0];
1106
+ if (agent !== "ralph" && agent !== "lisa") {
1107
+ console.error("Usage: ralph-lisa force-turn <ralph|lisa>");
1108
+ process.exit(1);
1109
+ }
1110
+ const dir = (0, state_js_1.stateDir)();
1111
+ // Block in full-auto mode (watcher running = auto mode)
1112
+ const watcherPid = path.join(dir, "watcher.pid");
1113
+ if (fs.existsSync(watcherPid)) {
1114
+ const pid = (0, state_js_1.readFile)(watcherPid).trim();
1115
+ try {
1116
+ process.kill(Number(pid), 0); // check if alive
1117
+ console.error("Error: force-turn is disabled in auto mode (watcher is running).");
1118
+ console.error("Stop auto mode first, or manually edit turn.txt.");
1119
+ process.exit(1);
1120
+ }
1121
+ catch {
1122
+ // watcher not running, PID stale — allow
1123
+ }
1124
+ }
1125
+ if (!confirmed) {
1126
+ const readline = require("node:readline");
1127
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1128
+ rl.question(`Force turn to ${agent}? This skips normal review flow. (y/N) `, (answer) => {
1129
+ rl.close();
1130
+ if (answer.trim().toLowerCase() === "y") {
1131
+ executeForceTurn(agent, dir);
1132
+ }
1133
+ else {
1134
+ console.log("Aborted.");
1135
+ }
1136
+ });
1137
+ return;
1138
+ }
1139
+ executeForceTurn(agent, dir);
1140
+ }
1141
+ function executeForceTurn(agent, dir) {
1142
+ (0, state_js_1.setTurn)(agent);
1143
+ const ts = (0, state_js_1.timestamp)();
1144
+ (0, state_js_1.appendHistory)("System", `[FORCE] Turn manually assigned to ${agent} by user`);
1145
+ (0, state_js_1.updateLastAction)("System", `[FORCE] Turn assigned to ${agent}`);
1146
+ console.log(`Turn forced to: ${agent}`);
1147
+ console.log("(Logged to history.md)");
594
1148
  }
595
1149
  // ─── uninit ──────────────────────────────────────
596
1150
  const MARKER = "RALPH-LISA-LOOP";
@@ -760,14 +1314,21 @@ function cmdInitProject(args) {
760
1314
  }
761
1315
  // Find templates directory (shipped inside npm package)
762
1316
  const templatesDir = findTemplatesDir();
763
- // 1. Append Ralph role to CLAUDE.md
1317
+ // 1. Append/update Ralph role in CLAUDE.md
764
1318
  const claudeMd = path.join(resolvedDir, "CLAUDE.md");
1319
+ const ralphRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "ralph.md"));
765
1320
  if (fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER)) {
766
- console.log("[Claude] Ralph role already in CLAUDE.md, skipping...");
1321
+ // Marker present we own this content, update to latest template
1322
+ console.log("[Claude] Updating Ralph role in CLAUDE.md...");
1323
+ const existing = fs.readFileSync(claudeMd, "utf-8");
1324
+ const markerTag = `<!-- ${MARKER} -->`;
1325
+ const markerIdx = existing.indexOf(markerTag);
1326
+ const before = markerIdx > 0 ? existing.slice(0, markerIdx) : "";
1327
+ fs.writeFileSync(claudeMd, before + ralphRole, "utf-8");
1328
+ console.log("[Claude] Updated.");
767
1329
  }
768
1330
  else {
769
1331
  console.log("[Claude] Appending Ralph role to CLAUDE.md...");
770
- const ralphRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "ralph.md"));
771
1332
  if (fs.existsSync(claudeMd)) {
772
1333
  fs.appendFileSync(claudeMd, "\n\n", "utf-8");
773
1334
  }
@@ -776,12 +1337,19 @@ function cmdInitProject(args) {
776
1337
  }
777
1338
  // 2. Create/update CODEX.md with Lisa role
778
1339
  const codexMd = path.join(resolvedDir, "CODEX.md");
1340
+ const lisaRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "lisa.md"));
779
1341
  if (fs.existsSync(codexMd) && (0, state_js_1.readFile)(codexMd).includes(MARKER)) {
780
- console.log("[Codex] Lisa role already in CODEX.md, skipping...");
1342
+ // Marker present we own this content, update to latest template
1343
+ console.log("[Codex] Updating Lisa role in CODEX.md...");
1344
+ const existing = fs.readFileSync(codexMd, "utf-8");
1345
+ const markerTag = `<!-- ${MARKER} -->`;
1346
+ const markerIdx = existing.indexOf(markerTag);
1347
+ const before = markerIdx > 0 ? existing.slice(0, markerIdx) : "";
1348
+ fs.writeFileSync(codexMd, before + lisaRole, "utf-8");
1349
+ console.log("[Codex] Updated.");
781
1350
  }
782
1351
  else {
783
1352
  console.log("[Codex] Creating CODEX.md with Lisa role...");
784
- const lisaRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "lisa.md"));
785
1353
  if (fs.existsSync(codexMd)) {
786
1354
  fs.appendFileSync(codexMd, "\n\n", "utf-8");
787
1355
  }
@@ -1037,6 +1605,52 @@ function cmdAuto(args) {
1037
1605
  console.log("Mode: FULL AUTO (no permission prompts)");
1038
1606
  console.log("");
1039
1607
  const { execSync } = require("node:child_process");
1608
+ // Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
1609
+ const claudeMd = path.join(projectDir, "CLAUDE.md");
1610
+ const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
1611
+ const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
1612
+ if (!hasFullInit && !hasSession) {
1613
+ console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
1614
+ process.exit(1);
1615
+ }
1616
+ const sessionName = generateSessionName(projectDir);
1617
+ const dir = (0, state_js_1.stateDir)(projectDir);
1618
+ fs.mkdirSync(dir, { recursive: true });
1619
+ // Stale detection: check for leftover PID files from a previous run.
1620
+ // Runs before prerequisite checks so stale state is always cleaned,
1621
+ // even in environments without tmux/claude/codex.
1622
+ for (const pidFileName of ["watcher.pid", "watcher_wrapper.pid"]) {
1623
+ const pidFile = path.join(dir, pidFileName);
1624
+ if (fs.existsSync(pidFile)) {
1625
+ const pid = (0, state_js_1.readFile)(pidFile).trim();
1626
+ if (pid) {
1627
+ if (isOurProcess(pid, dir)) {
1628
+ console.error(`Error: ${pidFileName.replace(".pid", "")} is still running (PID ${pid}).`);
1629
+ console.error("Run 'ralph-lisa stop' first.");
1630
+ process.exit(1);
1631
+ }
1632
+ else {
1633
+ // PID is dead or belongs to another process — stale file
1634
+ console.log(`Warning: Stale ${pidFileName} found (PID ${pid} is not ours). Cleaning up.`);
1635
+ try {
1636
+ fs.unlinkSync(pidFile);
1637
+ }
1638
+ catch { }
1639
+ }
1640
+ }
1641
+ }
1642
+ }
1643
+ // Clean orphaned state files from stale runs
1644
+ for (const staleFile of ["deadlock.txt", ".checkpoint_ack", "control.txt"]) {
1645
+ const fp = path.join(dir, staleFile);
1646
+ if (fs.existsSync(fp)) {
1647
+ console.log(`Warning: Cleaning stale ${staleFile}`);
1648
+ try {
1649
+ fs.unlinkSync(fp);
1650
+ }
1651
+ catch { }
1652
+ }
1653
+ }
1040
1654
  // Check prerequisites
1041
1655
  try {
1042
1656
  execSync("which tmux", { stdio: "pipe" });
@@ -1081,23 +1695,22 @@ function cmdAuto(args) {
1081
1695
  console.log("");
1082
1696
  }
1083
1697
  }
1084
- // Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
1085
- const claudeMd = path.join(projectDir, "CLAUDE.md");
1086
- const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
1087
- const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
1088
- if (!hasFullInit && !hasSession) {
1089
- console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
1090
- process.exit(1);
1091
- }
1092
1698
  // Initialize task
1093
1699
  if (task) {
1094
1700
  console.log(`Task: ${task}`);
1095
1701
  cmdInit(task.split(" "));
1096
1702
  console.log("");
1097
1703
  }
1098
- const sessionName = generateSessionName(projectDir);
1099
- const dir = (0, state_js_1.stateDir)(projectDir);
1100
- fs.mkdirSync(dir, { recursive: true });
1704
+ // Check if tmux session already exists
1705
+ try {
1706
+ execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`);
1707
+ console.error(`Error: tmux session "${sessionName}" already exists.`);
1708
+ console.error("Run 'ralph-lisa stop' first.");
1709
+ process.exit(1);
1710
+ }
1711
+ catch {
1712
+ // session doesn't exist — good
1713
+ }
1101
1714
  // Archive pane logs from previous runs (for transcript preservation)
1102
1715
  const logsDir = path.join(dir, "logs");
1103
1716
  fs.mkdirSync(logsDir, { recursive: true });
@@ -1122,28 +1735,44 @@ function cmdAuto(args) {
1122
1735
  // Create watcher script
1123
1736
  const watcherScript = path.join(dir, "watcher.sh");
1124
1737
  let watcherContent = `#!/bin/bash
1125
- # Turn watcher v3 - fire-and-forget agent triggering
1738
+ # Turn watcher v4 - round-based change detection + persistent state
1126
1739
  # Architecture: polling main loop + optional event acceleration
1127
- # v3: Removed output stability wait + delivery verification (RLL-001)
1740
+ # v4: Round-based detection fixes double-flip deadlock (step39)
1128
1741
 
1129
1742
  STATE_DIR=".dual-agent"
1130
1743
  SESSION="${sessionName}"
1131
1744
  SCRIPT_PATH="\$(cd "\$(dirname "\$0")" && pwd)/watcher.sh"
1132
1745
  SEEN_TURN=""
1133
1746
  ACKED_TURN=""
1747
+ SEEN_ROUND=""
1748
+ ACKED_ROUND=""
1134
1749
  FAIL_COUNT=0
1135
1750
  ACCEL_PID=""
1136
1751
  LAST_ACK_TIME=0
1752
+ DELIVERY_PENDING=0
1753
+ PENDING_TARGET=""
1754
+ CONSENSUS_AT_ROUND=""
1755
+ WATCHER_STATE_FILE="\${STATE_DIR}/.watcher_state"
1137
1756
  CHECKPOINT_ROUNDS=\${RL_CHECKPOINT_ROUNDS:-0}
1138
1757
  CHECKPOINT_REMIND_TIME=0
1758
+ DEADLOCK_REMIND_TIME=0
1139
1759
  CLEANUP_DONE=0
1140
1760
 
1761
+ # Per-turn escalation state (step38: anti-flooding + stuck-agent detection)
1762
+ NOTIFY_SENT_AT=0 # epoch when first notification was sent this turn
1763
+ REMINDER_LEVEL=0 # 0=initial, 1=REMINDER sent, 2=slash sent, 3=user notified
1764
+ CURRENT_TURN_HASH="" # hash of turn.txt content for change detection
1765
+
1141
1766
  PANE0_LOG="\${STATE_DIR}/pane0.log"
1142
1767
  PANE1_LOG="\${STATE_DIR}/pane1.log"
1143
1768
  PID_FILE="\${STATE_DIR}/watcher.pid"
1144
1769
 
1145
1770
  # Interactive prompt patterns (do NOT send "go" if matched)
1146
- INTERACTIVE_RE='[Pp]assword[: ]|[Pp]assphrase|[Uu]sername[: ]|[Tt]oken[: ]|[Ll]ogin[: ]|\\(y/[Nn]\\)|\\(Y/[Nn]\\)|\\[y/[Nn]\\]|\\[Y/[Nn]\\]|Are you sure|Continue\\?|[Pp]ress [Ee]nter|MFA|2FA|one-time|OTP'
1771
+ # Covers: passwords, confirmations, Claude Code permission prompts, Codex approval prompts
1772
+ # NOTE: patterns must be specific enough to avoid false positives in normal agent output
1773
+ # BAD: [Aa]pprove — matches "I approve this approach" in agent output
1774
+ # GOOD: Allow once — only matches literal permission button text
1775
+ INTERACTIVE_RE='[Pp]assword[: ]|[Pp]assphrase|[Uu]sername[: ]|[Tt]oken[: ]|[Ll]ogin[: ]|\\(y/[Nn]\\)|\\(Y/[Nn]\\)|\\[y/[Nn]\\]|\\[Y/[Nn]\\]|Are you sure|Continue\\?|[Pp]ress [Ee]nter|MFA|2FA|one-time|OTP|Do you want to proceed|[Gg]rant .* access|[Aa]llow .* to run|[Aa]llow .* to edit|Allow once|Allow always|Deny once|Deny always|allow_once|allow_always|reject_once|reject_always|[Yy]es.*[Nn]o.*[Aa]lways'
1147
1776
 
1148
1777
  # Pause state per pane: 0=active, consecutive hit count
1149
1778
  PANE0_PROMPT_HITS=0
@@ -1169,6 +1798,42 @@ fi
1169
1798
 
1170
1799
  echo \$\$ > "\$PID_FILE"
1171
1800
 
1801
+ # ─── State persistence (step39) ─────────────────
1802
+
1803
+ save_watcher_state() {
1804
+ cat > "\$WATCHER_STATE_FILE" <<WSTATE
1805
+ SEEN_TURN=\$SEEN_TURN
1806
+ ACKED_TURN=\$ACKED_TURN
1807
+ SEEN_ROUND=\$SEEN_ROUND
1808
+ ACKED_ROUND=\$ACKED_ROUND
1809
+ DELIVERY_PENDING=\$DELIVERY_PENDING
1810
+ PENDING_TARGET=\$PENDING_TARGET
1811
+ WSTATE
1812
+ }
1813
+
1814
+ restore_watcher_state() {
1815
+ if [[ -f "\$WATCHER_STATE_FILE" ]]; then
1816
+ echo "[Watcher] Restoring state from \$WATCHER_STATE_FILE"
1817
+ source "\$WATCHER_STATE_FILE"
1818
+ echo "[Watcher] Restored: SEEN_ROUND=\$SEEN_ROUND ACKED_ROUND=\$ACKED_ROUND DELIVERY_PENDING=\$DELIVERY_PENDING"
1819
+ if (( DELIVERY_PENDING )) && [[ -n "\$PENDING_TARGET" ]]; then
1820
+ echo "[Watcher] Replaying pending delivery for \$PENDING_TARGET"
1821
+ if trigger_agent "\$PENDING_TARGET"; then
1822
+ DELIVERY_PENDING=0
1823
+ PENDING_TARGET=""
1824
+ LAST_ACK_TIME=\$(date +%s)
1825
+ save_watcher_state
1826
+ echo "[Watcher] Pending delivery replayed successfully"
1827
+ else
1828
+ echo "[Watcher] Pending delivery replay failed, will retry"
1829
+ fi
1830
+ fi
1831
+ fi
1832
+ }
1833
+
1834
+ # Clean only transient files on startup (NOT .watcher_state — that's for crash recovery)
1835
+ rm -f "\${STATE_DIR}/.checkpoint_ack" "\${STATE_DIR}/deadlock.txt" "\${STATE_DIR}/control.txt" "\${STATE_DIR}/.graceful_stop"
1836
+
1172
1837
  # ─── Cleanup trap ────────────────────────────────
1173
1838
 
1174
1839
  cleanup() {
@@ -1184,6 +1849,15 @@ cleanup() {
1184
1849
  fi
1185
1850
  # Clean up PID and flag files
1186
1851
  rm -f "\$PID_FILE" "\${STATE_DIR}/.turn_changed"
1852
+ # Only remove .watcher_state on graceful stop (step39)
1853
+ # .graceful_stop is written by 'ralph-lisa stop' before SIGTERM
1854
+ # Crash/unexpected exits preserve .watcher_state so wrapper restart can replay pending deliveries
1855
+ if [[ -f "\${STATE_DIR}/.graceful_stop" ]]; then
1856
+ rm -f "\$WATCHER_STATE_FILE" "\${STATE_DIR}/.graceful_stop"
1857
+ echo "[Watcher] Graceful stop — watcher state cleared"
1858
+ else
1859
+ echo "[Watcher] Unexpected exit — watcher state preserved for crash recovery"
1860
+ fi
1187
1861
  # Archive pane logs (not delete) so transcripts are preserved
1188
1862
  local logs_dir="\${STATE_DIR}/logs"
1189
1863
  mkdir -p "\$logs_dir"
@@ -1315,9 +1989,28 @@ send_go_to_pane() {
1315
1989
  return 1
1316
1990
  fi
1317
1991
 
1318
- # 3. Send trigger message + Enter with retry
1319
- # tmux send-keys is synchronous no need to verify delivery via log growth
1320
- # Use first 20 chars as detection marker (long messages wrap in narrow panes)
1992
+ # 3. Wait for agent to be idle (output stable for 5s)
1993
+ # Prevents injecting text while agent is mid-response
1994
+ local stable_wait=0
1995
+ while (( stable_wait < 30 )); do
1996
+ if check_output_stable "\$log_file" 5; then
1997
+ break
1998
+ fi
1999
+ echo "[Watcher] Waiting for \$agent_name to finish output..."
2000
+ sleep 3
2001
+ stable_wait=\$((stable_wait + 3))
2002
+ done
2003
+ if (( stable_wait >= 30 )); then
2004
+ echo "[Watcher] \$agent_name still producing output after 30s, sending anyway"
2005
+ fi
2006
+
2007
+ # 4. Re-check interactive prompt (may have appeared while waiting)
2008
+ if check_for_interactive_prompt "\$pane"; then
2009
+ echo "[Watcher] Skipping \$agent_name - interactive prompt appeared during wait"
2010
+ return 1
2011
+ fi
2012
+
2013
+ # 5. Send trigger message + Enter with retry
1321
2014
  local detect_marker="\${go_msg:0:20}"
1322
2015
  while (( attempt < max_retries )); do
1323
2016
  tmux send-keys -t "\${SESSION}:\${pane}" -l "\$go_msg" 2>/dev/null || true
@@ -1344,8 +2037,29 @@ send_go_to_pane() {
1344
2037
  return 1
1345
2038
  fi
1346
2039
 
1347
- echo "[Watcher] OK: Message sent to \$agent_name (fire-and-forget)"
1348
- return 0
2040
+ # 6. Post-send verification: wait up to 20s for agent to start responding
2041
+ # Record size AFTER send+retry completes (not before), so we only measure
2042
+ # the agent's actual response, not the injected text appearing in the pane.
2043
+ local post_send_baseline=0
2044
+ if [[ -f "\$log_file" ]]; then
2045
+ post_send_baseline=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
2046
+ fi
2047
+ local verify_wait=0
2048
+ while (( verify_wait < 20 )); do
2049
+ sleep 4
2050
+ verify_wait=\$((verify_wait + 4))
2051
+ if [[ -f "\$log_file" ]]; then
2052
+ local cur_size
2053
+ cur_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
2054
+ if (( cur_size > post_send_baseline + 100 )); then
2055
+ echo "[Watcher] OK: \$agent_name responded (output grew +\$((cur_size - post_send_baseline)) bytes)"
2056
+ return 0
2057
+ fi
2058
+ fi
2059
+ done
2060
+
2061
+ echo "[Watcher] WARN: \$agent_name did not produce output after send — may not have received message"
2062
+ return 1
1349
2063
  }
1350
2064
 
1351
2065
  # ─── trigger_agent ───────────────────────────────
@@ -1433,6 +2147,32 @@ trigger_agent() {
1433
2147
  return 1
1434
2148
  }
1435
2149
 
2150
+ # ─── consensus detection (step38: suppress notifications after consensus) ────
2151
+
2152
+ # Returns 0 if consensus reached (suppress notifications), 1 otherwise
2153
+ check_consensus_reached() {
2154
+ local work_file="\${STATE_DIR}/work.md"
2155
+ local review_file="\${STATE_DIR}/review.md"
2156
+
2157
+ if [[ ! -f "\$work_file" ]] || [[ ! -f "\$review_file" ]]; then
2158
+ return 1
2159
+ fi
2160
+
2161
+ # Extract last tag from canonical header: ## [TAG] Round N | Step: ...
2162
+ # Use sed (POSIX-compatible) instead of grep -oP (Perl, not available on macOS)
2163
+ local work_tag review_tag
2164
+ work_tag=\$(grep '^## \\[' "\$work_file" | grep '] Round [0-9]' | sed 's/^## \\[\\([A-Z_]*\\)\\].*/\\1/' | tail -1)
2165
+ review_tag=\$(grep '^## \\[' "\$review_file" | grep '] Round [0-9]' | sed 's/^## \\[\\([A-Z_]*\\)\\].*/\\1/' | tail -1)
2166
+
2167
+ # Consensus combos: CONSENSUS+CONSENSUS, CONSENSUS+PASS, PASS+CONSENSUS
2168
+ if [[ "\$work_tag" == "CONSENSUS" && "\$review_tag" == "CONSENSUS" ]] ||
2169
+ [[ "\$work_tag" == "CONSENSUS" && "\$review_tag" == "PASS" ]] ||
2170
+ [[ "\$work_tag" == "PASS" && "\$review_tag" == "CONSENSUS" ]]; then
2171
+ return 0
2172
+ fi
2173
+ return 1
2174
+ }
2175
+
1436
2176
  # ─── check_and_trigger (state machine) ───────────
1437
2177
 
1438
2178
  check_and_trigger() {
@@ -1445,27 +2185,118 @@ check_and_trigger() {
1445
2185
  truncate_log_if_needed "0.0" "\$PANE0_LOG"
1446
2186
  truncate_log_if_needed "0.1" "\$PANE1_LOG"
1447
2187
 
2188
+ # Control file polling (Proposal §3.11): process commands from control.txt
2189
+ if [[ -f "\$STATE_DIR/control.txt" ]]; then
2190
+ local ctrl_cmd
2191
+ ctrl_cmd=\$(cat "\$STATE_DIR/control.txt" 2>/dev/null)
2192
+ rm -f "\$STATE_DIR/control.txt"
2193
+ if [[ -n "\$ctrl_cmd" ]]; then
2194
+ echo "[Watcher] Control command: \$ctrl_cmd"
2195
+ case "\$ctrl_cmd" in
2196
+ pause)
2197
+ echo "[Watcher] PAUSED by control file. Write 'resume' to control.txt to continue."
2198
+ while true; do
2199
+ sleep 2
2200
+ if [[ -f "\$STATE_DIR/control.txt" ]]; then
2201
+ local resume_cmd
2202
+ resume_cmd=\$(cat "\$STATE_DIR/control.txt" 2>/dev/null)
2203
+ rm -f "\$STATE_DIR/control.txt"
2204
+ if [[ "\$resume_cmd" == "resume" ]]; then
2205
+ echo "[Watcher] RESUMED by control file."
2206
+ # Reset escalation state so we restart from L0 (step38)
2207
+ NOTIFY_SENT_AT=0
2208
+ REMINDER_LEVEL=0
2209
+ break
2210
+ else
2211
+ echo "[Watcher] Ignoring '\$resume_cmd' while paused (only 'resume' accepted)"
2212
+ fi
2213
+ fi
2214
+ done
2215
+ ;;
2216
+ resume)
2217
+ echo "[Watcher] Already running — resume ignored."
2218
+ ;;
2219
+ "msg ralph "*)
2220
+ local ralph_msg="\${ctrl_cmd#msg ralph }"
2221
+ echo "[Watcher] Sending message to Ralph: \$ralph_msg"
2222
+ send_go_to_pane "0.0" "Ralph" "\$PANE0_LOG" "\$ralph_msg"
2223
+ ;;
2224
+ "msg lisa "*)
2225
+ local lisa_msg="\${ctrl_cmd#msg lisa }"
2226
+ echo "[Watcher] Sending message to Lisa: \$lisa_msg"
2227
+ send_go_to_pane "0.1" "Lisa" "\$PANE1_LOG" "\$lisa_msg"
2228
+ ;;
2229
+ "scope-update "*)
2230
+ local new_scope="\${ctrl_cmd#scope-update }"
2231
+ echo "[Watcher] Updating scope: \$new_scope"
2232
+ ralph-lisa scope-update "\$new_scope" 2>&1 || true
2233
+ ;;
2234
+ *)
2235
+ echo "[Watcher] Unknown control command: \$ctrl_cmd"
2236
+ ;;
2237
+ esac
2238
+ fi
2239
+ fi
2240
+
2241
+ # Deadlock detection (Proposal §3.2): pause if deadlock.txt exists
2242
+ if [[ -f "\$STATE_DIR/deadlock.txt" ]]; then
2243
+ local now_epoch
2244
+ now_epoch=\$(date +%s)
2245
+ if (( DEADLOCK_REMIND_TIME == 0 )) || (( now_epoch - DEADLOCK_REMIND_TIME >= 30 )); then
2246
+ echo "[Watcher] DEADLOCK detected — pausing. See: \$STATE_DIR/deadlock.txt"
2247
+ echo "[Watcher] To resolve: ralph-lisa scope-update or ralph-lisa force-turn"
2248
+ echo "[Watcher] Or remove: rm \$STATE_DIR/deadlock.txt"
2249
+ DEADLOCK_REMIND_TIME=\$now_epoch
2250
+ fi
2251
+ return
2252
+ fi
2253
+
1448
2254
  if [[ -f "\$STATE_DIR/turn.txt" ]]; then
1449
2255
  CURRENT_TURN=\$(cat "\$STATE_DIR/turn.txt" 2>/dev/null || echo "")
2256
+ CURRENT_ROUND=\$(cat "\$STATE_DIR/round.txt" 2>/dev/null || echo "0")
1450
2257
 
1451
- # Detect new turn change (reset fail count + cooldown)
1452
- if [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
1453
- echo "[Watcher] Turn changed: \$SEEN_TURN -> \$CURRENT_TURN"
2258
+ # Detect new round (step39: round-based detection fixes double-flip deadlock)
2259
+ # Round is monotonically increasing, so A→B→A during delivery is always detected.
2260
+ local round_changed=0
2261
+ if [[ -n "\$CURRENT_ROUND" && "\$CURRENT_ROUND" != "\$SEEN_ROUND" ]]; then
2262
+ round_changed=1
2263
+ echo "[Watcher] Round changed: \$SEEN_ROUND -> \$CURRENT_ROUND (turn: \$SEEN_TURN -> \$CURRENT_TURN)"
1454
2264
  SEEN_TURN="\$CURRENT_TURN"
2265
+ SEEN_ROUND="\$CURRENT_ROUND"
1455
2266
  FAIL_COUNT=0
1456
2267
  LAST_ACK_TIME=0
2268
+ # Reset per-turn escalation state (step38)
2269
+ NOTIFY_SENT_AT=0
2270
+ REMINDER_LEVEL=0
2271
+
2272
+ # Mark delivery pending (step39: decouple ack from delivery)
2273
+ DELIVERY_PENDING=1
2274
+ PENDING_TARGET="\$CURRENT_TURN"
2275
+ save_watcher_state
1457
2276
 
1458
2277
  # Write round separator to pane logs for transcript tracking
1459
2278
  local round_ts
1460
2279
  round_ts=\$(date "+%Y-%m-%d %H:%M:%S")
1461
- local round_marker="\\n\\n===== [Turn -> \$CURRENT_TURN] \$round_ts =====\\n\\n"
2280
+ local round_marker="\\n\\n===== [Round \$CURRENT_ROUND -> \$CURRENT_TURN] \$round_ts =====\\n\\n"
1462
2281
  echo -e "\$round_marker" >> "\$PANE0_LOG" 2>/dev/null || true
1463
2282
  echo -e "\$round_marker" >> "\$PANE1_LOG" 2>/dev/null || true
2283
+ elif [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
2284
+ # Fallback: turn changed without round change (e.g., force-turn)
2285
+ echo "[Watcher] Turn changed (no round change): \$SEEN_TURN -> \$CURRENT_TURN"
2286
+ SEEN_TURN="\$CURRENT_TURN"
2287
+ FAIL_COUNT=0
2288
+ LAST_ACK_TIME=0
2289
+ NOTIFY_SENT_AT=0
2290
+ REMINDER_LEVEL=0
2291
+ DELIVERY_PENDING=1
2292
+ PENDING_TARGET="\$CURRENT_TURN"
2293
+ save_watcher_state
2294
+ round_changed=1
1464
2295
  fi
1465
2296
 
1466
2297
  # Cooldown: skip delivery if last ack was < 30s ago (prevents re-triggering during normal work)
1467
- # Placed AFTER turn-change detection so new turns are never suppressed
1468
- if (( LAST_ACK_TIME > 0 )); then
2298
+ # ONLY applies when round has NOT changed — new rounds always deliver immediately (step39)
2299
+ if (( !round_changed && LAST_ACK_TIME > 0 )); then
1469
2300
  local now_epoch
1470
2301
  now_epoch=\$(date +%s)
1471
2302
  local elapsed=\$(( now_epoch - LAST_ACK_TIME ))
@@ -1474,6 +2305,21 @@ check_and_trigger() {
1474
2305
  fi
1475
2306
  fi
1476
2307
 
2308
+ # Consensus suppression (step38): suppress notifications when consensus reached
2309
+ # step39: only suppress if round hasn't changed since consensus was detected
2310
+ if check_consensus_reached; then
2311
+ if [[ "\$CONSENSUS_AT_ROUND" == "" ]]; then
2312
+ CONSENSUS_AT_ROUND="\$CURRENT_ROUND"
2313
+ fi
2314
+ if [[ "\$CURRENT_ROUND" == "\$CONSENSUS_AT_ROUND" ]]; then
2315
+ return
2316
+ fi
2317
+ # Round changed since consensus — clear suppression, deliver normally
2318
+ CONSENSUS_AT_ROUND=""
2319
+ else
2320
+ CONSENSUS_AT_ROUND=""
2321
+ fi
2322
+
1477
2323
  # Checkpoint: pause for user review at configured round intervals
1478
2324
  if (( CHECKPOINT_ROUNDS > 0 )); then
1479
2325
  local round
@@ -1500,8 +2346,8 @@ check_and_trigger() {
1500
2346
  fi
1501
2347
  fi
1502
2348
 
1503
- # Need to deliver? (seen but not yet acked)
1504
- if [[ -n "\$SEEN_TURN" && "\$SEEN_TURN" != "\$ACKED_TURN" ]]; then
2349
+ # Need to deliver? (step39: round-based + DELIVERY_PENDING)
2350
+ if (( DELIVERY_PENDING )) || [[ -n "\$SEEN_ROUND" && "\$SEEN_ROUND" != "\$ACKED_ROUND" ]]; then
1505
2351
  # Backoff on repeated failures
1506
2352
  if (( FAIL_COUNT >= 30 )); then
1507
2353
  echo "[Watcher] ALERT: \$FAIL_COUNT consecutive failures. Manual intervention needed."
@@ -1511,29 +2357,90 @@ check_and_trigger() {
1511
2357
  sleep 30
1512
2358
  fi
1513
2359
 
1514
- if trigger_agent "\$SEEN_TURN"; then
2360
+ local deliver_target="\${PENDING_TARGET:-\$SEEN_TURN}"
2361
+ if trigger_agent "\$deliver_target"; then
1515
2362
  ACKED_TURN="\$SEEN_TURN"
2363
+ ACKED_ROUND="\$SEEN_ROUND"
2364
+ DELIVERY_PENDING=0
2365
+ PENDING_TARGET=""
1516
2366
  FAIL_COUNT=0
1517
2367
  LAST_ACK_TIME=\$(date +%s)
1518
- echo "[Watcher] Turn acknowledged: \$SEEN_TURN (cooldown 30s)"
2368
+ NOTIFY_SENT_AT=\$(date +%s)
2369
+ REMINDER_LEVEL=0
2370
+ save_watcher_state
2371
+ echo "[Watcher] Round \$SEEN_ROUND acknowledged: \$SEEN_TURN (cooldown 30s)"
1519
2372
  else
1520
2373
  FAIL_COUNT=\$((FAIL_COUNT + 1))
1521
2374
  echo "[Watcher] Trigger failed (fail_count=\$FAIL_COUNT), will retry next cycle"
1522
2375
  fi
2376
+
2377
+ # Escalation (step38): agent was notified but turn hasn't changed
2378
+ elif [[ -n "\$SEEN_TURN" && "\$SEEN_ROUND" == "\$ACKED_ROUND" && "\$NOTIFY_SENT_AT" -gt 0 ]]; then
2379
+ local now_epoch elapsed
2380
+ now_epoch=\$(date +%s)
2381
+ elapsed=\$(( now_epoch - NOTIFY_SENT_AT ))
2382
+
2383
+ # Determine target pane for escalation
2384
+ local target_pane target_name target_log
2385
+ if [[ "\$SEEN_TURN" == "ralph" ]]; then
2386
+ target_pane="0.0"; target_name="Ralph"; target_log="\$PANE0_LOG"
2387
+ else
2388
+ target_pane="0.1"; target_name="Lisa"; target_log="\$PANE1_LOG"
2389
+ fi
2390
+
2391
+ # Check for context limit in pane output (unrecoverable — notify user immediately)
2392
+ local pane_tail
2393
+ pane_tail=\$(tmux capture-pane -t "\${SESSION}:\${target_pane}" -p 2>/dev/null | tail -10)
2394
+ if echo "\$pane_tail" | grep -qiE "context limit|conversation too long|token limit|context window"; then
2395
+ if (( REMINDER_LEVEL < 3 )); then
2396
+ echo "[Watcher] CONTEXT LIMIT detected for \$target_name. Manual intervention required."
2397
+ echo "[Watcher] Restart the agent session to continue."
2398
+ REMINDER_LEVEL=3
2399
+ fi
2400
+
2401
+ # Time-based escalation: each level checked independently by elapsed time.
2402
+ # If L1/L2 delivery fails, time still advances, so L3 is always reachable.
2403
+
2404
+ # Level 3: notify user after 10 minutes — always reachable regardless of L1/L2 success
2405
+ elif (( elapsed >= 600 && REMINDER_LEVEL < 3 )); then
2406
+ echo "[Watcher] STUCK: \$target_name has not responded for \${elapsed}s. Manual intervention needed."
2407
+ REMINDER_LEVEL=3
2408
+
2409
+ # Level 2: slash command after 5 minutes, with prompt guard
2410
+ elif (( elapsed >= 300 && REMINDER_LEVEL < 2 )); then
2411
+ if ! check_for_interactive_prompt "\$target_pane"; then
2412
+ echo "[Watcher] Escalation L2: Sending /check-turn to \$target_name (no response for \${elapsed}s)"
2413
+ if send_go_to_pane "\$target_pane" "\$target_name" "\$target_log" "/check-turn"; then
2414
+ REMINDER_LEVEL=2
2415
+ fi
2416
+ else
2417
+ echo "[Watcher] Escalation L2: Skipped — interactive prompt detected for \$target_name"
2418
+ fi
2419
+
2420
+ # Level 1: REMINDER after 2 minutes
2421
+ elif (( elapsed >= 120 && REMINDER_LEVEL < 1 )); then
2422
+ echo "[Watcher] Escalation L1: Sending REMINDER to \$target_name (no response for \${elapsed}s)"
2423
+ if send_go_to_pane "\$target_pane" "\$target_name" "\$target_log" "REMINDER: It is your turn. Please check turn and continue working."; then
2424
+ REMINDER_LEVEL=1
2425
+ fi
2426
+ fi
1523
2427
  fi
1524
2428
  fi
1525
2429
  }
1526
2430
 
1527
2431
  # ─── Main ────────────────────────────────────────
1528
2432
 
1529
- echo "[Watcher] Starting v3... (Ctrl+C to stop)"
1530
- echo "[Watcher] Monitoring \$STATE_DIR/turn.txt"
2433
+ echo "[Watcher] Starting v4... (Ctrl+C to stop)"
2434
+ echo "[Watcher] Monitoring \$STATE_DIR/turn.txt + round.txt"
1531
2435
  echo "[Watcher] Pane logs: \$PANE0_LOG, \$PANE1_LOG"
1532
2436
  if (( CHECKPOINT_ROUNDS > 0 )); then
1533
2437
  echo "[Watcher] Checkpoint every \$CHECKPOINT_ROUNDS rounds (RL_CHECKPOINT_ROUNDS)"
1534
2438
  fi
1535
2439
  echo "[Watcher] PID: \$\$"
1536
2440
 
2441
+ # Restore state from crash (step39)
2442
+ restore_watcher_state
2443
+
1537
2444
  sleep 5
1538
2445
  check_and_trigger
1539
2446
 
@@ -1578,6 +2485,9 @@ done
1578
2485
  execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`);
1579
2486
  // Pane 0: Ralph (left), Pane 1: Lisa (right)
1580
2487
  execSync(`tmux new-session -d -s "${sessionName}" -n "main" -c "${projectDir}"`);
2488
+ // Set authoritative state directory in tmux env (Proposal §3.10)
2489
+ // Both agents resolve to this regardless of their working directory
2490
+ execSync(`tmux set-environment -t "${sessionName}" RL_STATE_DIR "${dir}"`);
1581
2491
  execSync(`tmux split-window -h -t "${sessionName}" -c "${projectDir}"`);
1582
2492
  // Pane 0 = Ralph (left), Pane 1 = Lisa (right)
1583
2493
  execSync(`tmux send-keys -t "${sessionName}:0.0" "echo '=== Ralph (Claude Code) ===' && ${claudeCmd}" Enter`);
@@ -1713,7 +2623,7 @@ function cmdPolicyCheckConsensus() {
1713
2623
  }
1714
2624
  /**
1715
2625
  * Comprehensive check for proceeding to the next step:
1716
- * 1. Both agents have submitted [CONSENSUS]
2626
+ * 1. Consensus reached: [CONSENSUS]+[CONSENSUS], [CONSENSUS]+[PASS], or [PASS]+[CONSENSUS]
1717
2627
  * 2. Ralph's submission passes policy checks
1718
2628
  * 3. Lisa's submission passes policy checks
1719
2629
  */
@@ -1727,12 +2637,13 @@ function cmdPolicyCheckNextStep() {
1727
2637
  const workTag = workContent ? (0, state_js_1.extractTag)(workContent) : "";
1728
2638
  const reviewTag = reviewContent ? (0, state_js_1.extractTag)(reviewContent) : "";
1729
2639
  const allIssues = [];
1730
- // 1. Consensus check
1731
- if (workTag !== "CONSENSUS") {
1732
- allIssues.push(`Ralph's latest is [${workTag || "none"}], not [CONSENSUS].`);
1733
- }
1734
- if (reviewTag !== "CONSENSUS") {
1735
- allIssues.push(`Lisa's latest is [${reviewTag || "none"}], not [CONSENSUS].`);
2640
+ // 1. Consensus check — mirrors cmdStep() logic: accept CONSENSUS+CONSENSUS,
2641
+ // CONSENSUS+PASS, or PASS+CONSENSUS combinations
2642
+ const consensusReached = (workTag === "CONSENSUS" && reviewTag === "CONSENSUS") ||
2643
+ (workTag === "CONSENSUS" && reviewTag === "PASS") ||
2644
+ (workTag === "PASS" && reviewTag === "CONSENSUS");
2645
+ if (!consensusReached) {
2646
+ allIssues.push(`Consensus not reached: Ralph=[${workTag || "none"}], Lisa=[${reviewTag || "none"}]. Need [CONSENSUS]+[CONSENSUS] or [PASS]+[CONSENSUS] combination.`);
1736
2647
  }
1737
2648
  // 2. Policy checks on latest submissions (if content exists)
1738
2649
  if (workContent && workTag) {
@@ -1746,7 +2657,7 @@ function cmdPolicyCheckNextStep() {
1746
2657
  allIssues.push(`Lisa: ${v.message}`);
1747
2658
  }
1748
2659
  if (allIssues.length === 0) {
1749
- console.log("Ready to proceed: consensus reached and all checks pass.");
2660
+ console.log("Ready to proceed: consensus reached ([CONSENSUS]/[PASS] combination) and all checks pass.");
1750
2661
  return;
1751
2662
  }
1752
2663
  console.error("Not ready to proceed:");
@@ -1848,6 +2759,255 @@ function cmdLogs(args) {
1848
2759
  console.log(" ralph-lisa logs cat View live pane logs");
1849
2760
  console.log(" ralph-lisa logs cat <file> View specific log file");
1850
2761
  }
2762
+ // ─── remote (ttyd) ──────────────────────────────
2763
+ /**
2764
+ * Check if a PID belongs to a ttyd process to avoid killing unrelated processes.
2765
+ */
2766
+ function isTtydProcess(pid) {
2767
+ try {
2768
+ // Check if process is alive first
2769
+ process.kill(pid, 0);
2770
+ // Verify it's actually ttyd by checking command line
2771
+ const cmdline = (0, node_child_process_1.execSync)(`ps -p ${pid} -o comm=`, { encoding: "utf-8", stdio: "pipe" }).trim();
2772
+ return cmdline.includes("ttyd");
2773
+ }
2774
+ catch {
2775
+ return false;
2776
+ }
2777
+ }
2778
+ function cmdRemote(args) {
2779
+ (0, state_js_1.checkSession)();
2780
+ const dir = (0, state_js_1.stateDir)();
2781
+ const pidFile = path.join(dir, "ttyd.pid");
2782
+ // --stop / stop: kill existing ttyd
2783
+ if (args.includes("--stop") || args.includes("stop")) {
2784
+ if (!fs.existsSync(pidFile)) {
2785
+ console.log("No ttyd server running.");
2786
+ return;
2787
+ }
2788
+ const pid = Number((0, state_js_1.readFile)(pidFile));
2789
+ if (isTtydProcess(pid)) {
2790
+ process.kill(pid, "SIGTERM");
2791
+ console.log(`Stopped ttyd (PID ${pid}).`);
2792
+ }
2793
+ else {
2794
+ console.log(`Stale PID ${pid} (not a ttyd process). Cleaning up.`);
2795
+ }
2796
+ try {
2797
+ fs.unlinkSync(pidFile);
2798
+ }
2799
+ catch { }
2800
+ // Clean up the dedicated remote tmux session
2801
+ try {
2802
+ (0, node_child_process_1.execSync)("tmux list-sessions -F '#{session_name}'", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
2803
+ .trim().split("\n")
2804
+ .filter((s) => s.endsWith("-remote"))
2805
+ .forEach((s) => { try {
2806
+ (0, node_child_process_1.execSync)(`tmux kill-session -t ${s}`, { stdio: "pipe" });
2807
+ }
2808
+ catch { } });
2809
+ }
2810
+ catch { }
2811
+ return;
2812
+ }
2813
+ // Check ttyd installed
2814
+ try {
2815
+ (0, node_child_process_1.execSync)("which ttyd", { stdio: "pipe" });
2816
+ }
2817
+ catch {
2818
+ console.error("Error: ttyd not found.");
2819
+ console.error("Install: brew install ttyd (macOS) / apt install ttyd (Linux)");
2820
+ console.error("Or visit: https://github.com/tsl0922/ttyd");
2821
+ process.exit(1);
2822
+ }
2823
+ // Find tmux session: explicit arg > auto-detect > list available
2824
+ const knownFlags = ["--port", "--auth", "--stop"];
2825
+ const reservedWords = ["stop"];
2826
+ const explicitSession = args.find((a) => !a.startsWith("--") && !reservedWords.includes(a) && !knownFlags.includes(args[args.indexOf(a) - 1]));
2827
+ // Resolve session: explicit arg > auto-detect from cwd > single running rll-* session
2828
+ let rllSessions = [];
2829
+ try {
2830
+ rllSessions = (0, node_child_process_1.execSync)("tmux list-sessions -F '#{session_name}'", {
2831
+ encoding: "utf-8",
2832
+ stdio: ["pipe", "pipe", "pipe"],
2833
+ })
2834
+ .trim()
2835
+ .split("\n")
2836
+ .filter((s) => s.startsWith("rll-") && !/-\d+$/.test(s));
2837
+ }
2838
+ catch { }
2839
+ let sessionName;
2840
+ if (explicitSession) {
2841
+ sessionName = explicitSession;
2842
+ }
2843
+ else {
2844
+ // Try auto-detect from project dir
2845
+ const projectRoot = (0, state_js_1.findProjectRoot)() || process.cwd();
2846
+ const detected = generateSessionName(projectRoot);
2847
+ if (rllSessions.includes(detected)) {
2848
+ sessionName = detected;
2849
+ }
2850
+ else if (rllSessions.length === 1) {
2851
+ // Only one rll-* session running — use it
2852
+ sessionName = rllSessions[0];
2853
+ }
2854
+ else {
2855
+ sessionName = detected; // will fail below with helpful hint
2856
+ }
2857
+ }
2858
+ try {
2859
+ (0, node_child_process_1.execSync)(`tmux has-session -t ${sessionName}`, { stdio: "pipe" });
2860
+ }
2861
+ catch {
2862
+ let hint = "";
2863
+ if (rllSessions.length > 0) {
2864
+ hint = `\nAvailable sessions:\n${rllSessions.map((s) => ` ${s}`).join("\n")}\n\nUsage: ralph-lisa remote <session-name>`;
2865
+ }
2866
+ console.error(`Error: tmux session '${sessionName}' not found.${hint}`);
2867
+ if (!hint)
2868
+ console.error("Start a session first: ralph-lisa auto \"task\"");
2869
+ process.exit(1);
2870
+ }
2871
+ // Parse options
2872
+ let port = 7681;
2873
+ let authFlag = "";
2874
+ const portIdx = args.indexOf("--port");
2875
+ if (portIdx !== -1 && args[portIdx + 1]) {
2876
+ port = Number(args[portIdx + 1]);
2877
+ if (isNaN(port) || port < 1 || port > 65535) {
2878
+ console.error("Error: --port must be a valid port number (1-65535).");
2879
+ process.exit(1);
2880
+ }
2881
+ }
2882
+ const authIdx = args.indexOf("--auth");
2883
+ if (authIdx !== -1 && args[authIdx + 1]) {
2884
+ authFlag = `-c ${args[authIdx + 1]}`;
2885
+ }
2886
+ // Kill existing ttyd if running
2887
+ if (fs.existsSync(pidFile)) {
2888
+ const oldPid = Number((0, state_js_1.readFile)(pidFile));
2889
+ if (isTtydProcess(oldPid)) {
2890
+ try {
2891
+ process.kill(oldPid, "SIGTERM");
2892
+ }
2893
+ catch { }
2894
+ }
2895
+ try {
2896
+ fs.unlinkSync(pidFile);
2897
+ }
2898
+ catch { }
2899
+ }
2900
+ // Create a dedicated grouped session for ttyd so browser clients
2901
+ // get their own tmux client (independent input/scroll from Mac).
2902
+ // Re-use the same grouped session across browser refreshes.
2903
+ const remoteSession = `${sessionName}-remote`;
2904
+ try {
2905
+ (0, node_child_process_1.execSync)(`tmux has-session -t ${remoteSession}`, { stdio: "pipe" });
2906
+ }
2907
+ catch {
2908
+ (0, node_child_process_1.execSync)(`tmux new-session -d -s ${remoteSession} -t ${sessionName}`, { stdio: "pipe" });
2909
+ }
2910
+ // Launch ttyd — attach to the dedicated remote session
2911
+ const { spawn } = require("node:child_process");
2912
+ const ttydArgs = ["-p", String(port)];
2913
+ if (authFlag) {
2914
+ const [user, pass] = args[authIdx + 1].split(":");
2915
+ ttydArgs.push("-c", `${user}:${pass}`);
2916
+ }
2917
+ ttydArgs.push("tmux", "attach", "-t", remoteSession);
2918
+ const child = spawn("ttyd", ttydArgs, {
2919
+ detached: true,
2920
+ stdio: "ignore",
2921
+ });
2922
+ child.unref();
2923
+ (0, state_js_1.writeFile)(pidFile, String(child.pid));
2924
+ // Detect LAN IP
2925
+ let lanIp = "localhost";
2926
+ try {
2927
+ const os = require("node:os");
2928
+ const nets = os.networkInterfaces();
2929
+ for (const name of Object.keys(nets)) {
2930
+ for (const net of nets[name]) {
2931
+ if (net.family === "IPv4" && !net.internal) {
2932
+ lanIp = net.address;
2933
+ break;
2934
+ }
2935
+ }
2936
+ if (lanIp !== "localhost")
2937
+ break;
2938
+ }
2939
+ }
2940
+ catch { }
2941
+ console.log(line());
2942
+ console.log("ttyd server started");
2943
+ console.log(line());
2944
+ console.log("");
2945
+ console.log(` Local: http://localhost:${port}`);
2946
+ console.log(` Network: http://${lanIp}:${port}`);
2947
+ console.log("");
2948
+ console.log(` Session: ${sessionName}`);
2949
+ console.log(` PID: ${child.pid}`);
2950
+ if (authFlag) {
2951
+ console.log(" Auth: enabled");
2952
+ }
2953
+ else {
2954
+ console.log(" Auth: none (use --auth user:pass for LAN access)");
2955
+ }
2956
+ console.log("");
2957
+ console.log("Stop with: ralph-lisa remote --stop");
2958
+ console.log(line());
2959
+ }
2960
+ // ─── state-dir ───────────────────────────────────
2961
+ function cmdStateDir(args) {
2962
+ const setPath = args[0];
2963
+ if (setPath) {
2964
+ // Set mode: write to tmux env if in tmux, otherwise show instructions
2965
+ const resolvedPath = path.resolve(setPath);
2966
+ if (process.env.TMUX) {
2967
+ try {
2968
+ (0, node_child_process_1.execSync)(`tmux set-environment RL_STATE_DIR "${resolvedPath}"`, { stdio: "pipe" });
2969
+ console.log(`State dir set (tmux env): ${resolvedPath}`);
2970
+ }
2971
+ catch {
2972
+ console.error("Error: Failed to set tmux environment variable.");
2973
+ process.exit(1);
2974
+ }
2975
+ }
2976
+ else {
2977
+ console.log(`Not in tmux session. Set manually:`);
2978
+ console.log(` export RL_STATE_DIR="${resolvedPath}"`);
2979
+ }
2980
+ return;
2981
+ }
2982
+ // Show mode: display all sources + active
2983
+ const tmuxDir = (0, state_js_1.getTmuxStateDir)();
2984
+ const envDir = process.env.RL_STATE_DIR || null;
2985
+ const root = (0, state_js_1.findProjectRoot)();
2986
+ const autoDir = root ? path.join(root, state_js_1.STATE_DIR) : path.join(process.cwd(), state_js_1.STATE_DIR);
2987
+ const resolved = (0, state_js_1.resolveStateDir)();
2988
+ console.log(`tmux env: ${tmuxDir || "(not set)"}`);
2989
+ console.log(`shell env: ${envDir || "(not set)"}`);
2990
+ console.log(`auto-detect: ${autoDir}`);
2991
+ console.log(`→ using: ${resolved.dir} [${resolved.source}]`);
2992
+ }
2993
+ // ─── add-context ─────────────────────────────────
2994
+ function cmdAddContext(args) {
2995
+ (0, state_js_1.checkSession)();
2996
+ const text = args.join(" ");
2997
+ if (!text) {
2998
+ console.error('Usage: ralph-lisa add-context "user directive or context note"');
2999
+ process.exit(1);
3000
+ }
3001
+ const dir = (0, state_js_1.stateDir)();
3002
+ const contextPath = path.join(dir, "context.md");
3003
+ const ts = (0, state_js_1.timestamp)();
3004
+ if (!fs.existsSync(contextPath)) {
3005
+ (0, state_js_1.writeFile)(contextPath, `# Context Notes\n\nRuntime directives from Ralph (visible to Lisa).\n\n`);
3006
+ }
3007
+ fs.appendFileSync(contextPath, `- [${ts}] ${text}\n`, "utf-8");
3008
+ console.log(`Context added: ${text}`);
3009
+ console.log(`(Written to ${contextPath})`);
3010
+ }
1851
3011
  // ─── doctor ──────────────────────────────────────
1852
3012
  function cmdDoctor(args) {
1853
3013
  const strict = args.includes("--strict");
@@ -1892,6 +3052,13 @@ function cmdDoctor(args) {
1892
3052
  required: false,
1893
3053
  installHint: "apt install inotify-tools (Linux)",
1894
3054
  },
3055
+ {
3056
+ name: "ttyd (remote access)",
3057
+ cmd: "which ttyd",
3058
+ versionCmd: "ttyd --version",
3059
+ required: false,
3060
+ installHint: "brew install ttyd (macOS) / apt install ttyd (Linux)",
3061
+ },
1895
3062
  ];
1896
3063
  for (const check of checks) {
1897
3064
  try {