ralph-lisa-loop 0.3.0 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -144,6 +144,27 @@ Policy rules:
144
144
  - Ralph's [RESEARCH] must have substantive content
145
145
  - Lisa's [PASS]/[NEEDS_WORK] must include at least 1 reason
146
146
 
147
+ ### Mid-Session Task Update
148
+ Change direction without restarting:
149
+ ```bash
150
+ ralph-lisa update-task "switch to REST instead of GraphQL"
151
+ ```
152
+ Appends to task.md (preserving history). Task context is auto-injected into work.md submissions and watcher trigger messages so both agents always see the current goal.
153
+
154
+ ### Round 1 Mandatory Plan
155
+ Ralph's first submission must be `[PLAN]` — gives Lisa a chance to verify task understanding before coding begins.
156
+
157
+ ### Goal Guardian
158
+ Lisa reads task.md before every review and checks for direction drift. Catching misalignment early is prioritized over code-level review.
159
+
160
+ ### Watcher v3
161
+ - **Fire-and-forget triggering**: Removed output stability wait and delivery verification for faster turn transitions
162
+ - **30s cooldown**: Prevents re-triggering during normal work
163
+ - **Checkpoint system**: Set `RL_CHECKPOINT_ROUNDS=N` to pause for human review every N rounds
164
+ - **Auto-restart**: Watcher automatically restarts on crash (session-guarded)
165
+ - **Configurable log threshold**: `RL_LOG_MAX_MB` (default 5, min 1) with proportional tail retention
166
+ - **Heartbeat file**: `.dual-agent/.watcher_heartbeat` for external liveness checks
167
+
147
168
  ### Deadlock Escape
148
169
  After 5 rounds without consensus: `[OVERRIDE]` (proceed anyway) or `[HANDOFF]` (escalate to human).
149
170
 
@@ -172,6 +193,7 @@ ralph-lisa history # Full history
172
193
 
173
194
  # Flow control
174
195
  ralph-lisa step "phase-name" # Enter new phase
196
+ ralph-lisa update-task "new direction" # Update task direction mid-session
175
197
  ralph-lisa archive [name] # Archive session
176
198
  ralph-lisa clean # Clean session
177
199
 
@@ -199,6 +221,7 @@ your-project/
199
221
  │ └── skills/ # Codex skills
200
222
  └── .dual-agent/ # Session state
201
223
  ├── turn.txt # Current turn
224
+ ├── task.md # Task goal (updated via update-task)
202
225
  ├── work.md # Ralph's submissions
203
226
  ├── review.md # Lisa's submissions
204
227
  └── history.md # Full history
@@ -220,6 +243,14 @@ For auto mode:
220
243
  - tmux (required)
221
244
  - fswatch (macOS) or inotify-tools (Linux) — optional, speeds up turn detection; falls back to polling without them
222
245
 
246
+ ## Environment Variables
247
+
248
+ | Variable | Default | Description |
249
+ |----------|---------|-------------|
250
+ | `RL_POLICY_MODE` | `off` | Policy check mode: `off`, `warn`, `block` |
251
+ | `RL_CHECKPOINT_ROUNDS` | `0` (disabled) | Pause for human review every N rounds |
252
+ | `RL_LOG_MAX_MB` | `5` | Pane log truncation threshold in MB (min 1) |
253
+
223
254
  ## Ecosystem
224
255
 
225
256
  Part of the [TigerHill](https://github.com/Click-Intelligence-LLC/TigerHill) project family.
package/dist/cli.js CHANGED
@@ -65,6 +65,9 @@ switch (cmd) {
65
65
  case "logs":
66
66
  (0, commands_js_1.cmdLogs)(rest);
67
67
  break;
68
+ case "update-task":
69
+ (0, commands_js_1.cmdUpdateTask)(rest);
70
+ break;
68
71
  case "help":
69
72
  case "--help":
70
73
  case "-h":
@@ -111,6 +114,7 @@ function showHelp() {
111
114
  console.log("");
112
115
  console.log("Flow Control:");
113
116
  console.log(' ralph-lisa step "name" Enter new step');
117
+ console.log(' ralph-lisa update-task "desc" Update task direction');
114
118
  console.log(" ralph-lisa archive [name] Archive session");
115
119
  console.log(" ralph-lisa clean Clean session");
116
120
  console.log("");
@@ -13,6 +13,7 @@ export declare function cmdStep(args: string[]): void;
13
13
  export declare function cmdHistory(): void;
14
14
  export declare function cmdArchive(args: string[]): void;
15
15
  export declare function cmdClean(): void;
16
+ export declare function cmdUpdateTask(args: string[]): void;
16
17
  export declare function cmdUninit(): void;
17
18
  export declare function cmdInitProject(args: string[]): void;
18
19
  export declare function cmdStart(args: string[]): void;
package/dist/commands.js CHANGED
@@ -48,6 +48,7 @@ exports.cmdStep = cmdStep;
48
48
  exports.cmdHistory = cmdHistory;
49
49
  exports.cmdArchive = cmdArchive;
50
50
  exports.cmdClean = cmdClean;
51
+ exports.cmdUpdateTask = cmdUpdateTask;
51
52
  exports.cmdUninit = cmdUninit;
52
53
  exports.cmdInitProject = cmdInitProject;
53
54
  exports.cmdStart = cmdStart;
@@ -183,6 +184,11 @@ function cmdSubmitRalph(args) {
183
184
  const ts = (0, state_js_1.timestamp)();
184
185
  const summary = (0, state_js_1.extractSummary)(content);
185
186
  const dir = (0, state_js_1.stateDir)();
187
+ // Auto-inject task context so Lisa always sees the task goal
188
+ // Use last meaningful line (update-task appends new directions at the end)
189
+ const taskFile = (0, state_js_1.readFile)(path.join(dir, "task.md"));
190
+ const taskLines = taskFile.split("\n").filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---") && !l.startsWith("Created:") && !l.startsWith("Updated:"));
191
+ const taskContext = taskLines[taskLines.length - 1] || "";
186
192
  // Auto-attach files_changed for CODE/FIX submissions
187
193
  let filesChangedSection = "";
188
194
  if (tag === "CODE" || tag === "FIX") {
@@ -191,7 +197,8 @@ function cmdSubmitRalph(args) {
191
197
  filesChangedSection = `**Files Changed**:\n${files.map((f) => `- ${f}`).join("\n")}\n\n`;
192
198
  }
193
199
  }
194
- (0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
200
+ const taskLine = taskContext ? `**Task**: ${taskContext}\n` : "";
201
+ (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`);
195
202
  // External sources (--file/--stdin) get compact history to reduce context bloat
196
203
  const historyContent = external
197
204
  ? `[${tag}] ${summary}\n\n(Full content in work.md)`
@@ -514,6 +521,22 @@ function cmdClean() {
514
521
  console.log("Session cleaned");
515
522
  }
516
523
  }
524
+ // ─── update-task ─────────────────────────────────
525
+ function cmdUpdateTask(args) {
526
+ (0, state_js_1.checkSession)();
527
+ const newTask = args.join(" ");
528
+ if (!newTask) {
529
+ console.error('Usage: ralph-lisa update-task "new task description"');
530
+ process.exit(1);
531
+ }
532
+ const dir = (0, state_js_1.stateDir)();
533
+ const taskPath = path.join(dir, "task.md");
534
+ const ts = (0, state_js_1.timestamp)();
535
+ // Append new direction with timestamp (preserves history)
536
+ fs.appendFileSync(taskPath, `\n---\nUpdated: ${ts}\n\n${newTask}\n`, "utf-8");
537
+ console.log(`Task updated: ${newTask}`);
538
+ console.log(`(Appended to ${taskPath})`);
539
+ }
517
540
  // ─── uninit ──────────────────────────────────────
518
541
  const MARKER = "RALPH-LISA-LOOP";
519
542
  function cmdUninit() {
@@ -1044,8 +1067,9 @@ function cmdAuto(args) {
1044
1067
  // Create watcher script
1045
1068
  const watcherScript = path.join(dir, "watcher.sh");
1046
1069
  let watcherContent = `#!/bin/bash
1047
- # Turn watcher v2 - reliable agent triggering with health checks
1070
+ # Turn watcher v3 - fire-and-forget agent triggering
1048
1071
  # Architecture: polling main loop + optional event acceleration
1072
+ # v3: Removed output stability wait + delivery verification (RLL-001)
1049
1073
 
1050
1074
  STATE_DIR=".dual-agent"
1051
1075
  SESSION="${sessionName}"
@@ -1054,6 +1078,10 @@ SEEN_TURN=""
1054
1078
  ACKED_TURN=""
1055
1079
  FAIL_COUNT=0
1056
1080
  ACCEL_PID=""
1081
+ LAST_ACK_TIME=0
1082
+ CHECKPOINT_ROUNDS=\${RL_CHECKPOINT_ROUNDS:-0}
1083
+ CHECKPOINT_REMIND_TIME=0
1084
+ CLEANUP_DONE=0
1057
1085
 
1058
1086
  PANE0_LOG="\${STATE_DIR}/pane0.log"
1059
1087
  PANE1_LOG="\${STATE_DIR}/pane1.log"
@@ -1089,6 +1117,8 @@ echo \$\$ > "\$PID_FILE"
1089
1117
  # ─── Cleanup trap ────────────────────────────────
1090
1118
 
1091
1119
  cleanup() {
1120
+ if (( CLEANUP_DONE )); then return; fi
1121
+ CLEANUP_DONE=1
1092
1122
  echo "[Watcher] Shutting down..."
1093
1123
  # Stop pipe-pane capture
1094
1124
  tmux pipe-pane -t "\${SESSION}:0.0" 2>/dev/null || true
@@ -1192,15 +1222,18 @@ check_for_interactive_prompt() {
1192
1222
  truncate_log_if_needed() {
1193
1223
  local pane="\$1"
1194
1224
  local log_file="\$2"
1195
- local max_bytes=1048576 # 1MB
1225
+ local max_mb=\${RL_LOG_MAX_MB:-5}
1226
+ if (( max_mb < 1 )); then max_mb=1; fi
1227
+ local max_bytes=$(( max_mb * 1048576 ))
1228
+ local tail_bytes=$(( max_mb * 102400 )) # ~10% of max
1196
1229
 
1197
1230
  if [[ ! -f "\$log_file" ]]; then return; fi
1198
1231
  local size
1199
1232
  size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
1200
1233
  if (( size > max_bytes )); then
1201
- echo "[Watcher] Truncating \$log_file (\${size} bytes > 1MB)"
1234
+ echo "[Watcher] Truncating \$log_file (\${size} bytes > \${max_mb}MB)"
1202
1235
  tmux pipe-pane -t "\${SESSION}:\${pane}" 2>/dev/null || true
1203
- tail -c 102400 "\$log_file" > "\${log_file}.tmp" && mv "\${log_file}.tmp" "\$log_file"
1236
+ tail -c \$tail_bytes "\$log_file" > "\${log_file}.tmp" && mv "\${log_file}.tmp" "\$log_file"
1204
1237
  tmux pipe-pane -o -t "\${SESSION}:\${pane}" "cat >> \\"\$log_file\\"" 2>/dev/null || true
1205
1238
  fi
1206
1239
  }
@@ -1227,35 +1260,8 @@ send_go_to_pane() {
1227
1260
  return 1
1228
1261
  fi
1229
1262
 
1230
- # 3. Wait for output to stabilize (max 60s, then FAIL — not continue)
1231
- local wait_count=0
1232
- while ! check_output_stable "\$log_file" 5; do
1233
- wait_count=\$((wait_count + 1))
1234
- if (( wait_count > 30 )); then
1235
- echo "[Watcher] WARNING: \$agent_name output not stabilizing after 60s, returning failure"
1236
- return 1
1237
- fi
1238
- sleep 2
1239
- done
1240
-
1241
- # 4. Double-confirm stability
1242
- sleep 2
1243
- if ! check_output_stable "\$log_file" 2; then
1244
- echo "[Watcher] \$agent_name output resumed during confirmation wait, returning failure"
1245
- return 1
1246
- fi
1247
-
1248
- # 5. Re-check interactive prompt
1249
- if check_for_interactive_prompt "\$pane"; then
1250
- echo "[Watcher] Skipping \$agent_name - interactive prompt detected (post-wait)"
1251
- return 1
1252
- fi
1253
-
1254
- # 6. Record log size before sending
1255
- local pre_size
1256
- pre_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ' || echo 0)
1257
-
1258
- # 7. Send trigger message + Enter with retry
1263
+ # 3. Send trigger message + Enter with retry
1264
+ # tmux send-keys is synchronous — no need to verify delivery via log growth
1259
1265
  # Use first 20 chars as detection marker (long messages wrap in narrow panes)
1260
1266
  local detect_marker="\${go_msg:0:20}"
1261
1267
  while (( attempt < max_retries )); do
@@ -1277,16 +1283,13 @@ send_go_to_pane() {
1277
1283
  fi
1278
1284
  done
1279
1285
 
1280
- # 8. Verify delivery: did log file grow?
1281
- sleep 5
1282
- local post_size
1283
- post_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ' || echo 0)
1284
- if (( post_size <= pre_size )); then
1285
- echo "[Watcher] WARNING: No new output from \$agent_name after sending 'go'"
1286
+ # Check if retries exhausted (message never submitted)
1287
+ if (( attempt >= max_retries )); then
1288
+ echo "[Watcher] FAILED: Could not deliver message to \$agent_name after \$max_retries retries"
1286
1289
  return 1
1287
1290
  fi
1288
1291
 
1289
- echo "[Watcher] OK: \$agent_name is working (output \$pre_size -> \$post_size)"
1292
+ echo "[Watcher] OK: Message sent to \$agent_name (fire-and-forget)"
1290
1293
  return 0
1291
1294
  }
1292
1295
 
@@ -1294,6 +1297,15 @@ send_go_to_pane() {
1294
1297
 
1295
1298
  trigger_agent() {
1296
1299
  local turn="\$1"
1300
+
1301
+ # Read task context for trigger messages (last meaningful line = latest direction)
1302
+ local task_ctx=""
1303
+ if [[ -f "\$STATE_DIR/task.md" ]]; then
1304
+ # Extract last meaningful line (skip header, separators, timestamps)
1305
+ # Consistent with cmdSubmitRalph which also uses last meaningful line
1306
+ task_ctx=\$(grep -v '^#\\|^---\\|^Created:\\|^Updated:\\|^$' "\$STATE_DIR/task.md" 2>/dev/null | tail -1)
1307
+ fi
1308
+
1297
1309
  if [[ "\$turn" == "ralph" ]]; then
1298
1310
  # Check pause state
1299
1311
  if (( PANE0_PAUSED )); then
@@ -1308,7 +1320,11 @@ trigger_agent() {
1308
1320
  return 1
1309
1321
  fi
1310
1322
  fi
1311
- local ralph_msg="Your turn. Lisa's feedback is ready — run: ralph-lisa read review.md"
1323
+ local ralph_msg="Your turn."
1324
+ if [[ -n "\$task_ctx" ]]; then
1325
+ ralph_msg="Your turn. Task: \${task_ctx}."
1326
+ fi
1327
+ ralph_msg="\${ralph_msg} Lisa's feedback is ready — run: ralph-lisa read review.md"
1312
1328
  send_go_to_pane "0.0" "Ralph" "\$PANE0_LOG" "\$ralph_msg"
1313
1329
  local rc=\$?
1314
1330
  if (( rc != 0 )); then
@@ -1338,7 +1354,11 @@ trigger_agent() {
1338
1354
  return 1
1339
1355
  fi
1340
1356
  fi
1341
- local lisa_msg="Your turn. Ralph's work is ready — run: ralph-lisa read work.md"
1357
+ local lisa_msg="Your turn."
1358
+ if [[ -n "\$task_ctx" ]]; then
1359
+ lisa_msg="Your turn. Task: \${task_ctx}."
1360
+ fi
1361
+ lisa_msg="\${lisa_msg} Ralph's work is ready — run: ralph-lisa read work.md"
1342
1362
  send_go_to_pane "0.1" "Lisa" "\$PANE1_LOG" "\$lisa_msg"
1343
1363
  local rc=\$?
1344
1364
  if (( rc != 0 )); then
@@ -1363,6 +1383,9 @@ trigger_agent() {
1363
1383
  check_and_trigger() {
1364
1384
  check_session_alive
1365
1385
 
1386
+ # Heartbeat: write epoch so external tools can check watcher liveness
1387
+ echo \$(date +%s) > "\${STATE_DIR}/.watcher_heartbeat"
1388
+
1366
1389
  # Truncate logs if too large
1367
1390
  truncate_log_if_needed "0.0" "\$PANE0_LOG"
1368
1391
  truncate_log_if_needed "0.1" "\$PANE1_LOG"
@@ -1370,11 +1393,12 @@ check_and_trigger() {
1370
1393
  if [[ -f "\$STATE_DIR/turn.txt" ]]; then
1371
1394
  CURRENT_TURN=\$(cat "\$STATE_DIR/turn.txt" 2>/dev/null || echo "")
1372
1395
 
1373
- # Detect new turn change (reset fail count)
1396
+ # Detect new turn change (reset fail count + cooldown)
1374
1397
  if [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
1375
1398
  echo "[Watcher] Turn changed: \$SEEN_TURN -> \$CURRENT_TURN"
1376
1399
  SEEN_TURN="\$CURRENT_TURN"
1377
1400
  FAIL_COUNT=0
1401
+ LAST_ACK_TIME=0
1378
1402
 
1379
1403
  # Write round separator to pane logs for transcript tracking
1380
1404
  local round_ts
@@ -1384,6 +1408,43 @@ check_and_trigger() {
1384
1408
  echo -e "\$round_marker" >> "\$PANE1_LOG" 2>/dev/null || true
1385
1409
  fi
1386
1410
 
1411
+ # Cooldown: skip delivery if last ack was < 30s ago (prevents re-triggering during normal work)
1412
+ # Placed AFTER turn-change detection so new turns are never suppressed
1413
+ if (( LAST_ACK_TIME > 0 )); then
1414
+ local now_epoch
1415
+ now_epoch=\$(date +%s)
1416
+ local elapsed=\$(( now_epoch - LAST_ACK_TIME ))
1417
+ if (( elapsed < 30 )); then
1418
+ return
1419
+ fi
1420
+ fi
1421
+
1422
+ # Checkpoint: pause for user review at configured round intervals
1423
+ if (( CHECKPOINT_ROUNDS > 0 )); then
1424
+ local round
1425
+ round=\$(cat "\$STATE_DIR/round.txt" 2>/dev/null || echo 1)
1426
+ if (( round > 1 )) && (( (round - 1) % CHECKPOINT_ROUNDS == 0 )); then
1427
+ # At checkpoint round — file is source of truth (crash-safe)
1428
+ if [[ -f "\${STATE_DIR}/.checkpoint_ack" ]]; then
1429
+ # Acked — proceed (keep file until round advances past checkpoint)
1430
+ :
1431
+ else
1432
+ # Not acked — pause with periodic 30s reminder
1433
+ local now_epoch
1434
+ now_epoch=\$(date +%s)
1435
+ if (( CHECKPOINT_REMIND_TIME == 0 )) || (( now_epoch - CHECKPOINT_REMIND_TIME >= 30 )); then
1436
+ echo "[Watcher] CHECKPOINT: Round \$round. Review direction before continuing."
1437
+ echo "[Watcher] To continue: touch \${STATE_DIR}/.checkpoint_ack"
1438
+ CHECKPOINT_REMIND_TIME=\$now_epoch
1439
+ fi
1440
+ return
1441
+ fi
1442
+ else
1443
+ # Not at checkpoint round — clean up stale ack from previous checkpoint
1444
+ rm -f "\${STATE_DIR}/.checkpoint_ack"
1445
+ fi
1446
+ fi
1447
+
1387
1448
  # Need to deliver? (seen but not yet acked)
1388
1449
  if [[ -n "\$SEEN_TURN" && "\$SEEN_TURN" != "\$ACKED_TURN" ]]; then
1389
1450
  # Backoff on repeated failures
@@ -1398,7 +1459,8 @@ check_and_trigger() {
1398
1459
  if trigger_agent "\$SEEN_TURN"; then
1399
1460
  ACKED_TURN="\$SEEN_TURN"
1400
1461
  FAIL_COUNT=0
1401
- echo "[Watcher] Turn acknowledged: \$SEEN_TURN"
1462
+ LAST_ACK_TIME=\$(date +%s)
1463
+ echo "[Watcher] Turn acknowledged: \$SEEN_TURN (cooldown 30s)"
1402
1464
  else
1403
1465
  FAIL_COUNT=\$((FAIL_COUNT + 1))
1404
1466
  echo "[Watcher] Trigger failed (fail_count=\$FAIL_COUNT), will retry next cycle"
@@ -1409,9 +1471,12 @@ check_and_trigger() {
1409
1471
 
1410
1472
  # ─── Main ────────────────────────────────────────
1411
1473
 
1412
- echo "[Watcher] Starting v2... (Ctrl+C to stop)"
1474
+ echo "[Watcher] Starting v3... (Ctrl+C to stop)"
1413
1475
  echo "[Watcher] Monitoring \$STATE_DIR/turn.txt"
1414
1476
  echo "[Watcher] Pane logs: \$PANE0_LOG, \$PANE1_LOG"
1477
+ if (( CHECKPOINT_ROUNDS > 0 )); then
1478
+ echo "[Watcher] Checkpoint every \$CHECKPOINT_ROUNDS rounds (RL_CHECKPOINT_ROUNDS)"
1479
+ fi
1415
1480
  echo "[Watcher] PID: \$\$"
1416
1481
 
1417
1482
  sleep 5
@@ -1463,9 +1528,31 @@ done
1463
1528
  execSync(`tmux send-keys -t "${sessionName}:0.0" "echo '=== Ralph (Claude Code) ===' && ${claudeCmd}" Enter`);
1464
1529
  execSync(`tmux send-keys -t "${sessionName}:0.1" "echo '=== Lisa (Codex) ===' && ${codexCmd}" Enter`);
1465
1530
  execSync(`tmux select-pane -t "${sessionName}:0.0"`);
1466
- // Watcher runs in background (logs to .dual-agent/watcher.log)
1531
+ // Kill old wrapper process if present (prevents duplication on repeated cmdAuto)
1532
+ const wrapperPidFile = path.join(dir, "watcher_wrapper.pid");
1533
+ if (fs.existsSync(wrapperPidFile)) {
1534
+ const oldWrapperPid = (0, state_js_1.readFile)(wrapperPidFile).trim();
1535
+ if (oldWrapperPid) {
1536
+ try {
1537
+ // Validate process identity before killing (avoid PID reuse hazard)
1538
+ const oldArgs = execSync(`ps -p ${oldWrapperPid} -o args= 2>/dev/null || true`).toString().trim();
1539
+ if (oldArgs && oldArgs.includes("tmux has-session")) {
1540
+ execSync(`kill ${oldWrapperPid} 2>/dev/null || true`);
1541
+ try {
1542
+ execSync(`sleep 0.5`);
1543
+ }
1544
+ catch { /* ignore */ }
1545
+ }
1546
+ }
1547
+ catch {
1548
+ // ignore — process already dead or PID invalid
1549
+ }
1550
+ }
1551
+ fs.unlinkSync(wrapperPidFile);
1552
+ }
1553
+ // Watcher runs in background with session-guarded restart loop
1467
1554
  const watcherLog = path.join(dir, "watcher.log");
1468
- execSync(`bash -c 'nohup "${watcherScript}" > "${watcherLog}" 2>&1 &'`);
1555
+ execSync(`bash -c 'nohup bash -c '"'"'while tmux has-session -t "${sessionName}" 2>/dev/null; do bash "${watcherScript}"; EXIT_CODE=$?; if ! tmux has-session -t "${sessionName}" 2>/dev/null; then echo "[Watcher] Session gone, not restarting." >> "${watcherLog}"; break; fi; echo "[Watcher] Exited ($EXIT_CODE), restarting in 5s..." >> "${watcherLog}"; sleep 5; done'"'"' > "${watcherLog}" 2>&1 & echo $! > "${wrapperPidFile}"'`);
1469
1556
  console.log("");
1470
1557
  console.log(line());
1471
1558
  console.log("Auto Mode Started!");
@@ -592,3 +592,68 @@ function run(...args) {
592
592
  assert.ok(r.stdout.includes("[CODE]"));
593
593
  });
594
594
  });
595
+ (0, node_test_1.describe)("CLI: update-task", () => {
596
+ (0, node_test_1.beforeEach)(() => {
597
+ fs.rmSync(TMP, { recursive: true, force: true });
598
+ fs.mkdirSync(TMP, { recursive: true });
599
+ run("init", "--minimal");
600
+ });
601
+ (0, node_test_1.afterEach)(() => {
602
+ fs.rmSync(TMP, { recursive: true, force: true });
603
+ });
604
+ (0, node_test_1.it)("creates task entry with timestamp", () => {
605
+ const r = run("update-task", "New direction for the project");
606
+ assert.strictEqual(r.exitCode, 0);
607
+ assert.ok(r.stdout.includes("Task updated"));
608
+ const task = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
609
+ assert.ok(task.includes("New direction for the project"));
610
+ assert.ok(task.includes("Updated:"));
611
+ });
612
+ (0, node_test_1.it)("preserves original task when updating", () => {
613
+ const task = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
614
+ const originalContent = task.trim();
615
+ run("update-task", "Changed direction");
616
+ const updated = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
617
+ // Original content still present
618
+ assert.ok(updated.includes("Waiting for task assignment"));
619
+ assert.ok(updated.includes("Changed direction"));
620
+ });
621
+ (0, node_test_1.it)("fails when no description given", () => {
622
+ const r = run("update-task");
623
+ assert.notStrictEqual(r.exitCode, 0);
624
+ assert.ok(r.stdout.includes("Usage"));
625
+ });
626
+ });
627
+ (0, node_test_1.describe)("CLI: task context in work.md", () => {
628
+ (0, node_test_1.beforeEach)(() => {
629
+ fs.rmSync(TMP, { recursive: true, force: true });
630
+ fs.mkdirSync(TMP, { recursive: true });
631
+ run("init", "--minimal");
632
+ });
633
+ (0, node_test_1.afterEach)(() => {
634
+ fs.rmSync(TMP, { recursive: true, force: true });
635
+ });
636
+ (0, node_test_1.it)("auto-injects Task field into work.md from task.md", () => {
637
+ // Update task to have a meaningful description
638
+ run("update-task", "Implement login feature");
639
+ run("submit-ralph", "[PLAN] My plan");
640
+ const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
641
+ assert.ok(work.includes("**Task**: Implement login feature"), "work.md should contain Task field");
642
+ });
643
+ (0, node_test_1.it)("work.md has no Task field when task.md has no meaningful content", () => {
644
+ // Default task.md has "Waiting for task assignment" — this IS meaningful content
645
+ run("submit-ralph", "[PLAN] My plan");
646
+ const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
647
+ // Should include the default task text
648
+ assert.ok(work.includes("**Task**:"), "work.md should have Task field even with default task");
649
+ });
650
+ (0, node_test_1.it)("uses latest task direction after multiple update-task calls", () => {
651
+ run("update-task", "First direction");
652
+ run("update-task", "Second direction");
653
+ run("update-task", "Final direction");
654
+ run("submit-ralph", "[PLAN] My plan");
655
+ const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
656
+ assert.ok(work.includes("**Task**: Final direction"), "work.md should use latest task direction");
657
+ assert.ok(!work.includes("**Task**: First direction"), "work.md should NOT use first task direction");
658
+ });
659
+ });
@@ -51,24 +51,34 @@ function newState() {
51
51
  seenTurn: "",
52
52
  ackedTurn: "",
53
53
  failCount: 0,
54
+ lastAckTime: 0,
54
55
  panePromptHits: 0,
55
56
  panePaused: false,
56
57
  panePauseSize: 0,
57
58
  };
58
59
  }
59
60
  /**
60
- * Simulate check_and_trigger logic (matches bash watcher v2).
61
+ * Simulate check_and_trigger logic (matches bash watcher v3).
61
62
  * Two-variable approach: seenTurn (observed) vs ackedTurn (delivered).
62
63
  * triggerResult: true = trigger succeeded, false = failed.
64
+ * nowTime: simulated current epoch time for cooldown testing.
63
65
  * Returns the action taken.
64
66
  */
65
- function checkAndTrigger(state, currentTurn, triggerResult) {
66
- // Detect new turn change
67
+ function checkAndTrigger(state, currentTurn, triggerResult, nowTime = 0) {
68
+ // 1. Detect new turn change (BEFORE cooldown — new turns are never suppressed)
67
69
  if (currentTurn && currentTurn !== state.seenTurn) {
68
70
  state.seenTurn = currentTurn;
69
71
  state.failCount = 0;
72
+ state.lastAckTime = 0; // Reset cooldown on new turn
70
73
  }
71
- // Need to deliver? (seen but not acked)
74
+ // 2. Cooldown: skip delivery if last ack was < 30s ago
75
+ if (state.lastAckTime > 0 && nowTime > 0) {
76
+ const elapsed = nowTime - state.lastAckTime;
77
+ if (elapsed < 30) {
78
+ return "cooldown";
79
+ }
80
+ }
81
+ // 3. Need to deliver? (seen but not acked)
72
82
  if (state.seenTurn && state.seenTurn !== state.ackedTurn) {
73
83
  let mode = "retry";
74
84
  if (state.failCount >= 30) {
@@ -80,6 +90,7 @@ function checkAndTrigger(state, currentTurn, triggerResult) {
80
90
  if (triggerResult) {
81
91
  state.ackedTurn = state.seenTurn;
82
92
  state.failCount = 0;
93
+ state.lastAckTime = nowTime;
83
94
  return "ack";
84
95
  }
85
96
  else {
@@ -89,6 +100,27 @@ function checkAndTrigger(state, currentTurn, triggerResult) {
89
100
  }
90
101
  return "noop";
91
102
  }
103
+ /**
104
+ * Simulate send_go_to_pane retry-exhaustion logic (v3).
105
+ * Returns true if message was delivered, false if retries exhausted.
106
+ */
107
+ function simulateSendGo(agentAlive, interactivePrompt, enterRegistered, // per-attempt: did Enter register?
108
+ maxRetries = 3) {
109
+ if (!agentAlive)
110
+ return false;
111
+ if (interactivePrompt)
112
+ return false;
113
+ let attempt = 0;
114
+ for (let i = 0; i < maxRetries; i++) {
115
+ if (enterRegistered[i] !== false) {
116
+ // Enter registered (message submitted)
117
+ return true;
118
+ }
119
+ attempt++;
120
+ }
121
+ // All retries exhausted — message never submitted
122
+ return attempt < maxRetries;
123
+ }
92
124
  /**
93
125
  * Simulate interactive prompt pause/resume logic.
94
126
  * Returns whether send_go should proceed.
@@ -245,3 +277,344 @@ function handleInteractivePrompt(state, promptDetected, outputChanged, currentLo
245
277
  assert.strictEqual(s.panePromptHits, 0);
246
278
  });
247
279
  });
280
+ (0, node_test_1.describe)("Watcher: send_go_to_pane retry exhaustion (RLL-001)", () => {
281
+ (0, node_test_1.it)("returns false when all retries fail (Enter never registers)", () => {
282
+ const result = simulateSendGo(true, false, [false, false, false]);
283
+ assert.strictEqual(result, false);
284
+ });
285
+ (0, node_test_1.it)("returns true when first attempt succeeds", () => {
286
+ const result = simulateSendGo(true, false, [true, false, false]);
287
+ assert.strictEqual(result, true);
288
+ });
289
+ (0, node_test_1.it)("returns true when second attempt succeeds", () => {
290
+ const result = simulateSendGo(true, false, [false, true, false]);
291
+ assert.strictEqual(result, true);
292
+ });
293
+ (0, node_test_1.it)("returns false when agent is dead", () => {
294
+ const result = simulateSendGo(false, false, [true, true, true]);
295
+ assert.strictEqual(result, false);
296
+ });
297
+ (0, node_test_1.it)("returns false when interactive prompt detected", () => {
298
+ const result = simulateSendGo(true, true, [true, true, true]);
299
+ assert.strictEqual(result, false);
300
+ });
301
+ });
302
+ (0, node_test_1.describe)("Watcher: cooldown does not block new turns (RLL-001)", () => {
303
+ (0, node_test_1.it)("cooldown suppresses re-delivery for same turn", () => {
304
+ const s = newState();
305
+ // Ack ralph at t=100
306
+ const action1 = checkAndTrigger(s, "ralph", true, 100);
307
+ assert.strictEqual(action1, "ack");
308
+ assert.strictEqual(s.lastAckTime, 100);
309
+ // Same turn at t=110 (within 30s) → cooldown kicks in before reaching noop
310
+ const action2 = checkAndTrigger(s, "ralph", true, 110);
311
+ assert.strictEqual(action2, "cooldown");
312
+ });
313
+ (0, node_test_1.it)("new turn within 30s is NOT suppressed by cooldown", () => {
314
+ const s = newState();
315
+ // Ack ralph at t=100
316
+ checkAndTrigger(s, "ralph", true, 100);
317
+ assert.strictEqual(s.lastAckTime, 100);
318
+ // New turn (lisa) at t=110 — within 30s of last ack
319
+ // Turn-change detection resets lastAckTime to 0, so cooldown does not apply
320
+ const action = checkAndTrigger(s, "lisa", true, 110);
321
+ assert.strictEqual(action, "ack");
322
+ assert.strictEqual(s.ackedTurn, "lisa");
323
+ assert.strictEqual(s.lastAckTime, 110);
324
+ });
325
+ (0, node_test_1.it)("cooldown expires after 30s for failed re-delivery", () => {
326
+ const s = newState();
327
+ // Ack ralph at t=100
328
+ checkAndTrigger(s, "ralph", true, 100);
329
+ // Force unacked state (simulate edge case: ack succeeded but need re-trigger)
330
+ s.ackedTurn = "";
331
+ // At t=110 (within 30s) → cooldown
332
+ const action1 = checkAndTrigger(s, "ralph", true, 110);
333
+ assert.strictEqual(action1, "cooldown");
334
+ // At t=135 (past 30s) → delivers
335
+ const action2 = checkAndTrigger(s, "ralph", true, 135);
336
+ assert.strictEqual(action2, "ack");
337
+ });
338
+ });
339
+ function newCheckpointState(rounds) {
340
+ return { checkpointRounds: rounds, remindTime: 0 };
341
+ }
342
+ /**
343
+ * Simulate checkpoint check in check_and_trigger (matches bash watcher v3).
344
+ * ackFilePresent: whether .checkpoint_ack file exists on disk.
345
+ * nowTime: simulated epoch time.
346
+ * Returns: "pause" (blocked, reminder emitted), "pause_silent" (blocked, no reminder yet),
347
+ * "proceed" (acked or not a checkpoint round), "no_checkpoint" (disabled/not applicable),
348
+ * "cleanup" (not at checkpoint round, stale ack file should be removed).
349
+ */
350
+ function checkCheckpoint(state, round, ackFilePresent, nowTime) {
351
+ if (state.checkpointRounds <= 0)
352
+ return "no_checkpoint";
353
+ if (round <= 1)
354
+ return "no_checkpoint";
355
+ const isCheckpointRound = (round - 1) % state.checkpointRounds === 0;
356
+ if (isCheckpointRound) {
357
+ // At checkpoint round — file is source of truth (crash-safe)
358
+ if (ackFilePresent) {
359
+ // Acked — proceed (keep file until round advances)
360
+ return "proceed";
361
+ }
362
+ // Not acked — pause with periodic 30s reminder
363
+ if (state.remindTime === 0 || nowTime - state.remindTime >= 30) {
364
+ state.remindTime = nowTime;
365
+ return "pause"; // reminder emitted
366
+ }
367
+ return "pause_silent"; // blocked but no new reminder
368
+ }
369
+ // Not at checkpoint round — clean up stale ack if present
370
+ if (ackFilePresent)
371
+ return "cleanup";
372
+ return "no_checkpoint";
373
+ }
374
+ (0, node_test_1.describe)("Watcher: checkpoint round detection (RLL-003)", () => {
375
+ (0, node_test_1.it)("pauses at checkpoint round when not acked", () => {
376
+ const s = newCheckpointState(3);
377
+ // round=4 → (4-1)%3==0 → pause
378
+ assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
379
+ });
380
+ (0, node_test_1.it)("proceeds at checkpoint round when ack file present", () => {
381
+ const s = newCheckpointState(3);
382
+ assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
383
+ });
384
+ (0, node_test_1.it)("does not checkpoint at non-checkpoint rounds", () => {
385
+ const s = newCheckpointState(3);
386
+ assert.strictEqual(checkCheckpoint(s, 2, false, 100), "no_checkpoint");
387
+ assert.strictEqual(checkCheckpoint(s, 3, false, 100), "no_checkpoint");
388
+ assert.strictEqual(checkCheckpoint(s, 5, false, 100), "no_checkpoint");
389
+ });
390
+ (0, node_test_1.it)("does not checkpoint at round 1", () => {
391
+ const s = newCheckpointState(3);
392
+ assert.strictEqual(checkCheckpoint(s, 1, false, 100), "no_checkpoint");
393
+ });
394
+ (0, node_test_1.it)("does not checkpoint when disabled (0)", () => {
395
+ const s = newCheckpointState(0);
396
+ assert.strictEqual(checkCheckpoint(s, 4, false, 100), "no_checkpoint");
397
+ });
398
+ (0, node_test_1.it)("checkpoints every N rounds correctly", () => {
399
+ const s = newCheckpointState(2);
400
+ // N=2: checkpoints at round 3, 5, 7...
401
+ assert.strictEqual(checkCheckpoint(s, 3, false, 100), "pause");
402
+ assert.strictEqual(checkCheckpoint(s, 5, false, 200), "pause");
403
+ assert.strictEqual(checkCheckpoint(s, 7, false, 300), "pause");
404
+ // Not at round 2, 4, 6
405
+ assert.strictEqual(checkCheckpoint(s, 2, false, 100), "no_checkpoint");
406
+ assert.strictEqual(checkCheckpoint(s, 4, false, 100), "no_checkpoint");
407
+ assert.strictEqual(checkCheckpoint(s, 6, false, 100), "no_checkpoint");
408
+ });
409
+ });
410
+ (0, node_test_1.describe)("Watcher: checkpoint reminder cadence (RLL-003)", () => {
411
+ (0, node_test_1.it)("emits reminder on first pause", () => {
412
+ const s = newCheckpointState(3);
413
+ assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
414
+ assert.strictEqual(s.remindTime, 100);
415
+ });
416
+ (0, node_test_1.it)("suppresses reminder within 30s", () => {
417
+ const s = newCheckpointState(3);
418
+ checkCheckpoint(s, 4, false, 100); // first reminder at t=100
419
+ assert.strictEqual(checkCheckpoint(s, 4, false, 110), "pause_silent"); // t=110 < 30s
420
+ assert.strictEqual(s.remindTime, 100); // unchanged
421
+ });
422
+ (0, node_test_1.it)("re-emits reminder after 30s", () => {
423
+ const s = newCheckpointState(3);
424
+ checkCheckpoint(s, 4, false, 100); // first at t=100
425
+ assert.strictEqual(checkCheckpoint(s, 4, false, 130), "pause"); // t=130 >= 30s
426
+ assert.strictEqual(s.remindTime, 130); // updated
427
+ });
428
+ (0, node_test_1.it)("re-emits multiple times at 30s intervals", () => {
429
+ const s = newCheckpointState(3);
430
+ assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
431
+ assert.strictEqual(checkCheckpoint(s, 4, false, 115), "pause_silent");
432
+ assert.strictEqual(checkCheckpoint(s, 4, false, 130), "pause");
433
+ assert.strictEqual(checkCheckpoint(s, 4, false, 145), "pause_silent");
434
+ assert.strictEqual(checkCheckpoint(s, 4, false, 160), "pause");
435
+ });
436
+ });
437
+ (0, node_test_1.describe)("Watcher: checkpoint ack lifecycle (RLL-003)", () => {
438
+ (0, node_test_1.it)("ack file persists — repeated checks at same round still proceed", () => {
439
+ const s = newCheckpointState(3);
440
+ // File present at round 4 — proceeds every time (file is NOT deleted)
441
+ assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
442
+ assert.strictEqual(checkCheckpoint(s, 4, true, 110), "proceed");
443
+ assert.strictEqual(checkCheckpoint(s, 4, true, 200), "proceed");
444
+ });
445
+ (0, node_test_1.it)("ack file cleaned up when round advances past checkpoint", () => {
446
+ const s = newCheckpointState(3);
447
+ // Ack at checkpoint round 4
448
+ assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
449
+ // Round advances to 5 (non-checkpoint) — stale ack triggers cleanup
450
+ assert.strictEqual(checkCheckpoint(s, 5, true, 200), "cleanup");
451
+ // After cleanup (file removed), normal no_checkpoint
452
+ assert.strictEqual(checkCheckpoint(s, 5, false, 200), "no_checkpoint");
453
+ });
454
+ (0, node_test_1.it)("next checkpoint round requires fresh ack", () => {
455
+ const s = newCheckpointState(3);
456
+ // Ack round 4, then round advances
457
+ assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
458
+ // Next checkpoint: round 7 — no ack file → pauses
459
+ assert.strictEqual(checkCheckpoint(s, 7, false, 200), "pause");
460
+ });
461
+ (0, node_test_1.it)("watcher restart at same checkpoint round still proceeds if ack file exists", () => {
462
+ // Simulates crash+restart: in-memory state is fresh but file persists
463
+ const fresh = newCheckpointState(3);
464
+ // File still on disk from before crash — proceeds immediately
465
+ assert.strictEqual(checkCheckpoint(fresh, 4, true, 300), "proceed");
466
+ });
467
+ });
468
+ // ─── RLL-005: Watcher auto-restart simulations ──
469
+ /**
470
+ * Simulate cleanup guard flag (RLL-005).
471
+ * Returns number of times cleanup body actually executed.
472
+ */
473
+ function simulateCleanup(signals) {
474
+ let cleanupDone = 0;
475
+ let executions = 0;
476
+ for (let i = 0; i < signals; i++) {
477
+ if (cleanupDone)
478
+ continue;
479
+ cleanupDone = 1;
480
+ executions++;
481
+ }
482
+ return executions;
483
+ }
484
+ /**
485
+ * Simulate session-guarded restart loop (RLL-005).
486
+ * watcherExitCodes: sequence of exit codes from watcher runs.
487
+ * sessionAliveAfter: for each run index, whether tmux session is alive after exit.
488
+ * Returns number of watcher launches.
489
+ */
490
+ function simulateRestartLoop(watcherExitCodes, sessionAliveAfter) {
491
+ let launches = 0;
492
+ for (let i = 0; i < watcherExitCodes.length; i++) {
493
+ // Loop condition: session must be alive to enter
494
+ if (i > 0 && !sessionAliveAfter[i - 1])
495
+ break;
496
+ launches++;
497
+ // After watcher exits, check session
498
+ if (!sessionAliveAfter[i])
499
+ break;
500
+ }
501
+ return launches;
502
+ }
503
+ (0, node_test_1.describe)("Watcher: cleanup guard flag (RLL-005)", () => {
504
+ (0, node_test_1.it)("executes cleanup exactly once on single signal", () => {
505
+ assert.strictEqual(simulateCleanup(1), 1);
506
+ });
507
+ (0, node_test_1.it)("executes cleanup exactly once on double signal", () => {
508
+ assert.strictEqual(simulateCleanup(2), 1);
509
+ });
510
+ (0, node_test_1.it)("executes cleanup exactly once on triple signal", () => {
511
+ assert.strictEqual(simulateCleanup(3), 1);
512
+ });
513
+ });
514
+ (0, node_test_1.describe)("Watcher: session-guarded restart loop (RLL-005)", () => {
515
+ (0, node_test_1.it)("restarts after crash when session is alive", () => {
516
+ // Watcher crashes (exit 1), session alive → restarts, then exits clean
517
+ const launches = simulateRestartLoop([1, 0], [true, true]);
518
+ assert.strictEqual(launches, 2);
519
+ });
520
+ (0, node_test_1.it)("does NOT restart when session is gone after exit", () => {
521
+ // Watcher exits, session gone → no restart
522
+ const launches = simulateRestartLoop([0, 0], [false, true]);
523
+ assert.strictEqual(launches, 1);
524
+ });
525
+ (0, node_test_1.it)("does NOT restart when session disappears mid-crash", () => {
526
+ // Watcher crashes (exit 1), session gone → no restart
527
+ const launches = simulateRestartLoop([1, 0], [false, true]);
528
+ assert.strictEqual(launches, 1);
529
+ });
530
+ (0, node_test_1.it)("handles multiple crashes before stable run", () => {
531
+ // 3 crashes then stable exit, session alive throughout
532
+ const launches = simulateRestartLoop([1, 1, 1, 0], [true, true, true, true]);
533
+ assert.strictEqual(launches, 4);
534
+ });
535
+ (0, node_test_1.it)("stops after session teardown mid-sequence", () => {
536
+ // 2 crashes (session alive), then session gone on 3rd exit
537
+ const launches = simulateRestartLoop([1, 1, 1], [true, true, false]);
538
+ assert.strictEqual(launches, 3);
539
+ });
540
+ });
541
+ /**
542
+ * Simulate wrapper singleton management with process-identity validation (RLL-005).
543
+ * processArgs: what `ps -p PID -o args=` returns for the old PID.
544
+ * - null = process already dead (PID not running)
545
+ * - string = process args (must contain "tmux has-session" to be a valid wrapper)
546
+ * Returns whether the old PID was killed.
547
+ */
548
+ function simulateWrapperKill(oldPidExists, processArgs) {
549
+ if (!oldPidExists)
550
+ return "no_pid_file";
551
+ if (processArgs === null)
552
+ return "skipped_dead";
553
+ if (!processArgs.includes("tmux has-session"))
554
+ return "skipped_wrong_process";
555
+ return "killed";
556
+ }
557
+ (0, node_test_1.describe)("Watcher: wrapper singleton management (RLL-005)", () => {
558
+ (0, node_test_1.it)("kills old wrapper when process matches", () => {
559
+ assert.strictEqual(simulateWrapperKill(true, "bash -c while tmux has-session -t rll ..."), "killed");
560
+ });
561
+ (0, node_test_1.it)("skips kill when no PID file", () => {
562
+ assert.strictEqual(simulateWrapperKill(false, null), "no_pid_file");
563
+ });
564
+ (0, node_test_1.it)("skips kill when PID is dead (process not running)", () => {
565
+ assert.strictEqual(simulateWrapperKill(true, null), "skipped_dead");
566
+ });
567
+ (0, node_test_1.it)("skips kill when PID was reused by unrelated process", () => {
568
+ assert.strictEqual(simulateWrapperKill(true, "/usr/bin/python3 my_app.py"), "skipped_wrong_process");
569
+ });
570
+ (0, node_test_1.it)("skips kill when PID reused by similar but wrong command", () => {
571
+ assert.strictEqual(simulateWrapperKill(true, "bash -c some_other_script.sh"), "skipped_wrong_process");
572
+ });
573
+ });
574
+ // ─── RLL-006: Pane log threshold simulations ─────
575
+ /**
576
+ * Simulate truncate_log_if_needed logic (RLL-006).
577
+ * Returns whether truncation would occur, and the computed thresholds.
578
+ */
579
+ function shouldTruncate(fileSize, maxMb) {
580
+ if (maxMb < 1)
581
+ maxMb = 1; // floor guard
582
+ const maxBytes = maxMb * 1048576;
583
+ const tailBytes = maxMb * 102400; // ~10% of max
584
+ return { truncate: fileSize > maxBytes, maxBytes, tailBytes };
585
+ }
586
+ (0, node_test_1.describe)("Watcher: pane log threshold (RLL-006)", () => {
587
+ (0, node_test_1.it)("default 5MB threshold: no truncation under limit", () => {
588
+ const r = shouldTruncate(4 * 1048576, 5); // 4MB
589
+ assert.strictEqual(r.truncate, false);
590
+ assert.strictEqual(r.maxBytes, 5 * 1048576);
591
+ });
592
+ (0, node_test_1.it)("default 5MB threshold: truncates over limit", () => {
593
+ const r = shouldTruncate(6 * 1048576, 5); // 6MB
594
+ assert.strictEqual(r.truncate, true);
595
+ assert.strictEqual(r.tailBytes, 5 * 102400); // 500KB retention
596
+ });
597
+ (0, node_test_1.it)("custom 10MB threshold via RL_LOG_MAX_MB", () => {
598
+ const r = shouldTruncate(8 * 1048576, 10); // 8MB < 10MB
599
+ assert.strictEqual(r.truncate, false);
600
+ assert.strictEqual(r.maxBytes, 10 * 1048576);
601
+ });
602
+ (0, node_test_1.it)("custom 10MB threshold: truncates over limit", () => {
603
+ const r = shouldTruncate(11 * 1048576, 10); // 11MB > 10MB
604
+ assert.strictEqual(r.truncate, true);
605
+ assert.strictEqual(r.tailBytes, 10 * 102400); // 1000KB retention
606
+ });
607
+ (0, node_test_1.it)("tail retention scales with max_mb", () => {
608
+ assert.strictEqual(shouldTruncate(0, 1).tailBytes, 102400); // 1MB → 100KB
609
+ assert.strictEqual(shouldTruncate(0, 5).tailBytes, 512000); // 5MB → 500KB
610
+ assert.strictEqual(shouldTruncate(0, 10).tailBytes, 1024000); // 10MB → 1000KB
611
+ });
612
+ (0, node_test_1.it)("floor guard: zero/negative RL_LOG_MAX_MB clamps to 1MB", () => {
613
+ const r0 = shouldTruncate(2 * 1048576, 0); // 0 → clamped to 1
614
+ assert.strictEqual(r0.maxBytes, 1048576);
615
+ assert.strictEqual(r0.truncate, true); // 2MB > 1MB
616
+ const rNeg = shouldTruncate(500000, -3); // negative → clamped to 1
617
+ assert.strictEqual(rNeg.maxBytes, 1048576);
618
+ assert.strictEqual(rNeg.truncate, false); // 500KB < 1MB
619
+ });
620
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-lisa-loop",
3
- "version": "0.3.0",
3
+ "version": "0.3.8",
4
4
  "description": "Turn-based dual-agent collaboration: Ralph codes, Lisa reviews, consensus required.",
5
5
  "bin": {
6
6
  "ralph-lisa": "dist/cli.js"