ralph-lisa-loop 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +142 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +1812 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/policy.d.ts +28 -0
- package/dist/policy.js +112 -0
- package/dist/state.d.ts +24 -0
- package/dist/state.js +150 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +594 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +130 -0
- package/dist/test/state.test.d.ts +1 -0
- package/dist/test/state.test.js +82 -0
- package/dist/test/watcher.test.d.ts +12 -0
- package/dist/test/watcher.test.js +247 -0
- package/package.json +44 -0
- package/templates/claude-commands/check-turn.md +18 -0
- package/templates/claude-commands/next-step.md +23 -0
- package/templates/claude-commands/read-review.md +11 -0
- package/templates/claude-commands/submit-work.md +39 -0
- package/templates/claude-commands/view-status.md +18 -0
- package/templates/codex-skills/check-turn.md +16 -0
- package/templates/codex-skills/read-work.md +9 -0
- package/templates/codex-skills/submit-review.md +36 -0
- package/templates/codex-skills/view-status.md +16 -0
- package/templates/ralph-prompt.md +59 -0
- package/templates/roles/lisa.md +115 -0
- package/templates/roles/ralph.md +117 -0
- package/templates/skill.json +27 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CLI commands for Ralph-Lisa Loop.
|
|
4
|
+
* Direct port of io.sh logic to Node/TS.
|
|
5
|
+
*/
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.cmdInit = cmdInit;
|
|
41
|
+
exports.cmdWhoseTurn = cmdWhoseTurn;
|
|
42
|
+
exports.cmdSubmitRalph = cmdSubmitRalph;
|
|
43
|
+
exports.cmdSubmitLisa = cmdSubmitLisa;
|
|
44
|
+
exports.cmdStatus = cmdStatus;
|
|
45
|
+
exports.cmdRead = cmdRead;
|
|
46
|
+
exports.cmdRecap = cmdRecap;
|
|
47
|
+
exports.cmdStep = cmdStep;
|
|
48
|
+
exports.cmdHistory = cmdHistory;
|
|
49
|
+
exports.cmdArchive = cmdArchive;
|
|
50
|
+
exports.cmdClean = cmdClean;
|
|
51
|
+
exports.cmdUninit = cmdUninit;
|
|
52
|
+
exports.cmdInitProject = cmdInitProject;
|
|
53
|
+
exports.cmdStart = cmdStart;
|
|
54
|
+
exports.cmdAuto = cmdAuto;
|
|
55
|
+
exports.cmdPolicy = cmdPolicy;
|
|
56
|
+
exports.cmdLogs = cmdLogs;
|
|
57
|
+
exports.cmdDoctor = cmdDoctor;
|
|
58
|
+
const fs = __importStar(require("node:fs"));
|
|
59
|
+
const path = __importStar(require("node:path"));
|
|
60
|
+
const node_child_process_1 = require("node:child_process");
|
|
61
|
+
const state_js_1 = require("./state.js");
|
|
62
|
+
const policy_js_1 = require("./policy.js");
|
|
63
|
+
function line(ch = "=", len = 40) {
|
|
64
|
+
return ch.repeat(len);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve submission content from args, --file, or --stdin.
|
|
68
|
+
* Returns content and whether it came from an external source (file/stdin).
|
|
69
|
+
* External sources get compact history entries to reduce context bloat.
|
|
70
|
+
*/
|
|
71
|
+
function resolveContent(args) {
|
|
72
|
+
const fileIdx = args.indexOf("--file");
|
|
73
|
+
if (fileIdx !== -1) {
|
|
74
|
+
const filePath = args[fileIdx + 1];
|
|
75
|
+
if (!filePath) {
|
|
76
|
+
console.error("Error: --file requires a file path");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (!fs.existsSync(filePath)) {
|
|
80
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
return { content: fs.readFileSync(filePath, "utf-8").trim(), external: true };
|
|
84
|
+
}
|
|
85
|
+
if (args.includes("--stdin")) {
|
|
86
|
+
try {
|
|
87
|
+
return { content: fs.readFileSync(0, "utf-8").trim(), external: true };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
console.error("Error: Failed to read from stdin");
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { content: args.join(" "), external: false };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get list of changed files via git diff.
|
|
98
|
+
* Returns empty array if not in a git repo or git fails.
|
|
99
|
+
*/
|
|
100
|
+
function getFilesChanged() {
|
|
101
|
+
try {
|
|
102
|
+
const output = (0, node_child_process_1.execSync)("git diff --name-only HEAD 2>/dev/null || git diff --name-only", {
|
|
103
|
+
encoding: "utf-8",
|
|
104
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
105
|
+
}).trim();
|
|
106
|
+
if (!output)
|
|
107
|
+
return [];
|
|
108
|
+
return output.split("\n").filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ─── init ────────────────────────────────────────
|
|
115
|
+
function cmdInit(args) {
|
|
116
|
+
const task = args.join(" ");
|
|
117
|
+
if (!task) {
|
|
118
|
+
console.error('Usage: ralph-lisa init "task description"');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
const dir = (0, state_js_1.stateDir)();
|
|
122
|
+
if (fs.existsSync(dir)) {
|
|
123
|
+
console.log("Warning: Existing session will be overwritten");
|
|
124
|
+
}
|
|
125
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
const ts = (0, state_js_1.timestamp)();
|
|
128
|
+
(0, state_js_1.writeFile)(path.join(dir, "task.md"), `# Task\n\n${task}\n\n---\nCreated: ${ts}\n`);
|
|
129
|
+
(0, state_js_1.writeFile)(path.join(dir, "round.txt"), "1");
|
|
130
|
+
(0, state_js_1.writeFile)(path.join(dir, "step.txt"), "planning");
|
|
131
|
+
(0, state_js_1.writeFile)(path.join(dir, "turn.txt"), "ralph");
|
|
132
|
+
(0, state_js_1.writeFile)(path.join(dir, "last_action.txt"), "(No action yet)");
|
|
133
|
+
(0, state_js_1.writeFile)(path.join(dir, "plan.md"), "# Plan\n\n(To be drafted by Ralph and reviewed by Lisa)\n");
|
|
134
|
+
(0, state_js_1.writeFile)(path.join(dir, "work.md"), "# Ralph Work\n\n(Waiting for Ralph to submit)\n");
|
|
135
|
+
(0, state_js_1.writeFile)(path.join(dir, "review.md"), "# Lisa Review\n\n(Waiting for Lisa to respond)\n");
|
|
136
|
+
(0, state_js_1.writeFile)(path.join(dir, "history.md"), `# Collaboration History\n\n**Task**: ${task}\n**Started**: ${ts}\n`);
|
|
137
|
+
console.log(line());
|
|
138
|
+
console.log("Session Initialized");
|
|
139
|
+
console.log(line());
|
|
140
|
+
console.log(`Task: ${task}`);
|
|
141
|
+
console.log("Turn: ralph");
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log('Ralph should start with: ralph-lisa submit-ralph "[PLAN] summary..."');
|
|
144
|
+
console.log(line());
|
|
145
|
+
}
|
|
146
|
+
// ─── whose-turn ──────────────────────────────────
|
|
147
|
+
function cmdWhoseTurn() {
|
|
148
|
+
(0, state_js_1.checkSession)();
|
|
149
|
+
console.log((0, state_js_1.getTurn)());
|
|
150
|
+
}
|
|
151
|
+
// ─── submit-ralph ────────────────────────────────
|
|
152
|
+
function cmdSubmitRalph(args) {
|
|
153
|
+
(0, state_js_1.checkSession)();
|
|
154
|
+
const { content, external } = resolveContent(args);
|
|
155
|
+
if (!content) {
|
|
156
|
+
console.error('Usage: ralph-lisa submit-ralph "[TAG] summary\\n\\ndetails..."');
|
|
157
|
+
console.error(' ralph-lisa submit-ralph --file <path>');
|
|
158
|
+
console.error(" echo content | ralph-lisa submit-ralph --stdin");
|
|
159
|
+
console.error("");
|
|
160
|
+
console.error("Valid tags: PLAN, RESEARCH, CODE, FIX, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const turn = (0, state_js_1.getTurn)();
|
|
164
|
+
if (turn !== "ralph") {
|
|
165
|
+
console.error("Error: It's Lisa's turn. Wait for her response.");
|
|
166
|
+
console.error("Run: ralph-lisa whose-turn");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
const tag = (0, state_js_1.extractTag)(content);
|
|
170
|
+
if (!tag) {
|
|
171
|
+
console.error("Error: Content must start with a valid tag.");
|
|
172
|
+
console.error("Format: [TAG] One line summary");
|
|
173
|
+
console.error("");
|
|
174
|
+
console.error("Valid tags: PLAN, RESEARCH, CODE, FIX, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
// Policy check
|
|
178
|
+
if (!(0, policy_js_1.runPolicyCheck)("ralph", tag, content)) {
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const round = (0, state_js_1.getRound)();
|
|
182
|
+
const step = (0, state_js_1.getStep)();
|
|
183
|
+
const ts = (0, state_js_1.timestamp)();
|
|
184
|
+
const summary = (0, state_js_1.extractSummary)(content);
|
|
185
|
+
const dir = (0, state_js_1.stateDir)();
|
|
186
|
+
// Auto-attach files_changed for CODE/FIX submissions
|
|
187
|
+
let filesChangedSection = "";
|
|
188
|
+
if (tag === "CODE" || tag === "FIX") {
|
|
189
|
+
const files = getFilesChanged();
|
|
190
|
+
if (files.length > 0) {
|
|
191
|
+
filesChangedSection = `**Files Changed**:\n${files.map((f) => `- ${f}`).join("\n")}\n\n`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
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`);
|
|
195
|
+
// External sources (--file/--stdin) get compact history to reduce context bloat
|
|
196
|
+
const historyContent = external
|
|
197
|
+
? `[${tag}] ${summary}\n\n(Full content in work.md)`
|
|
198
|
+
: content;
|
|
199
|
+
(0, state_js_1.appendHistory)("Ralph", historyContent);
|
|
200
|
+
(0, state_js_1.updateLastAction)("Ralph", content);
|
|
201
|
+
(0, state_js_1.setTurn)("lisa");
|
|
202
|
+
console.log(line());
|
|
203
|
+
console.log(`Submitted: [${tag}] ${summary}`);
|
|
204
|
+
console.log("Turn passed to: Lisa");
|
|
205
|
+
console.log(line());
|
|
206
|
+
console.log("");
|
|
207
|
+
console.log("Now wait for Lisa. Check with: ralph-lisa whose-turn");
|
|
208
|
+
}
|
|
209
|
+
// ─── submit-lisa ─────────────────────────────────
|
|
210
|
+
function cmdSubmitLisa(args) {
|
|
211
|
+
(0, state_js_1.checkSession)();
|
|
212
|
+
const { content, external } = resolveContent(args);
|
|
213
|
+
if (!content) {
|
|
214
|
+
console.error('Usage: ralph-lisa submit-lisa "[TAG] summary\\n\\ndetails..."');
|
|
215
|
+
console.error(' ralph-lisa submit-lisa --file <path>');
|
|
216
|
+
console.error(" echo content | ralph-lisa submit-lisa --stdin");
|
|
217
|
+
console.error("");
|
|
218
|
+
console.error("Valid tags: PASS, NEEDS_WORK, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
const turn = (0, state_js_1.getTurn)();
|
|
222
|
+
if (turn !== "lisa") {
|
|
223
|
+
console.error("Error: It's Ralph's turn. Wait for his submission.");
|
|
224
|
+
console.error("Run: ralph-lisa whose-turn");
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
const tag = (0, state_js_1.extractTag)(content);
|
|
228
|
+
if (!tag) {
|
|
229
|
+
console.error("Error: Content must start with a valid tag.");
|
|
230
|
+
console.error("Format: [TAG] One line summary");
|
|
231
|
+
console.error("");
|
|
232
|
+
console.error("Valid tags: PASS, NEEDS_WORK, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
// Policy check
|
|
236
|
+
if (!(0, policy_js_1.runPolicyCheck)("lisa", tag, content)) {
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
const round = (0, state_js_1.getRound)();
|
|
240
|
+
const step = (0, state_js_1.getStep)();
|
|
241
|
+
const ts = (0, state_js_1.timestamp)();
|
|
242
|
+
const summary = (0, state_js_1.extractSummary)(content);
|
|
243
|
+
const dir = (0, state_js_1.stateDir)();
|
|
244
|
+
// Append new review entry, keep last 3
|
|
245
|
+
const reviewPath = path.join(dir, "review.md");
|
|
246
|
+
const newEntry = `## [${tag}] Round ${round} | Step: ${step}\n**Updated**: ${ts}\n**Summary**: ${summary}\n\n${content}`;
|
|
247
|
+
const existing = (0, state_js_1.readFile)(reviewPath);
|
|
248
|
+
// Split existing entries by separator, filter out header/empty
|
|
249
|
+
const REVIEW_SEP = "\n\n---\n\n";
|
|
250
|
+
let entries = [];
|
|
251
|
+
if (existing && !existing.startsWith("# Lisa Review\n\n(Waiting")) {
|
|
252
|
+
// Remove the "# Lisa Review" header if present
|
|
253
|
+
const body = existing.replace(/^# Lisa Review\n\n/, "");
|
|
254
|
+
entries = body.split(REVIEW_SEP).filter((e) => e.trim());
|
|
255
|
+
}
|
|
256
|
+
entries.push(newEntry);
|
|
257
|
+
// Keep only last 3
|
|
258
|
+
if (entries.length > 3) {
|
|
259
|
+
entries = entries.slice(-3);
|
|
260
|
+
}
|
|
261
|
+
(0, state_js_1.writeFile)(reviewPath, `# Lisa Review\n\n${entries.join(REVIEW_SEP)}\n`);
|
|
262
|
+
// External sources (--file/--stdin) get compact history to reduce context bloat
|
|
263
|
+
const historyContent = external
|
|
264
|
+
? `[${tag}] ${summary}\n\n(Full content in review.md)`
|
|
265
|
+
: content;
|
|
266
|
+
(0, state_js_1.appendHistory)("Lisa", historyContent);
|
|
267
|
+
(0, state_js_1.updateLastAction)("Lisa", content);
|
|
268
|
+
(0, state_js_1.setTurn)("ralph");
|
|
269
|
+
// Increment round
|
|
270
|
+
const nextRound = (parseInt(round, 10) || 0) + 1;
|
|
271
|
+
(0, state_js_1.setRound)(nextRound);
|
|
272
|
+
console.log(line());
|
|
273
|
+
console.log(`Submitted: [${tag}] ${summary}`);
|
|
274
|
+
console.log("Turn passed to: Ralph");
|
|
275
|
+
console.log(`Round: ${round} -> ${nextRound}`);
|
|
276
|
+
console.log(line());
|
|
277
|
+
console.log("");
|
|
278
|
+
console.log("Now wait for Ralph. Check with: ralph-lisa whose-turn");
|
|
279
|
+
}
|
|
280
|
+
// ─── status ──────────────────────────────────────
|
|
281
|
+
function cmdStatus() {
|
|
282
|
+
const dir = (0, state_js_1.stateDir)();
|
|
283
|
+
if (!fs.existsSync(dir)) {
|
|
284
|
+
console.log("Status: Not initialized");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const turn = (0, state_js_1.getTurn)();
|
|
288
|
+
const round = (0, state_js_1.getRound)();
|
|
289
|
+
const step = (0, state_js_1.getStep)();
|
|
290
|
+
const last = (0, state_js_1.readFile)(path.join(dir, "last_action.txt")) || "None";
|
|
291
|
+
const taskFile = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
292
|
+
const taskLine = taskFile.split("\n")[2] || "Unknown";
|
|
293
|
+
console.log(line());
|
|
294
|
+
console.log("Ralph Lisa Dual-Agent Loop");
|
|
295
|
+
console.log(line());
|
|
296
|
+
console.log(`Task: ${taskLine}`);
|
|
297
|
+
console.log(`Round: ${round} | Step: ${step}`);
|
|
298
|
+
console.log("");
|
|
299
|
+
console.log(`>>> Turn: ${turn} <<<`);
|
|
300
|
+
console.log(`Last: ${last}`);
|
|
301
|
+
console.log(line());
|
|
302
|
+
}
|
|
303
|
+
// ─── read ────────────────────────────────────────
|
|
304
|
+
function cmdRead(args) {
|
|
305
|
+
(0, state_js_1.checkSession)();
|
|
306
|
+
const file = args[0];
|
|
307
|
+
if (!file) {
|
|
308
|
+
console.error("Usage: ralph-lisa read <file>");
|
|
309
|
+
console.error(" work.md - Ralph's work");
|
|
310
|
+
console.error(" review.md - Lisa's feedback (last 3)");
|
|
311
|
+
console.error(" review --round N - Lisa's review from round N (from history)");
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
// Handle: ralph-lisa read review --round N
|
|
315
|
+
const roundIdx = args.indexOf("--round");
|
|
316
|
+
if ((file === "review" || file === "review.md") && roundIdx !== -1) {
|
|
317
|
+
const roundStr = args[roundIdx + 1];
|
|
318
|
+
const roundNum = parseInt(roundStr, 10);
|
|
319
|
+
if (!roundStr || isNaN(roundNum) || roundNum < 1) {
|
|
320
|
+
console.error("Error: --round requires a positive integer");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
const review = extractReviewByRound(roundNum);
|
|
324
|
+
if (review) {
|
|
325
|
+
console.log(review);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
console.log(`No review found for round ${roundNum}`);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const filePath = path.join((0, state_js_1.stateDir)(), file);
|
|
333
|
+
if (fs.existsSync(filePath)) {
|
|
334
|
+
console.log(fs.readFileSync(filePath, "utf-8"));
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
console.log(`(File ${file} does not exist)`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Extract Lisa's review for a specific round from history.md.
|
|
342
|
+
*/
|
|
343
|
+
function extractReviewByRound(round) {
|
|
344
|
+
const dir = (0, state_js_1.stateDir)();
|
|
345
|
+
const history = (0, state_js_1.readFile)(path.join(dir, "history.md"));
|
|
346
|
+
if (!history)
|
|
347
|
+
return null;
|
|
348
|
+
// Find Lisa's entry for the given round
|
|
349
|
+
const entryRe = new RegExp(`## \\[Lisa\\] \\[\\w+\\] Round ${round} \\| Step: .+`, "m");
|
|
350
|
+
const match = entryRe.exec(history);
|
|
351
|
+
if (!match)
|
|
352
|
+
return null;
|
|
353
|
+
// Extract from this header to the next entry separator (--- or end)
|
|
354
|
+
const start = match.index;
|
|
355
|
+
const rest = history.slice(start);
|
|
356
|
+
const nextSep = rest.indexOf("\n---\n");
|
|
357
|
+
const entry = nextSep !== -1 ? rest.slice(0, nextSep) : rest;
|
|
358
|
+
return entry.trim();
|
|
359
|
+
}
|
|
360
|
+
// ─── recap ───────────────────────────────────────
|
|
361
|
+
function cmdRecap() {
|
|
362
|
+
(0, state_js_1.checkSession)();
|
|
363
|
+
const dir = (0, state_js_1.stateDir)();
|
|
364
|
+
const step = (0, state_js_1.getStep)();
|
|
365
|
+
const round = (0, state_js_1.getRound)();
|
|
366
|
+
const turn = (0, state_js_1.getTurn)();
|
|
367
|
+
const history = (0, state_js_1.readFile)(path.join(dir, "history.md"));
|
|
368
|
+
if (!history) {
|
|
369
|
+
console.log("No history to recap.");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Find the current step section in history
|
|
373
|
+
const stepHeaderRe = new RegExp(`^# Step: ${step.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
|
374
|
+
const stepMatch = stepHeaderRe.exec(history);
|
|
375
|
+
const stepSection = stepMatch
|
|
376
|
+
? history.slice(stepMatch.index)
|
|
377
|
+
: history; // If no step header found, use full history
|
|
378
|
+
// Extract all action entries from the current step section
|
|
379
|
+
const entryRe = /^## \[(Ralph|Lisa)\] \[(\w+)\] Round (\d+) \| Step: .+\n\*\*Time\*\*: .+\n\*\*Summary\*\*: (.+)/gm;
|
|
380
|
+
const entries = [];
|
|
381
|
+
let match;
|
|
382
|
+
while ((match = entryRe.exec(stepSection)) !== null) {
|
|
383
|
+
entries.push({
|
|
384
|
+
role: match[1],
|
|
385
|
+
tag: match[2],
|
|
386
|
+
round: match[3],
|
|
387
|
+
summary: match[4],
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// Find unresolved NEEDS_WORK items (NEEDS_WORK from Lisa not followed by FIX/CHALLENGE from Ralph)
|
|
391
|
+
const unresolvedNeedsWork = [];
|
|
392
|
+
for (let i = 0; i < entries.length; i++) {
|
|
393
|
+
const e = entries[i];
|
|
394
|
+
if (e.role === "Lisa" && e.tag === "NEEDS_WORK") {
|
|
395
|
+
// Check if Ralph responded with FIX or CHALLENGE after this
|
|
396
|
+
const resolved = entries
|
|
397
|
+
.slice(i + 1)
|
|
398
|
+
.some((later) => later.role === "Ralph" &&
|
|
399
|
+
(later.tag === "FIX" || later.tag === "CHALLENGE"));
|
|
400
|
+
if (!resolved) {
|
|
401
|
+
unresolvedNeedsWork.push(e.summary);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Output recap
|
|
406
|
+
console.log(line());
|
|
407
|
+
console.log("RECAP — Context Recovery");
|
|
408
|
+
console.log(line());
|
|
409
|
+
console.log(`Step: ${step}`);
|
|
410
|
+
console.log(`Round: ${round} | Turn: ${turn}`);
|
|
411
|
+
console.log(`Actions in this step: ${entries.length}`);
|
|
412
|
+
console.log("");
|
|
413
|
+
// Last 3 actions
|
|
414
|
+
const recent = entries.slice(-3);
|
|
415
|
+
if (recent.length > 0) {
|
|
416
|
+
console.log("Recent actions:");
|
|
417
|
+
for (const e of recent) {
|
|
418
|
+
console.log(` R${e.round} ${e.role} [${e.tag}] ${e.summary}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
console.log("Recent actions: (none)");
|
|
423
|
+
}
|
|
424
|
+
// Unresolved NEEDS_WORK
|
|
425
|
+
if (unresolvedNeedsWork.length > 0) {
|
|
426
|
+
console.log("");
|
|
427
|
+
console.log("Unresolved NEEDS_WORK:");
|
|
428
|
+
for (const nw of unresolvedNeedsWork) {
|
|
429
|
+
console.log(` - ${nw}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
console.log(line());
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Extract the last tag from a work.md or review.md file content.
|
|
436
|
+
* Only matches the canonical metadata header format: ## [TAG] Round N | Step: ...
|
|
437
|
+
* Does NOT match arbitrary ## [TAG] headings in body text.
|
|
438
|
+
*/
|
|
439
|
+
function extractLastTag(fileContent) {
|
|
440
|
+
const re = /^## \[(\w+)\] Round \d+ \| Step: /gm;
|
|
441
|
+
let lastTag = "";
|
|
442
|
+
let match;
|
|
443
|
+
while ((match = re.exec(fileContent)) !== null) {
|
|
444
|
+
lastTag = match[1];
|
|
445
|
+
}
|
|
446
|
+
return lastTag;
|
|
447
|
+
}
|
|
448
|
+
// ─── step ────────────────────────────────────────
|
|
449
|
+
function cmdStep(args) {
|
|
450
|
+
(0, state_js_1.checkSession)();
|
|
451
|
+
// Parse --force flag
|
|
452
|
+
const forceIdx = args.indexOf("--force");
|
|
453
|
+
const force = forceIdx !== -1;
|
|
454
|
+
const filteredArgs = force
|
|
455
|
+
? args.filter((_, i) => i !== forceIdx)
|
|
456
|
+
: args;
|
|
457
|
+
const stepName = filteredArgs.join(" ");
|
|
458
|
+
if (!stepName) {
|
|
459
|
+
console.error('Usage: ralph-lisa step "step name"');
|
|
460
|
+
console.error(" ralph-lisa step --force \"step name\" (skip consensus check)");
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
// Check consensus before allowing step transition
|
|
464
|
+
if (!force) {
|
|
465
|
+
const dir = (0, state_js_1.stateDir)();
|
|
466
|
+
const workContent = (0, state_js_1.readFile)(path.join(dir, "work.md"));
|
|
467
|
+
const reviewContent = (0, state_js_1.readFile)(path.join(dir, "review.md"));
|
|
468
|
+
const workTag = extractLastTag(workContent);
|
|
469
|
+
const reviewTag = extractLastTag(reviewContent);
|
|
470
|
+
const consensusReached = (workTag === "CONSENSUS" && reviewTag === "CONSENSUS") ||
|
|
471
|
+
(workTag === "CONSENSUS" && reviewTag === "PASS") ||
|
|
472
|
+
(workTag === "PASS" && reviewTag === "CONSENSUS");
|
|
473
|
+
if (!consensusReached) {
|
|
474
|
+
console.error("Error: Consensus not reached. Cannot proceed to next step.");
|
|
475
|
+
console.error(` Ralph's last tag: [${workTag || "none"}]`);
|
|
476
|
+
console.error(` Lisa's last tag: [${reviewTag || "none"}]`);
|
|
477
|
+
console.error("");
|
|
478
|
+
console.error("Required: both [CONSENSUS], or [PASS]+[CONSENSUS] combination.");
|
|
479
|
+
console.error('Use --force to skip this check: ralph-lisa step --force "step name"');
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
(0, state_js_1.setStep)(stepName);
|
|
484
|
+
(0, state_js_1.setRound)(1);
|
|
485
|
+
const dir = (0, state_js_1.stateDir)();
|
|
486
|
+
const ts = (0, state_js_1.timestamp)();
|
|
487
|
+
const entry = `\n---\n\n# Step: ${stepName}\n\nStarted: ${ts}\n\n`;
|
|
488
|
+
fs.appendFileSync(path.join(dir, "history.md"), entry, "utf-8");
|
|
489
|
+
console.log(`Entered step: ${stepName} (round reset to 1)`);
|
|
490
|
+
}
|
|
491
|
+
// ─── history ─────────────────────────────────────
|
|
492
|
+
function cmdHistory() {
|
|
493
|
+
(0, state_js_1.checkSession)();
|
|
494
|
+
const filePath = path.join((0, state_js_1.stateDir)(), "history.md");
|
|
495
|
+
if (fs.existsSync(filePath)) {
|
|
496
|
+
console.log(fs.readFileSync(filePath, "utf-8"));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// ─── archive ─────────────────────────────────────
|
|
500
|
+
function cmdArchive(args) {
|
|
501
|
+
(0, state_js_1.checkSession)();
|
|
502
|
+
const name = args[0] || new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
503
|
+
const archiveDir = path.join(process.cwd(), state_js_1.ARCHIVE_DIR);
|
|
504
|
+
const dest = path.join(archiveDir, name);
|
|
505
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
506
|
+
fs.cpSync((0, state_js_1.stateDir)(), dest, { recursive: true });
|
|
507
|
+
console.log(`Archived: ${state_js_1.ARCHIVE_DIR}/${name}/`);
|
|
508
|
+
}
|
|
509
|
+
// ─── clean ───────────────────────────────────────
|
|
510
|
+
function cmdClean() {
|
|
511
|
+
const dir = (0, state_js_1.stateDir)();
|
|
512
|
+
if (fs.existsSync(dir)) {
|
|
513
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
514
|
+
console.log("Session cleaned");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// ─── uninit ──────────────────────────────────────
|
|
518
|
+
const MARKER = "RALPH-LISA-LOOP";
|
|
519
|
+
function cmdUninit() {
|
|
520
|
+
const projectDir = process.cwd();
|
|
521
|
+
// Remove .dual-agent/
|
|
522
|
+
const dualAgentDir = path.join(projectDir, state_js_1.STATE_DIR);
|
|
523
|
+
if (fs.existsSync(dualAgentDir)) {
|
|
524
|
+
fs.rmSync(dualAgentDir, { recursive: true, force: true });
|
|
525
|
+
console.log("Removed: .dual-agent/");
|
|
526
|
+
}
|
|
527
|
+
// Clean CODEX.md marker block (same logic as CLAUDE.md — preserve pre-existing content)
|
|
528
|
+
const codexMd = path.join(projectDir, "CODEX.md");
|
|
529
|
+
if (fs.existsSync(codexMd)) {
|
|
530
|
+
const content = fs.readFileSync(codexMd, "utf-8");
|
|
531
|
+
if (content.includes(MARKER)) {
|
|
532
|
+
const markerIdx = content.indexOf(`<!-- ${MARKER} -->`);
|
|
533
|
+
if (markerIdx >= 0) {
|
|
534
|
+
const before = content.slice(0, markerIdx).trimEnd();
|
|
535
|
+
if (before) {
|
|
536
|
+
fs.writeFileSync(codexMd, before + "\n", "utf-8");
|
|
537
|
+
console.log("Cleaned: CODEX.md (removed Ralph-Lisa-Loop section)");
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
fs.unlinkSync(codexMd);
|
|
541
|
+
console.log("Removed: CODEX.md (was entirely Ralph-Lisa-Loop content)");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Clean CLAUDE.md marker block
|
|
547
|
+
const claudeMd = path.join(projectDir, "CLAUDE.md");
|
|
548
|
+
if (fs.existsSync(claudeMd)) {
|
|
549
|
+
const content = fs.readFileSync(claudeMd, "utf-8");
|
|
550
|
+
if (content.includes(MARKER)) {
|
|
551
|
+
// Remove everything from <!-- RALPH-LISA-LOOP --> to end of file
|
|
552
|
+
// or to next <!-- end --> marker
|
|
553
|
+
const markerIdx = content.indexOf(`<!-- ${MARKER} -->`);
|
|
554
|
+
if (markerIdx >= 0) {
|
|
555
|
+
const before = content.slice(0, markerIdx).trimEnd();
|
|
556
|
+
if (before) {
|
|
557
|
+
fs.writeFileSync(claudeMd, before + "\n", "utf-8");
|
|
558
|
+
console.log("Cleaned: CLAUDE.md (removed Ralph-Lisa-Loop section)");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
fs.unlinkSync(claudeMd);
|
|
562
|
+
console.log("Removed: CLAUDE.md (was entirely Ralph-Lisa-Loop content)");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Remove .claude/commands/ (only our files)
|
|
568
|
+
const claudeCmdDir = path.join(projectDir, ".claude", "commands");
|
|
569
|
+
const ourCommands = [
|
|
570
|
+
"check-turn.md",
|
|
571
|
+
"next-step.md",
|
|
572
|
+
"read-review.md",
|
|
573
|
+
"submit-work.md",
|
|
574
|
+
"view-status.md",
|
|
575
|
+
];
|
|
576
|
+
if (fs.existsSync(claudeCmdDir)) {
|
|
577
|
+
for (const cmd of ourCommands) {
|
|
578
|
+
const cmdPath = path.join(claudeCmdDir, cmd);
|
|
579
|
+
if (fs.existsSync(cmdPath)) {
|
|
580
|
+
fs.unlinkSync(cmdPath);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Remove directory if empty
|
|
584
|
+
try {
|
|
585
|
+
const remaining = fs.readdirSync(claudeCmdDir);
|
|
586
|
+
if (remaining.length === 0) {
|
|
587
|
+
fs.rmdirSync(claudeCmdDir);
|
|
588
|
+
// Also remove .claude/ if empty
|
|
589
|
+
const claudeDir = path.join(projectDir, ".claude");
|
|
590
|
+
const claudeRemaining = fs.readdirSync(claudeDir);
|
|
591
|
+
if (claudeRemaining.length === 0) {
|
|
592
|
+
fs.rmdirSync(claudeDir);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// ignore
|
|
598
|
+
}
|
|
599
|
+
console.log("Cleaned: .claude/commands/");
|
|
600
|
+
}
|
|
601
|
+
// Remove only our skill from .codex/ (preserve other content)
|
|
602
|
+
const codexSkillDir = path.join(projectDir, ".codex", "skills", "ralph-lisa-loop");
|
|
603
|
+
if (fs.existsSync(codexSkillDir)) {
|
|
604
|
+
fs.rmSync(codexSkillDir, { recursive: true, force: true });
|
|
605
|
+
console.log("Removed: .codex/skills/ralph-lisa-loop/");
|
|
606
|
+
// Clean up empty parent dirs
|
|
607
|
+
try {
|
|
608
|
+
const skillsDir = path.join(projectDir, ".codex", "skills");
|
|
609
|
+
if (fs.readdirSync(skillsDir).length === 0) {
|
|
610
|
+
fs.rmdirSync(skillsDir);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// ignore
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Remove .codex/config.toml only if it has our marker
|
|
618
|
+
const codexConfig = path.join(projectDir, ".codex", "config.toml");
|
|
619
|
+
if (fs.existsSync(codexConfig)) {
|
|
620
|
+
const configContent = fs.readFileSync(codexConfig, "utf-8");
|
|
621
|
+
if (configContent.includes(MARKER)) {
|
|
622
|
+
fs.unlinkSync(codexConfig);
|
|
623
|
+
console.log("Removed: .codex/config.toml");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Remove .codex/ if empty
|
|
627
|
+
try {
|
|
628
|
+
const codexDir = path.join(projectDir, ".codex");
|
|
629
|
+
if (fs.existsSync(codexDir) && fs.readdirSync(codexDir).length === 0) {
|
|
630
|
+
fs.rmdirSync(codexDir);
|
|
631
|
+
console.log("Removed: .codex/ (empty)");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
// ignore
|
|
636
|
+
}
|
|
637
|
+
// Remove io.sh if it exists
|
|
638
|
+
const ioSh = path.join(projectDir, "io.sh");
|
|
639
|
+
if (fs.existsSync(ioSh)) {
|
|
640
|
+
fs.unlinkSync(ioSh);
|
|
641
|
+
console.log("Removed: io.sh");
|
|
642
|
+
}
|
|
643
|
+
console.log("");
|
|
644
|
+
console.log("Ralph-Lisa Loop removed from this project.");
|
|
645
|
+
}
|
|
646
|
+
// ─── init (project setup) ────────────────────────
|
|
647
|
+
function cmdInitProject(args) {
|
|
648
|
+
// Parse --minimal flag
|
|
649
|
+
const minimal = args.includes("--minimal");
|
|
650
|
+
const filteredArgs = args.filter((a) => a !== "--minimal");
|
|
651
|
+
const projectDir = filteredArgs[0] || process.cwd();
|
|
652
|
+
const resolvedDir = path.resolve(projectDir);
|
|
653
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
654
|
+
console.error(`Error: Directory does not exist: ${resolvedDir}`);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
console.log(line());
|
|
658
|
+
console.log(`Ralph-Lisa Loop - Init${minimal ? " (minimal)" : ""}`);
|
|
659
|
+
console.log(line());
|
|
660
|
+
console.log(`Project: ${resolvedDir}`);
|
|
661
|
+
console.log("");
|
|
662
|
+
if (minimal) {
|
|
663
|
+
// Minimal mode: only create .dual-agent/ session state.
|
|
664
|
+
// Use this when Claude Code plugin + Codex global config are installed.
|
|
665
|
+
console.log("[Session] Initializing .dual-agent/ (minimal mode)...");
|
|
666
|
+
const origCwd = process.cwd();
|
|
667
|
+
process.chdir(resolvedDir);
|
|
668
|
+
cmdInit(["Waiting for task assignment"]);
|
|
669
|
+
process.chdir(origCwd);
|
|
670
|
+
console.log("");
|
|
671
|
+
console.log(line());
|
|
672
|
+
console.log("Minimal Init Complete");
|
|
673
|
+
console.log(line());
|
|
674
|
+
console.log("");
|
|
675
|
+
console.log("Files created:");
|
|
676
|
+
console.log(" - .dual-agent/ (session state only)");
|
|
677
|
+
console.log("");
|
|
678
|
+
console.log("No project-level role/command files written.");
|
|
679
|
+
console.log("Requires: Claude Code plugin + Codex global config.");
|
|
680
|
+
console.log(line());
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// Find templates directory (shipped inside npm package)
|
|
684
|
+
const templatesDir = findTemplatesDir();
|
|
685
|
+
// 1. Append Ralph role to CLAUDE.md
|
|
686
|
+
const claudeMd = path.join(resolvedDir, "CLAUDE.md");
|
|
687
|
+
if (fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER)) {
|
|
688
|
+
console.log("[Claude] Ralph role already in CLAUDE.md, skipping...");
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
console.log("[Claude] Appending Ralph role to CLAUDE.md...");
|
|
692
|
+
const ralphRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "ralph.md"));
|
|
693
|
+
if (fs.existsSync(claudeMd)) {
|
|
694
|
+
fs.appendFileSync(claudeMd, "\n\n", "utf-8");
|
|
695
|
+
}
|
|
696
|
+
fs.appendFileSync(claudeMd, ralphRole, "utf-8");
|
|
697
|
+
console.log("[Claude] Done.");
|
|
698
|
+
}
|
|
699
|
+
// 2. Create/update CODEX.md with Lisa role
|
|
700
|
+
const codexMd = path.join(resolvedDir, "CODEX.md");
|
|
701
|
+
if (fs.existsSync(codexMd) && (0, state_js_1.readFile)(codexMd).includes(MARKER)) {
|
|
702
|
+
console.log("[Codex] Lisa role already in CODEX.md, skipping...");
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
console.log("[Codex] Creating CODEX.md with Lisa role...");
|
|
706
|
+
const lisaRole = (0, state_js_1.readFile)(path.join(templatesDir, "roles", "lisa.md"));
|
|
707
|
+
if (fs.existsSync(codexMd)) {
|
|
708
|
+
fs.appendFileSync(codexMd, "\n\n", "utf-8");
|
|
709
|
+
}
|
|
710
|
+
fs.appendFileSync(codexMd, lisaRole, "utf-8");
|
|
711
|
+
console.log("[Codex] Done.");
|
|
712
|
+
}
|
|
713
|
+
// 3. Copy Claude commands
|
|
714
|
+
console.log("[Claude] Copying commands to .claude/commands/...");
|
|
715
|
+
const claudeCmdDir = path.join(resolvedDir, ".claude", "commands");
|
|
716
|
+
fs.mkdirSync(claudeCmdDir, { recursive: true });
|
|
717
|
+
const cmdSrc = path.join(templatesDir, "claude-commands");
|
|
718
|
+
if (fs.existsSync(cmdSrc)) {
|
|
719
|
+
for (const f of fs.readdirSync(cmdSrc)) {
|
|
720
|
+
if (f.endsWith(".md")) {
|
|
721
|
+
fs.copyFileSync(path.join(cmdSrc, f), path.join(claudeCmdDir, f));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
console.log("[Claude] Commands copied.");
|
|
726
|
+
// 4. Copy Codex skills
|
|
727
|
+
console.log("[Codex] Setting up skills in .codex/skills/ralph-lisa-loop/...");
|
|
728
|
+
const codexSkillDir = path.join(resolvedDir, ".codex", "skills", "ralph-lisa-loop");
|
|
729
|
+
fs.mkdirSync(codexSkillDir, { recursive: true });
|
|
730
|
+
const skillContent = `---
|
|
731
|
+
name: ralph-lisa-loop
|
|
732
|
+
description: Lisa review commands for Ralph-Lisa dual-agent collaboration
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
# Ralph-Lisa Loop - Lisa Skills
|
|
736
|
+
|
|
737
|
+
This skill provides Lisa's review commands for the Ralph-Lisa collaboration.
|
|
738
|
+
|
|
739
|
+
## Available Commands
|
|
740
|
+
|
|
741
|
+
### Check Turn
|
|
742
|
+
\`\`\`bash
|
|
743
|
+
ralph-lisa whose-turn
|
|
744
|
+
\`\`\`
|
|
745
|
+
Check if it's your turn before taking action.
|
|
746
|
+
|
|
747
|
+
### Submit Review
|
|
748
|
+
\`\`\`bash
|
|
749
|
+
ralph-lisa submit-lisa "[TAG] summary
|
|
750
|
+
|
|
751
|
+
detailed content..."
|
|
752
|
+
\`\`\`
|
|
753
|
+
Submit your review. Valid tags: PASS, NEEDS_WORK, CHALLENGE, DISCUSS, QUESTION, CONSENSUS
|
|
754
|
+
|
|
755
|
+
### View Status
|
|
756
|
+
\`\`\`bash
|
|
757
|
+
ralph-lisa status
|
|
758
|
+
\`\`\`
|
|
759
|
+
View current task, turn, and last action.
|
|
760
|
+
|
|
761
|
+
### Read Ralph's Work
|
|
762
|
+
\`\`\`bash
|
|
763
|
+
ralph-lisa read work.md
|
|
764
|
+
\`\`\`
|
|
765
|
+
Read Ralph's latest submission.
|
|
766
|
+
`;
|
|
767
|
+
(0, state_js_1.writeFile)(path.join(codexSkillDir, "SKILL.md"), skillContent);
|
|
768
|
+
// Create .codex/config.toml (with marker for safe uninit)
|
|
769
|
+
// Codex reads AGENTS.md by default; fallback to CODEX.md for our setup
|
|
770
|
+
const codexConfig = `# ${MARKER} - managed by ralph-lisa-loop
|
|
771
|
+
project_doc_fallback_filenames = ["CODEX.md"]
|
|
772
|
+
|
|
773
|
+
[skills]
|
|
774
|
+
enabled = true
|
|
775
|
+
path = ".codex/skills"
|
|
776
|
+
`;
|
|
777
|
+
(0, state_js_1.writeFile)(path.join(resolvedDir, ".codex", "config.toml"), codexConfig);
|
|
778
|
+
console.log(`[Codex] Skill created at ${codexSkillDir}/`);
|
|
779
|
+
console.log(`[Codex] Config created at ${path.join(resolvedDir, ".codex", "config.toml")}`);
|
|
780
|
+
// 5. Initialize session state
|
|
781
|
+
console.log("[Session] Initializing .dual-agent/...");
|
|
782
|
+
const origCwd = process.cwd();
|
|
783
|
+
process.chdir(resolvedDir);
|
|
784
|
+
cmdInit(["Waiting for task assignment"]);
|
|
785
|
+
process.chdir(origCwd);
|
|
786
|
+
console.log("");
|
|
787
|
+
console.log(line());
|
|
788
|
+
console.log("Initialization Complete");
|
|
789
|
+
console.log(line());
|
|
790
|
+
console.log("");
|
|
791
|
+
console.log("Files created/updated:");
|
|
792
|
+
console.log(" - CLAUDE.md (Ralph role)");
|
|
793
|
+
console.log(" - CODEX.md (Lisa role)");
|
|
794
|
+
console.log(" - .claude/commands/ (Claude slash commands)");
|
|
795
|
+
console.log(" - .codex/skills/ (Codex skills)");
|
|
796
|
+
console.log(" - .dual-agent/");
|
|
797
|
+
console.log("");
|
|
798
|
+
console.log("Start agents:");
|
|
799
|
+
console.log(" Terminal 1: claude");
|
|
800
|
+
console.log(" Terminal 2: codex");
|
|
801
|
+
console.log("");
|
|
802
|
+
console.log('Or run: ralph-lisa start "your task"');
|
|
803
|
+
console.log(line());
|
|
804
|
+
}
|
|
805
|
+
function findTemplatesDir() {
|
|
806
|
+
// Look for templates relative to the CLI package
|
|
807
|
+
const candidates = [
|
|
808
|
+
// When installed via npm (templates shipped in package)
|
|
809
|
+
path.join(__dirname, "..", "templates"),
|
|
810
|
+
// When running from repo
|
|
811
|
+
path.join(__dirname, "..", "..", "templates"),
|
|
812
|
+
// Repo root
|
|
813
|
+
path.join(__dirname, "..", "..", "..", "templates"),
|
|
814
|
+
];
|
|
815
|
+
for (const c of candidates) {
|
|
816
|
+
if (fs.existsSync(path.join(c, "roles", "ralph.md"))) {
|
|
817
|
+
return c;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
console.error("Error: Templates directory not found. Reinstall ralph-lisa-loop.");
|
|
821
|
+
process.exit(1);
|
|
822
|
+
}
|
|
823
|
+
// ─── start ───────────────────────────────────────
|
|
824
|
+
function cmdStart(args) {
|
|
825
|
+
const projectDir = process.cwd();
|
|
826
|
+
const fullAuto = args.includes("--full-auto");
|
|
827
|
+
const filteredArgs = args.filter((a) => a !== "--full-auto");
|
|
828
|
+
const task = filteredArgs.join(" ");
|
|
829
|
+
const claudeCmd = fullAuto ? "claude --dangerously-skip-permissions" : "claude";
|
|
830
|
+
const codexCmd = fullAuto ? "codex --full-auto" : "codex";
|
|
831
|
+
console.log(line());
|
|
832
|
+
console.log("Ralph-Lisa Loop - Start");
|
|
833
|
+
console.log(line());
|
|
834
|
+
console.log(`Project: ${projectDir}`);
|
|
835
|
+
if (fullAuto)
|
|
836
|
+
console.log("Mode: FULL AUTO (no permission prompts)");
|
|
837
|
+
console.log("");
|
|
838
|
+
// Check prerequisites
|
|
839
|
+
const { execSync } = require("node:child_process");
|
|
840
|
+
try {
|
|
841
|
+
execSync("which claude", { stdio: "pipe" });
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
console.error("Error: 'claude' command not found. Install Claude Code first.");
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
execSync("which codex", { stdio: "pipe" });
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
console.error("Error: 'codex' command not found. Install Codex CLI first.");
|
|
852
|
+
process.exit(1);
|
|
853
|
+
}
|
|
854
|
+
// Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
|
|
855
|
+
const claudeMd = path.join(projectDir, "CLAUDE.md");
|
|
856
|
+
const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
|
|
857
|
+
const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
|
|
858
|
+
if (!hasFullInit && !hasSession) {
|
|
859
|
+
console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
// Initialize task if provided
|
|
863
|
+
if (task) {
|
|
864
|
+
console.log(`Task: ${task}`);
|
|
865
|
+
cmdInit(task.split(" "));
|
|
866
|
+
console.log("");
|
|
867
|
+
}
|
|
868
|
+
// Detect terminal and launch
|
|
869
|
+
const platform = process.platform;
|
|
870
|
+
const ralphCmd = `cd '${projectDir}' && echo '=== Ralph (Claude Code) ===' && echo 'Commands: /check-turn, /submit-work, /view-status' && echo 'First: /check-turn' && echo '' && ${claudeCmd}`;
|
|
871
|
+
const lisaCmd = `cd '${projectDir}' && echo '=== Lisa (Codex) ===' && echo 'First: ralph-lisa whose-turn' && echo '' && ${codexCmd}`;
|
|
872
|
+
if (platform === "darwin") {
|
|
873
|
+
try {
|
|
874
|
+
// Try iTerm2 first
|
|
875
|
+
execSync("pgrep -x iTerm2", { stdio: "pipe" });
|
|
876
|
+
console.log("Launching with iTerm2...");
|
|
877
|
+
execSync(`osascript -e 'tell application "iTerm"
|
|
878
|
+
activate
|
|
879
|
+
set ralphWindow to (create window with default profile)
|
|
880
|
+
tell current session of ralphWindow
|
|
881
|
+
write text "${ralphCmd.replace(/"/g, '\\"')}"
|
|
882
|
+
set name to "Ralph (Claude)"
|
|
883
|
+
end tell
|
|
884
|
+
tell current window
|
|
885
|
+
set lisaTab to (create tab with default profile)
|
|
886
|
+
tell current session of lisaTab
|
|
887
|
+
write text "${lisaCmd.replace(/"/g, '\\"')}"
|
|
888
|
+
set name to "Lisa (Codex)"
|
|
889
|
+
end tell
|
|
890
|
+
end tell
|
|
891
|
+
end tell'`, { stdio: "pipe" });
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
// Fall back to Terminal.app
|
|
895
|
+
console.log("Launching with macOS Terminal...");
|
|
896
|
+
try {
|
|
897
|
+
execSync(`osascript -e 'tell application "Terminal"
|
|
898
|
+
activate
|
|
899
|
+
do script "${ralphCmd.replace(/"/g, '\\"')}"
|
|
900
|
+
end tell'`, { stdio: "pipe" });
|
|
901
|
+
execSync("sleep 1");
|
|
902
|
+
execSync(`osascript -e 'tell application "Terminal"
|
|
903
|
+
activate
|
|
904
|
+
do script "${lisaCmd.replace(/"/g, '\\"')}"
|
|
905
|
+
end tell'`, { stdio: "pipe" });
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
launchGeneric(projectDir);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
// Try tmux
|
|
915
|
+
try {
|
|
916
|
+
execSync("which tmux", { stdio: "pipe" });
|
|
917
|
+
console.log("Launching with tmux...");
|
|
918
|
+
const sessionName = "ralph-lisa";
|
|
919
|
+
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`);
|
|
920
|
+
execSync(`tmux new-session -d -s "${sessionName}" -n "Ralph" "bash -c '${ralphCmd}; exec bash'"`);
|
|
921
|
+
execSync(`tmux split-window -h -t "${sessionName}" "bash -c '${lisaCmd}; exec bash'"`);
|
|
922
|
+
execSync(`tmux attach-session -t "${sessionName}"`, { stdio: "inherit" });
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
launchGeneric(projectDir);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
console.log("");
|
|
930
|
+
console.log(line());
|
|
931
|
+
console.log("Both agents launched!");
|
|
932
|
+
console.log(line());
|
|
933
|
+
const currentTurn = (0, state_js_1.readFile)(path.join(projectDir, state_js_1.STATE_DIR, "turn.txt")) || "ralph";
|
|
934
|
+
console.log(`Current turn: ${currentTurn}`);
|
|
935
|
+
console.log(line());
|
|
936
|
+
}
|
|
937
|
+
function launchGeneric(projectDir) {
|
|
938
|
+
console.log("Please manually open two terminals:");
|
|
939
|
+
console.log("");
|
|
940
|
+
console.log("Terminal 1 (Ralph):");
|
|
941
|
+
console.log(` cd ${projectDir} && claude`);
|
|
942
|
+
console.log("");
|
|
943
|
+
console.log("Terminal 2 (Lisa):");
|
|
944
|
+
console.log(` cd ${projectDir} && codex`);
|
|
945
|
+
}
|
|
946
|
+
// ─── auto ────────────────────────────────────────
|
|
947
|
+
function cmdAuto(args) {
|
|
948
|
+
const projectDir = process.cwd();
|
|
949
|
+
const fullAuto = args.includes("--full-auto");
|
|
950
|
+
const filteredArgs = args.filter((a) => a !== "--full-auto");
|
|
951
|
+
const task = filteredArgs.join(" ");
|
|
952
|
+
const claudeCmd = fullAuto ? "claude --dangerously-skip-permissions" : "claude";
|
|
953
|
+
const codexCmd = fullAuto ? "codex --full-auto" : "codex";
|
|
954
|
+
console.log(line());
|
|
955
|
+
console.log("Ralph-Lisa Loop - Auto Mode");
|
|
956
|
+
console.log(line());
|
|
957
|
+
console.log(`Project: ${projectDir}`);
|
|
958
|
+
if (fullAuto)
|
|
959
|
+
console.log("Mode: FULL AUTO (no permission prompts)");
|
|
960
|
+
console.log("");
|
|
961
|
+
const { execSync } = require("node:child_process");
|
|
962
|
+
// Check prerequisites
|
|
963
|
+
try {
|
|
964
|
+
execSync("which tmux", { stdio: "pipe" });
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
console.error("Error: tmux is required for auto mode.");
|
|
968
|
+
console.error("Install: brew install tmux (macOS) or apt install tmux (Linux)");
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
execSync("which claude", { stdio: "pipe" });
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
console.error("Error: 'claude' (Claude Code CLI) not found in PATH.");
|
|
976
|
+
console.error("");
|
|
977
|
+
console.error("Install: npm install -g @anthropic-ai/claude-code");
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
execSync("which codex", { stdio: "pipe" });
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
console.error("Error: 'codex' (OpenAI Codex CLI) not found in PATH.");
|
|
985
|
+
console.error("");
|
|
986
|
+
console.error("Install: npm install -g @openai/codex");
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
// Check file watcher (optional - falls back to polling)
|
|
990
|
+
let watcher = "";
|
|
991
|
+
try {
|
|
992
|
+
execSync("which fswatch", { stdio: "pipe" });
|
|
993
|
+
watcher = "fswatch";
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
try {
|
|
997
|
+
execSync("which inotifywait", { stdio: "pipe" });
|
|
998
|
+
watcher = "inotifywait";
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
console.log("Note: No file watcher found (fswatch/inotifywait). Using polling mode.");
|
|
1002
|
+
console.log(" Install for faster turn detection: brew install fswatch (macOS) or apt install inotify-tools (Linux)");
|
|
1003
|
+
console.log("");
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// Check if initialized (full init has CLAUDE.md marker, minimal has .dual-agent/)
|
|
1007
|
+
const claudeMd = path.join(projectDir, "CLAUDE.md");
|
|
1008
|
+
const hasFullInit = fs.existsSync(claudeMd) && (0, state_js_1.readFile)(claudeMd).includes(MARKER);
|
|
1009
|
+
const hasSession = fs.existsSync(path.join(projectDir, state_js_1.STATE_DIR));
|
|
1010
|
+
if (!hasFullInit && !hasSession) {
|
|
1011
|
+
console.error("Error: Not initialized. Run 'ralph-lisa init' first.");
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
// Initialize task
|
|
1015
|
+
if (task) {
|
|
1016
|
+
console.log(`Task: ${task}`);
|
|
1017
|
+
cmdInit(task.split(" "));
|
|
1018
|
+
console.log("");
|
|
1019
|
+
}
|
|
1020
|
+
const sessionName = "ralph-lisa-auto";
|
|
1021
|
+
const dir = (0, state_js_1.stateDir)(projectDir);
|
|
1022
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1023
|
+
// Archive pane logs from previous runs (for transcript preservation)
|
|
1024
|
+
const logsDir = path.join(dir, "logs");
|
|
1025
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
1026
|
+
for (const f of ["pane0.log", "pane1.log"]) {
|
|
1027
|
+
const src = path.join(dir, f);
|
|
1028
|
+
if (fs.existsSync(src) && fs.statSync(src).size > 0) {
|
|
1029
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1030
|
+
fs.renameSync(src, path.join(logsDir, `${f.replace(".log", "")}-${ts}.log`));
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
try {
|
|
1034
|
+
fs.unlinkSync(src);
|
|
1035
|
+
}
|
|
1036
|
+
catch { }
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// Clean event accelerator flag
|
|
1040
|
+
try {
|
|
1041
|
+
fs.unlinkSync(path.join(dir, ".turn_changed"));
|
|
1042
|
+
}
|
|
1043
|
+
catch { }
|
|
1044
|
+
// Create watcher script
|
|
1045
|
+
const watcherScript = path.join(dir, "watcher.sh");
|
|
1046
|
+
let watcherContent = `#!/bin/bash
|
|
1047
|
+
# Turn watcher v2 - reliable agent triggering with health checks
|
|
1048
|
+
# Architecture: polling main loop + optional event acceleration
|
|
1049
|
+
|
|
1050
|
+
STATE_DIR=".dual-agent"
|
|
1051
|
+
SESSION="${sessionName}"
|
|
1052
|
+
SCRIPT_PATH="\$(cd "\$(dirname "\$0")" && pwd)/watcher.sh"
|
|
1053
|
+
SEEN_TURN=""
|
|
1054
|
+
ACKED_TURN=""
|
|
1055
|
+
FAIL_COUNT=0
|
|
1056
|
+
ACCEL_PID=""
|
|
1057
|
+
|
|
1058
|
+
PANE0_LOG="\${STATE_DIR}/pane0.log"
|
|
1059
|
+
PANE1_LOG="\${STATE_DIR}/pane1.log"
|
|
1060
|
+
PID_FILE="\${STATE_DIR}/watcher.pid"
|
|
1061
|
+
|
|
1062
|
+
# Interactive prompt patterns (do NOT send "go" if matched)
|
|
1063
|
+
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'
|
|
1064
|
+
|
|
1065
|
+
# Pause state per pane: 0=active, consecutive hit count
|
|
1066
|
+
PANE0_PROMPT_HITS=0
|
|
1067
|
+
PANE1_PROMPT_HITS=0
|
|
1068
|
+
PANE0_PAUSED=0
|
|
1069
|
+
PANE1_PAUSED=0
|
|
1070
|
+
PANE0_PAUSE_SIZE=0
|
|
1071
|
+
PANE1_PAUSE_SIZE=0
|
|
1072
|
+
|
|
1073
|
+
# ─── PID singleton ───────────────────────────────
|
|
1074
|
+
|
|
1075
|
+
if [[ -f "\$PID_FILE" ]]; then
|
|
1076
|
+
old_pid=\$(cat "\$PID_FILE" 2>/dev/null)
|
|
1077
|
+
if [[ -n "\$old_pid" ]] && kill -0 "\$old_pid" 2>/dev/null; then
|
|
1078
|
+
old_args=\$(ps -p "\$old_pid" -o args= 2>/dev/null || echo "")
|
|
1079
|
+
if echo "\$old_args" | grep -qF "\$SCRIPT_PATH"; then
|
|
1080
|
+
echo "[Watcher] Killing old watcher (PID \$old_pid)"
|
|
1081
|
+
kill "\$old_pid" 2>/dev/null || true
|
|
1082
|
+
sleep 1
|
|
1083
|
+
fi
|
|
1084
|
+
fi
|
|
1085
|
+
fi
|
|
1086
|
+
|
|
1087
|
+
echo \$\$ > "\$PID_FILE"
|
|
1088
|
+
|
|
1089
|
+
# ─── Cleanup trap ────────────────────────────────
|
|
1090
|
+
|
|
1091
|
+
cleanup() {
|
|
1092
|
+
echo "[Watcher] Shutting down..."
|
|
1093
|
+
# Stop pipe-pane capture
|
|
1094
|
+
tmux pipe-pane -t "\${SESSION}:0.0" 2>/dev/null || true
|
|
1095
|
+
tmux pipe-pane -t "\${SESSION}:0.1" 2>/dev/null || true
|
|
1096
|
+
# Kill event accelerator
|
|
1097
|
+
if [[ -n "\$ACCEL_PID" ]] && kill -0 "\$ACCEL_PID" 2>/dev/null; then
|
|
1098
|
+
kill "\$ACCEL_PID" 2>/dev/null || true
|
|
1099
|
+
fi
|
|
1100
|
+
# Clean up PID and flag files
|
|
1101
|
+
rm -f "\$PID_FILE" "\${STATE_DIR}/.turn_changed"
|
|
1102
|
+
# Archive pane logs (not delete) so transcripts are preserved
|
|
1103
|
+
local logs_dir="\${STATE_DIR}/logs"
|
|
1104
|
+
mkdir -p "\$logs_dir"
|
|
1105
|
+
local archive_ts
|
|
1106
|
+
archive_ts=\$(date "+%Y-%m-%dT%H-%M-%S")
|
|
1107
|
+
for lf in "\$PANE0_LOG" "\$PANE1_LOG"; do
|
|
1108
|
+
if [[ -f "\$lf" && -s "\$lf" ]]; then
|
|
1109
|
+
local base
|
|
1110
|
+
base=\$(basename "\$lf" .log)
|
|
1111
|
+
mv "\$lf" "\${logs_dir}/\${base}-\${archive_ts}.log" 2>/dev/null || true
|
|
1112
|
+
fi
|
|
1113
|
+
done
|
|
1114
|
+
exit 0
|
|
1115
|
+
}
|
|
1116
|
+
trap cleanup EXIT INT TERM
|
|
1117
|
+
|
|
1118
|
+
# ─── Set up pipe-pane ────────────────────────────
|
|
1119
|
+
|
|
1120
|
+
touch "\$PANE0_LOG" "\$PANE1_LOG"
|
|
1121
|
+
tmux pipe-pane -o -t "\${SESSION}:0.0" "cat >> \\"\$PANE0_LOG\\"" 2>/dev/null || true
|
|
1122
|
+
tmux pipe-pane -o -t "\${SESSION}:0.1" "cat >> \\"\$PANE1_LOG\\"" 2>/dev/null || true
|
|
1123
|
+
|
|
1124
|
+
# ─── Helper functions ────────────────────────────
|
|
1125
|
+
|
|
1126
|
+
check_session_alive() {
|
|
1127
|
+
if ! tmux has-session -t "\${SESSION}" 2>/dev/null; then
|
|
1128
|
+
echo "[Watcher] ERROR: tmux session '\${SESSION}' no longer exists. Exiting."
|
|
1129
|
+
exit 1
|
|
1130
|
+
fi
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
# Returns 0 if agent appears dead (pane shows bare shell 3 consecutive times)
|
|
1134
|
+
check_agent_alive() {
|
|
1135
|
+
local pane="\$1"
|
|
1136
|
+
local agent_name="\$2"
|
|
1137
|
+
local dead_count=0
|
|
1138
|
+
local i
|
|
1139
|
+
for i in 1 2 3; do
|
|
1140
|
+
local pane_cmd
|
|
1141
|
+
pane_cmd=\$(tmux list-panes -t "\${SESSION}" -F '#{pane_index} #{pane_current_command}' 2>/dev/null | grep "^\${pane##*.} " | awk '{print \$2}')
|
|
1142
|
+
if [[ "\$pane_cmd" == "bash" || "\$pane_cmd" == "zsh" || "\$pane_cmd" == "sh" || "\$pane_cmd" == "fish" ]]; then
|
|
1143
|
+
dead_count=\$((dead_count + 1))
|
|
1144
|
+
else
|
|
1145
|
+
return 0 # Agent alive
|
|
1146
|
+
fi
|
|
1147
|
+
[[ \$i -lt 3 ]] && sleep 2
|
|
1148
|
+
done
|
|
1149
|
+
if (( dead_count >= 3 )); then
|
|
1150
|
+
echo "[Watcher] ALERT: \$agent_name appears to have exited (pane shows shell 3 consecutive times)"
|
|
1151
|
+
return 1 # Agent dead
|
|
1152
|
+
fi
|
|
1153
|
+
return 0
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
# Returns 0 if pane output has been stable for at least N seconds
|
|
1157
|
+
check_output_stable() {
|
|
1158
|
+
local log_file="\$1"
|
|
1159
|
+
local stable_seconds="\${2:-5}"
|
|
1160
|
+
|
|
1161
|
+
if [[ ! -f "\$log_file" ]]; then
|
|
1162
|
+
return 0
|
|
1163
|
+
fi
|
|
1164
|
+
|
|
1165
|
+
local mtime_epoch now_epoch elapsed
|
|
1166
|
+
if [[ "\$(uname)" == "Darwin" ]]; then
|
|
1167
|
+
mtime_epoch=\$(stat -f %m "\$log_file" 2>/dev/null || echo 0)
|
|
1168
|
+
else
|
|
1169
|
+
mtime_epoch=\$(stat -c %Y "\$log_file" 2>/dev/null || echo 0)
|
|
1170
|
+
fi
|
|
1171
|
+
now_epoch=\$(date +%s)
|
|
1172
|
+
elapsed=\$(( now_epoch - mtime_epoch ))
|
|
1173
|
+
|
|
1174
|
+
if (( elapsed >= stable_seconds )); then
|
|
1175
|
+
return 0 # Stable
|
|
1176
|
+
fi
|
|
1177
|
+
return 1 # Still producing output
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
# Returns 0 if interactive prompt detected (do NOT send go)
|
|
1181
|
+
check_for_interactive_prompt() {
|
|
1182
|
+
local pane="\$1"
|
|
1183
|
+
local pane_content
|
|
1184
|
+
pane_content=\$(tmux capture-pane -t "\${SESSION}:\${pane}" -p 2>/dev/null | tail -5)
|
|
1185
|
+
if echo "\$pane_content" | grep -Eq "\$INTERACTIVE_RE"; then
|
|
1186
|
+
return 0 # IS interactive
|
|
1187
|
+
fi
|
|
1188
|
+
return 1 # Not interactive
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
# Truncate log file safely: unbind pipe → truncate → rebind
|
|
1192
|
+
truncate_log_if_needed() {
|
|
1193
|
+
local pane="\$1"
|
|
1194
|
+
local log_file="\$2"
|
|
1195
|
+
local max_bytes=1048576 # 1MB
|
|
1196
|
+
|
|
1197
|
+
if [[ ! -f "\$log_file" ]]; then return; fi
|
|
1198
|
+
local size
|
|
1199
|
+
size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
|
|
1200
|
+
if (( size > max_bytes )); then
|
|
1201
|
+
echo "[Watcher] Truncating \$log_file (\${size} bytes > 1MB)"
|
|
1202
|
+
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"
|
|
1204
|
+
tmux pipe-pane -o -t "\${SESSION}:\${pane}" "cat >> \\"\$log_file\\"" 2>/dev/null || true
|
|
1205
|
+
fi
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
# ─── send_go_to_pane ─────────────────────────────
|
|
1209
|
+
|
|
1210
|
+
send_go_to_pane() {
|
|
1211
|
+
local pane="\$1"
|
|
1212
|
+
local agent_name="\$2"
|
|
1213
|
+
local log_file="\$3"
|
|
1214
|
+
local go_msg="\${4:-go}"
|
|
1215
|
+
local max_retries=3
|
|
1216
|
+
local attempt=0
|
|
1217
|
+
|
|
1218
|
+
# 1. Agent alive?
|
|
1219
|
+
if ! check_agent_alive "\$pane" "\$agent_name"; then
|
|
1220
|
+
echo "[Watcher] Skipping \$agent_name - agent not running"
|
|
1221
|
+
return 1
|
|
1222
|
+
fi
|
|
1223
|
+
|
|
1224
|
+
# 2. Interactive prompt?
|
|
1225
|
+
if check_for_interactive_prompt "\$pane"; then
|
|
1226
|
+
echo "[Watcher] Skipping \$agent_name - interactive prompt detected"
|
|
1227
|
+
return 1
|
|
1228
|
+
fi
|
|
1229
|
+
|
|
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
|
|
1259
|
+
# Use first 20 chars as detection marker (long messages wrap in narrow panes)
|
|
1260
|
+
local detect_marker="\${go_msg:0:20}"
|
|
1261
|
+
while (( attempt < max_retries )); do
|
|
1262
|
+
tmux send-keys -t "\${SESSION}:\${pane}" -l "\$go_msg" 2>/dev/null || true
|
|
1263
|
+
sleep 1
|
|
1264
|
+
tmux send-keys -t "\${SESSION}:\${pane}" Enter 2>/dev/null || true
|
|
1265
|
+
sleep 3
|
|
1266
|
+
|
|
1267
|
+
# Check if message is stuck in input line (not submitted)
|
|
1268
|
+
local pane_content
|
|
1269
|
+
pane_content=\$(tmux capture-pane -t "\${SESSION}:\${pane}" -p 2>/dev/null | tail -5)
|
|
1270
|
+
if echo "\$pane_content" | grep -qF "\$detect_marker"; then
|
|
1271
|
+
attempt=\$((attempt + 1))
|
|
1272
|
+
echo "[Watcher] Retry \$attempt: Enter not registered for \$agent_name"
|
|
1273
|
+
tmux send-keys -t "\${SESSION}:\${pane}" C-u 2>/dev/null || true
|
|
1274
|
+
sleep 1
|
|
1275
|
+
else
|
|
1276
|
+
break
|
|
1277
|
+
fi
|
|
1278
|
+
done
|
|
1279
|
+
|
|
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
|
+
return 1
|
|
1287
|
+
fi
|
|
1288
|
+
|
|
1289
|
+
echo "[Watcher] OK: \$agent_name is working (output \$pre_size -> \$post_size)"
|
|
1290
|
+
return 0
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
# ─── trigger_agent ───────────────────────────────
|
|
1294
|
+
|
|
1295
|
+
trigger_agent() {
|
|
1296
|
+
local turn="\$1"
|
|
1297
|
+
if [[ "\$turn" == "ralph" ]]; then
|
|
1298
|
+
# Check pause state
|
|
1299
|
+
if (( PANE0_PAUSED )); then
|
|
1300
|
+
local cur_size
|
|
1301
|
+
cur_size=\$(wc -c < "\$PANE0_LOG" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1302
|
+
if (( cur_size != PANE0_PAUSE_SIZE )) && ! check_for_interactive_prompt "0.0"; then
|
|
1303
|
+
echo "[Watcher] Ralph pane resumed (output changed + prompt gone)"
|
|
1304
|
+
PANE0_PAUSED=0
|
|
1305
|
+
PANE0_PROMPT_HITS=0
|
|
1306
|
+
else
|
|
1307
|
+
echo "[Watcher] Ralph pane still paused (waiting for user)"
|
|
1308
|
+
return 1
|
|
1309
|
+
fi
|
|
1310
|
+
fi
|
|
1311
|
+
local ralph_msg="Your turn. Lisa's feedback is ready — run: ralph-lisa read review.md"
|
|
1312
|
+
send_go_to_pane "0.0" "Ralph" "\$PANE0_LOG" "\$ralph_msg"
|
|
1313
|
+
local rc=\$?
|
|
1314
|
+
if (( rc != 0 )); then
|
|
1315
|
+
# Track interactive prompt hits for pause
|
|
1316
|
+
if check_for_interactive_prompt "0.0"; then
|
|
1317
|
+
PANE0_PROMPT_HITS=\$((PANE0_PROMPT_HITS + 1))
|
|
1318
|
+
if (( PANE0_PROMPT_HITS >= 3 )); then
|
|
1319
|
+
PANE0_PAUSED=1
|
|
1320
|
+
PANE0_PAUSE_SIZE=\$(wc -c < "\$PANE0_LOG" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1321
|
+
echo "[Watcher] PAUSED: Ralph pane waiting for user input (hit \$PANE0_PROMPT_HITS times)"
|
|
1322
|
+
fi
|
|
1323
|
+
fi
|
|
1324
|
+
else
|
|
1325
|
+
PANE0_PROMPT_HITS=0
|
|
1326
|
+
fi
|
|
1327
|
+
return \$rc
|
|
1328
|
+
elif [[ "\$turn" == "lisa" ]]; then
|
|
1329
|
+
if (( PANE1_PAUSED )); then
|
|
1330
|
+
local cur_size
|
|
1331
|
+
cur_size=\$(wc -c < "\$PANE1_LOG" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1332
|
+
if (( cur_size != PANE1_PAUSE_SIZE )) && ! check_for_interactive_prompt "0.1"; then
|
|
1333
|
+
echo "[Watcher] Lisa pane resumed (output changed + prompt gone)"
|
|
1334
|
+
PANE1_PAUSED=0
|
|
1335
|
+
PANE1_PROMPT_HITS=0
|
|
1336
|
+
else
|
|
1337
|
+
echo "[Watcher] Lisa pane still paused (waiting for user)"
|
|
1338
|
+
return 1
|
|
1339
|
+
fi
|
|
1340
|
+
fi
|
|
1341
|
+
local lisa_msg="Your turn. Ralph's work is ready — run: ralph-lisa read work.md"
|
|
1342
|
+
send_go_to_pane "0.1" "Lisa" "\$PANE1_LOG" "\$lisa_msg"
|
|
1343
|
+
local rc=\$?
|
|
1344
|
+
if (( rc != 0 )); then
|
|
1345
|
+
if check_for_interactive_prompt "0.1"; then
|
|
1346
|
+
PANE1_PROMPT_HITS=\$((PANE1_PROMPT_HITS + 1))
|
|
1347
|
+
if (( PANE1_PROMPT_HITS >= 3 )); then
|
|
1348
|
+
PANE1_PAUSED=1
|
|
1349
|
+
PANE1_PAUSE_SIZE=\$(wc -c < "\$PANE1_LOG" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1350
|
+
echo "[Watcher] PAUSED: Lisa pane waiting for user input (hit \$PANE1_PROMPT_HITS times)"
|
|
1351
|
+
fi
|
|
1352
|
+
fi
|
|
1353
|
+
else
|
|
1354
|
+
PANE1_PROMPT_HITS=0
|
|
1355
|
+
fi
|
|
1356
|
+
return \$rc
|
|
1357
|
+
fi
|
|
1358
|
+
return 1
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
# ─── check_and_trigger (state machine) ───────────
|
|
1362
|
+
|
|
1363
|
+
check_and_trigger() {
|
|
1364
|
+
check_session_alive
|
|
1365
|
+
|
|
1366
|
+
# Truncate logs if too large
|
|
1367
|
+
truncate_log_if_needed "0.0" "\$PANE0_LOG"
|
|
1368
|
+
truncate_log_if_needed "0.1" "\$PANE1_LOG"
|
|
1369
|
+
|
|
1370
|
+
if [[ -f "\$STATE_DIR/turn.txt" ]]; then
|
|
1371
|
+
CURRENT_TURN=\$(cat "\$STATE_DIR/turn.txt" 2>/dev/null || echo "")
|
|
1372
|
+
|
|
1373
|
+
# Detect new turn change (reset fail count)
|
|
1374
|
+
if [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
|
|
1375
|
+
echo "[Watcher] Turn changed: \$SEEN_TURN -> \$CURRENT_TURN"
|
|
1376
|
+
SEEN_TURN="\$CURRENT_TURN"
|
|
1377
|
+
FAIL_COUNT=0
|
|
1378
|
+
|
|
1379
|
+
# Write round separator to pane logs for transcript tracking
|
|
1380
|
+
local round_ts
|
|
1381
|
+
round_ts=\$(date "+%Y-%m-%d %H:%M:%S")
|
|
1382
|
+
local round_marker="\\n\\n===== [Turn -> \$CURRENT_TURN] \$round_ts =====\\n\\n"
|
|
1383
|
+
echo -e "\$round_marker" >> "\$PANE0_LOG" 2>/dev/null || true
|
|
1384
|
+
echo -e "\$round_marker" >> "\$PANE1_LOG" 2>/dev/null || true
|
|
1385
|
+
fi
|
|
1386
|
+
|
|
1387
|
+
# Need to deliver? (seen but not yet acked)
|
|
1388
|
+
if [[ -n "\$SEEN_TURN" && "\$SEEN_TURN" != "\$ACKED_TURN" ]]; then
|
|
1389
|
+
# Backoff on repeated failures
|
|
1390
|
+
if (( FAIL_COUNT >= 30 )); then
|
|
1391
|
+
echo "[Watcher] ALERT: \$FAIL_COUNT consecutive failures. Manual intervention needed."
|
|
1392
|
+
sleep 30
|
|
1393
|
+
elif (( FAIL_COUNT >= 10 )); then
|
|
1394
|
+
echo "[Watcher] DEGRADED: \$FAIL_COUNT consecutive failures, slowing down..."
|
|
1395
|
+
sleep 30
|
|
1396
|
+
fi
|
|
1397
|
+
|
|
1398
|
+
if trigger_agent "\$SEEN_TURN"; then
|
|
1399
|
+
ACKED_TURN="\$SEEN_TURN"
|
|
1400
|
+
FAIL_COUNT=0
|
|
1401
|
+
echo "[Watcher] Turn acknowledged: \$SEEN_TURN"
|
|
1402
|
+
else
|
|
1403
|
+
FAIL_COUNT=\$((FAIL_COUNT + 1))
|
|
1404
|
+
echo "[Watcher] Trigger failed (fail_count=\$FAIL_COUNT), will retry next cycle"
|
|
1405
|
+
fi
|
|
1406
|
+
fi
|
|
1407
|
+
fi
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
# ─── Main ────────────────────────────────────────
|
|
1411
|
+
|
|
1412
|
+
echo "[Watcher] Starting v2... (Ctrl+C to stop)"
|
|
1413
|
+
echo "[Watcher] Monitoring \$STATE_DIR/turn.txt"
|
|
1414
|
+
echo "[Watcher] Pane logs: \$PANE0_LOG, \$PANE1_LOG"
|
|
1415
|
+
echo "[Watcher] PID: \$\$"
|
|
1416
|
+
|
|
1417
|
+
sleep 5
|
|
1418
|
+
check_and_trigger
|
|
1419
|
+
|
|
1420
|
+
`;
|
|
1421
|
+
// Event accelerator (optional background subprocess)
|
|
1422
|
+
if (watcher === "fswatch") {
|
|
1423
|
+
watcherContent += `# Event accelerator: fswatch touches flag file to wake main loop faster
|
|
1424
|
+
fswatch -o "\$STATE_DIR/turn.txt" 2>/dev/null | while read; do
|
|
1425
|
+
touch "\${STATE_DIR}/.turn_changed"
|
|
1426
|
+
done &
|
|
1427
|
+
ACCEL_PID=\$!
|
|
1428
|
+
echo "[Watcher] Event accelerator started (fswatch, PID \$ACCEL_PID)"
|
|
1429
|
+
|
|
1430
|
+
`;
|
|
1431
|
+
}
|
|
1432
|
+
else if (watcher === "inotifywait") {
|
|
1433
|
+
watcherContent += `# Event accelerator: inotifywait touches flag file to wake main loop faster
|
|
1434
|
+
while inotifywait -e modify "\$STATE_DIR/turn.txt" 2>/dev/null; do
|
|
1435
|
+
touch "\${STATE_DIR}/.turn_changed"
|
|
1436
|
+
done &
|
|
1437
|
+
ACCEL_PID=\$!
|
|
1438
|
+
echo "[Watcher] Event accelerator started (inotifywait, PID \$ACCEL_PID)"
|
|
1439
|
+
|
|
1440
|
+
`;
|
|
1441
|
+
}
|
|
1442
|
+
// Main polling loop (always runs, event accelerator just speeds it up)
|
|
1443
|
+
watcherContent += `# Main loop: polling + optional event acceleration
|
|
1444
|
+
while true; do
|
|
1445
|
+
check_and_trigger
|
|
1446
|
+
# If event accelerator touched the flag, skip sleep
|
|
1447
|
+
if [[ -f "\${STATE_DIR}/.turn_changed" ]]; then
|
|
1448
|
+
rm -f "\${STATE_DIR}/.turn_changed"
|
|
1449
|
+
else
|
|
1450
|
+
sleep 2
|
|
1451
|
+
fi
|
|
1452
|
+
done
|
|
1453
|
+
`;
|
|
1454
|
+
(0, state_js_1.writeFile)(watcherScript, watcherContent);
|
|
1455
|
+
fs.chmodSync(watcherScript, 0o755);
|
|
1456
|
+
// Launch tmux session
|
|
1457
|
+
// Layout: Ralph (left) | Lisa (right), Watcher runs in background
|
|
1458
|
+
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`);
|
|
1459
|
+
// Pane 0: Ralph (left), Pane 1: Lisa (right)
|
|
1460
|
+
execSync(`tmux new-session -d -s "${sessionName}" -n "main" -c "${projectDir}"`);
|
|
1461
|
+
execSync(`tmux split-window -h -t "${sessionName}" -c "${projectDir}"`);
|
|
1462
|
+
// Pane 0 = Ralph (left), Pane 1 = Lisa (right)
|
|
1463
|
+
execSync(`tmux send-keys -t "${sessionName}:0.0" "echo '=== Ralph (Claude Code) ===' && ${claudeCmd}" Enter`);
|
|
1464
|
+
execSync(`tmux send-keys -t "${sessionName}:0.1" "echo '=== Lisa (Codex) ===' && ${codexCmd}" Enter`);
|
|
1465
|
+
execSync(`tmux select-pane -t "${sessionName}:0.0"`);
|
|
1466
|
+
// Watcher runs in background (logs to .dual-agent/watcher.log)
|
|
1467
|
+
const watcherLog = path.join(dir, "watcher.log");
|
|
1468
|
+
execSync(`bash -c 'nohup "${watcherScript}" > "${watcherLog}" 2>&1 &'`);
|
|
1469
|
+
console.log("");
|
|
1470
|
+
console.log(line());
|
|
1471
|
+
console.log("Auto Mode Started!");
|
|
1472
|
+
console.log(line());
|
|
1473
|
+
console.log("");
|
|
1474
|
+
console.log("Layout:");
|
|
1475
|
+
console.log(" +-----------+-----------+");
|
|
1476
|
+
console.log(" | Ralph | Lisa |");
|
|
1477
|
+
console.log(" | (Claude) | (Codex) |");
|
|
1478
|
+
console.log(" +-----------+-----------+");
|
|
1479
|
+
console.log(" Watcher runs in background (log: .dual-agent/watcher.log)");
|
|
1480
|
+
console.log(" Pane output captured: .dual-agent/pane0.log, .dual-agent/pane1.log");
|
|
1481
|
+
console.log("");
|
|
1482
|
+
console.log("Attaching to session...");
|
|
1483
|
+
console.log(line());
|
|
1484
|
+
execSync(`tmux attach-session -t "${sessionName}"`, { stdio: "inherit" });
|
|
1485
|
+
}
|
|
1486
|
+
// ─── policy ──────────────────────────────────────
|
|
1487
|
+
function cmdPolicy(args) {
|
|
1488
|
+
const sub = args[0];
|
|
1489
|
+
if (sub === "check-consensus") {
|
|
1490
|
+
cmdPolicyCheckConsensus();
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (sub === "check-next-step") {
|
|
1494
|
+
cmdPolicyCheckNextStep();
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (sub !== "check") {
|
|
1498
|
+
console.error("Usage:");
|
|
1499
|
+
console.error(" ralph-lisa policy check <ralph|lisa>");
|
|
1500
|
+
console.error(" ralph-lisa policy check-consensus");
|
|
1501
|
+
console.error(" ralph-lisa policy check-next-step");
|
|
1502
|
+
process.exit(1);
|
|
1503
|
+
}
|
|
1504
|
+
const role = args[1];
|
|
1505
|
+
if (role !== "ralph" && role !== "lisa") {
|
|
1506
|
+
console.error("Usage: ralph-lisa policy check <ralph|lisa>");
|
|
1507
|
+
process.exit(1);
|
|
1508
|
+
}
|
|
1509
|
+
(0, state_js_1.checkSession)();
|
|
1510
|
+
const dir = (0, state_js_1.stateDir)();
|
|
1511
|
+
const file = role === "ralph" ? "work.md" : "review.md";
|
|
1512
|
+
const raw = (0, state_js_1.readFile)(path.join(dir, file));
|
|
1513
|
+
if (!raw) {
|
|
1514
|
+
console.log("No submission to check.");
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const content = extractSubmissionContent(raw);
|
|
1518
|
+
if (!content) {
|
|
1519
|
+
console.log("No submission content found.");
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const tag = (0, state_js_1.extractTag)(content);
|
|
1523
|
+
if (!tag) {
|
|
1524
|
+
console.log("No valid tag found in submission.");
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const violations = role === "ralph" ? (0, policy_js_1.checkRalph)(tag, content) : (0, policy_js_1.checkLisa)(tag, content);
|
|
1528
|
+
if (violations.length === 0) {
|
|
1529
|
+
console.log("Policy check passed.");
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
console.error("");
|
|
1533
|
+
console.error("⚠️ Policy violations:");
|
|
1534
|
+
for (const v of violations) {
|
|
1535
|
+
console.error(` - ${v.message}`);
|
|
1536
|
+
}
|
|
1537
|
+
console.error("");
|
|
1538
|
+
// Standalone policy check always exits non-zero on violations,
|
|
1539
|
+
// regardless of RL_POLICY_MODE. This is a hard gate for use in
|
|
1540
|
+
// scripts/hooks. RL_POLICY_MODE only affects inline checks during submit.
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Check if the most recent round has both agents submitting [CONSENSUS].
|
|
1545
|
+
*/
|
|
1546
|
+
function cmdPolicyCheckConsensus() {
|
|
1547
|
+
(0, state_js_1.checkSession)();
|
|
1548
|
+
const dir = (0, state_js_1.stateDir)();
|
|
1549
|
+
const workRaw = (0, state_js_1.readFile)(path.join(dir, "work.md"));
|
|
1550
|
+
const reviewRaw = (0, state_js_1.readFile)(path.join(dir, "review.md"));
|
|
1551
|
+
const workContent = extractSubmissionContent(workRaw);
|
|
1552
|
+
const reviewContent = extractSubmissionContent(reviewRaw);
|
|
1553
|
+
const workTag = workContent ? (0, state_js_1.extractTag)(workContent) : "";
|
|
1554
|
+
const reviewTag = reviewContent ? (0, state_js_1.extractTag)(reviewContent) : "";
|
|
1555
|
+
const issues = [];
|
|
1556
|
+
if (workTag !== "CONSENSUS") {
|
|
1557
|
+
issues.push(`Ralph's latest submission is [${workTag || "none"}], not [CONSENSUS].`);
|
|
1558
|
+
}
|
|
1559
|
+
if (reviewTag !== "CONSENSUS") {
|
|
1560
|
+
issues.push(`Lisa's latest submission is [${reviewTag || "none"}], not [CONSENSUS].`);
|
|
1561
|
+
}
|
|
1562
|
+
if (issues.length === 0) {
|
|
1563
|
+
console.log("Consensus reached: both agents submitted [CONSENSUS].");
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
console.error("Consensus NOT reached:");
|
|
1567
|
+
for (const issue of issues) {
|
|
1568
|
+
console.error(` - ${issue}`);
|
|
1569
|
+
}
|
|
1570
|
+
process.exit(1);
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Comprehensive check for proceeding to the next step:
|
|
1574
|
+
* 1. Both agents have submitted [CONSENSUS]
|
|
1575
|
+
* 2. Ralph's submission passes policy checks
|
|
1576
|
+
* 3. Lisa's submission passes policy checks
|
|
1577
|
+
*/
|
|
1578
|
+
function cmdPolicyCheckNextStep() {
|
|
1579
|
+
(0, state_js_1.checkSession)();
|
|
1580
|
+
const dir = (0, state_js_1.stateDir)();
|
|
1581
|
+
const workRaw = (0, state_js_1.readFile)(path.join(dir, "work.md"));
|
|
1582
|
+
const reviewRaw = (0, state_js_1.readFile)(path.join(dir, "review.md"));
|
|
1583
|
+
const workContent = extractSubmissionContent(workRaw);
|
|
1584
|
+
const reviewContent = extractSubmissionContent(reviewRaw);
|
|
1585
|
+
const workTag = workContent ? (0, state_js_1.extractTag)(workContent) : "";
|
|
1586
|
+
const reviewTag = reviewContent ? (0, state_js_1.extractTag)(reviewContent) : "";
|
|
1587
|
+
const allIssues = [];
|
|
1588
|
+
// 1. Consensus check
|
|
1589
|
+
if (workTag !== "CONSENSUS") {
|
|
1590
|
+
allIssues.push(`Ralph's latest is [${workTag || "none"}], not [CONSENSUS].`);
|
|
1591
|
+
}
|
|
1592
|
+
if (reviewTag !== "CONSENSUS") {
|
|
1593
|
+
allIssues.push(`Lisa's latest is [${reviewTag || "none"}], not [CONSENSUS].`);
|
|
1594
|
+
}
|
|
1595
|
+
// 2. Policy checks on latest submissions (if content exists)
|
|
1596
|
+
if (workContent && workTag) {
|
|
1597
|
+
const rv = (0, policy_js_1.checkRalph)(workTag, workContent);
|
|
1598
|
+
for (const v of rv)
|
|
1599
|
+
allIssues.push(`Ralph: ${v.message}`);
|
|
1600
|
+
}
|
|
1601
|
+
if (reviewContent && reviewTag) {
|
|
1602
|
+
const lv = (0, policy_js_1.checkLisa)(reviewTag, reviewContent);
|
|
1603
|
+
for (const v of lv)
|
|
1604
|
+
allIssues.push(`Lisa: ${v.message}`);
|
|
1605
|
+
}
|
|
1606
|
+
if (allIssues.length === 0) {
|
|
1607
|
+
console.log("Ready to proceed: consensus reached and all checks pass.");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
console.error("Not ready to proceed:");
|
|
1611
|
+
for (const issue of allIssues) {
|
|
1612
|
+
console.error(` - ${issue}`);
|
|
1613
|
+
}
|
|
1614
|
+
process.exit(1);
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Extract the actual submission content from work.md/review.md.
|
|
1618
|
+
* The file has metadata headers; the submission content is the part
|
|
1619
|
+
* that starts with a [TAG] line.
|
|
1620
|
+
*/
|
|
1621
|
+
function extractSubmissionContent(raw) {
|
|
1622
|
+
const lines = raw.split("\n");
|
|
1623
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1624
|
+
if ((0, state_js_1.extractTag)(lines[i])) {
|
|
1625
|
+
return lines.slice(i).join("\n");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return "";
|
|
1629
|
+
}
|
|
1630
|
+
// ─── logs ────────────────────────────────────────
|
|
1631
|
+
function cmdLogs(args) {
|
|
1632
|
+
const dir = (0, state_js_1.stateDir)();
|
|
1633
|
+
const logsDir = path.join(dir, "logs");
|
|
1634
|
+
// Also include live pane logs
|
|
1635
|
+
const liveFiles = [];
|
|
1636
|
+
for (const f of ["pane0.log", "pane1.log"]) {
|
|
1637
|
+
const p = path.join(dir, f);
|
|
1638
|
+
if (fs.existsSync(p) && fs.statSync(p).size > 0)
|
|
1639
|
+
liveFiles.push(p);
|
|
1640
|
+
}
|
|
1641
|
+
const archivedFiles = [];
|
|
1642
|
+
if (fs.existsSync(logsDir)) {
|
|
1643
|
+
archivedFiles.push(...fs.readdirSync(logsDir)
|
|
1644
|
+
.filter((f) => f.endsWith(".log"))
|
|
1645
|
+
.sort()
|
|
1646
|
+
.map((f) => path.join(logsDir, f)));
|
|
1647
|
+
}
|
|
1648
|
+
const sub = args[0] || "";
|
|
1649
|
+
if (sub === "cat" || sub === "view") {
|
|
1650
|
+
// ralph-lisa logs cat [filename] — view a specific log or latest
|
|
1651
|
+
const target = args[1];
|
|
1652
|
+
let file;
|
|
1653
|
+
if (target) {
|
|
1654
|
+
// Try exact match in logs/ or as pane name
|
|
1655
|
+
file = [...archivedFiles, ...liveFiles].find((f) => path.basename(f) === target || f.endsWith(target));
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
// Default: show live pane logs
|
|
1659
|
+
if (liveFiles.length > 0) {
|
|
1660
|
+
for (const f of liveFiles) {
|
|
1661
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
1662
|
+
console.log(` ${path.basename(f)} (live)`);
|
|
1663
|
+
console.log(`${"=".repeat(60)}\n`);
|
|
1664
|
+
console.log(fs.readFileSync(f, "utf-8"));
|
|
1665
|
+
}
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
console.log("No live pane logs. Use 'ralph-lisa logs cat <filename>' to view archived logs.");
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (file && fs.existsSync(file)) {
|
|
1672
|
+
console.log(fs.readFileSync(file, "utf-8"));
|
|
1673
|
+
}
|
|
1674
|
+
else {
|
|
1675
|
+
console.error(`Log not found: ${target}`);
|
|
1676
|
+
process.exit(1);
|
|
1677
|
+
}
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
// Default: list all logs
|
|
1681
|
+
console.log("Transcript Logs");
|
|
1682
|
+
console.log("===============\n");
|
|
1683
|
+
if (liveFiles.length > 0) {
|
|
1684
|
+
console.log("Live (current session):");
|
|
1685
|
+
for (const f of liveFiles) {
|
|
1686
|
+
const stat = fs.statSync(f);
|
|
1687
|
+
const size = stat.size > 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
|
|
1688
|
+
console.log(` ${path.basename(f)} ${size} ${stat.mtime.toISOString().slice(0, 19)}`);
|
|
1689
|
+
}
|
|
1690
|
+
console.log("");
|
|
1691
|
+
}
|
|
1692
|
+
if (archivedFiles.length > 0) {
|
|
1693
|
+
console.log("Archived (previous sessions):");
|
|
1694
|
+
for (const f of archivedFiles) {
|
|
1695
|
+
const stat = fs.statSync(f);
|
|
1696
|
+
const size = stat.size > 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
|
|
1697
|
+
console.log(` ${path.basename(f)} ${size} ${stat.mtime.toISOString().slice(0, 19)}`);
|
|
1698
|
+
}
|
|
1699
|
+
console.log("");
|
|
1700
|
+
}
|
|
1701
|
+
if (liveFiles.length === 0 && archivedFiles.length === 0) {
|
|
1702
|
+
console.log("No transcript logs found. Logs are created during auto mode sessions.");
|
|
1703
|
+
}
|
|
1704
|
+
console.log("\nUsage:");
|
|
1705
|
+
console.log(" ralph-lisa logs List all logs");
|
|
1706
|
+
console.log(" ralph-lisa logs cat View live pane logs");
|
|
1707
|
+
console.log(" ralph-lisa logs cat <file> View specific log file");
|
|
1708
|
+
}
|
|
1709
|
+
// ─── doctor ──────────────────────────────────────
|
|
1710
|
+
function cmdDoctor(args) {
|
|
1711
|
+
const strict = args.includes("--strict");
|
|
1712
|
+
const { execSync } = require("node:child_process");
|
|
1713
|
+
console.log(line());
|
|
1714
|
+
console.log("Ralph-Lisa Loop - Dependency Check");
|
|
1715
|
+
console.log(line());
|
|
1716
|
+
console.log("");
|
|
1717
|
+
let allOk = true;
|
|
1718
|
+
let hasWatcher = false;
|
|
1719
|
+
const checks = [
|
|
1720
|
+
{
|
|
1721
|
+
name: "tmux",
|
|
1722
|
+
cmd: "which tmux",
|
|
1723
|
+
versionCmd: "tmux -V",
|
|
1724
|
+
required: true,
|
|
1725
|
+
installHint: "brew install tmux (macOS) / apt install tmux (Linux)",
|
|
1726
|
+
},
|
|
1727
|
+
{
|
|
1728
|
+
name: "claude (Claude Code CLI)",
|
|
1729
|
+
cmd: "which claude",
|
|
1730
|
+
versionCmd: "claude --version",
|
|
1731
|
+
required: true,
|
|
1732
|
+
installHint: "npm install -g @anthropic-ai/claude-code",
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
name: "codex (OpenAI Codex CLI)",
|
|
1736
|
+
cmd: "which codex",
|
|
1737
|
+
versionCmd: "codex --version",
|
|
1738
|
+
required: true,
|
|
1739
|
+
installHint: "npm install -g @openai/codex",
|
|
1740
|
+
},
|
|
1741
|
+
{
|
|
1742
|
+
name: "fswatch (file watcher)",
|
|
1743
|
+
cmd: "which fswatch",
|
|
1744
|
+
required: false,
|
|
1745
|
+
installHint: "brew install fswatch (macOS)",
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
name: "inotifywait (file watcher)",
|
|
1749
|
+
cmd: "which inotifywait",
|
|
1750
|
+
required: false,
|
|
1751
|
+
installHint: "apt install inotify-tools (Linux)",
|
|
1752
|
+
},
|
|
1753
|
+
];
|
|
1754
|
+
for (const check of checks) {
|
|
1755
|
+
try {
|
|
1756
|
+
execSync(check.cmd, { stdio: "pipe" });
|
|
1757
|
+
let version = "";
|
|
1758
|
+
if (check.versionCmd) {
|
|
1759
|
+
try {
|
|
1760
|
+
version = execSync(check.versionCmd, {
|
|
1761
|
+
stdio: "pipe",
|
|
1762
|
+
encoding: "utf-8",
|
|
1763
|
+
timeout: 5000,
|
|
1764
|
+
})
|
|
1765
|
+
.trim()
|
|
1766
|
+
.split("\n")[0];
|
|
1767
|
+
}
|
|
1768
|
+
catch { }
|
|
1769
|
+
}
|
|
1770
|
+
console.log(` OK ${check.name}${version ? ` (${version})` : ""}`);
|
|
1771
|
+
if (check.name.includes("fswatch") || check.name.includes("inotifywait")) {
|
|
1772
|
+
hasWatcher = true;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
catch {
|
|
1776
|
+
if (check.required) {
|
|
1777
|
+
console.log(` MISSING ${check.name}`);
|
|
1778
|
+
console.log(` Install: ${check.installHint}`);
|
|
1779
|
+
allOk = false;
|
|
1780
|
+
}
|
|
1781
|
+
else {
|
|
1782
|
+
console.log(` -- ${check.name} (optional)`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
if (!hasWatcher) {
|
|
1787
|
+
console.log("");
|
|
1788
|
+
console.log(" Note: No file watcher found. Auto mode will use polling (slower).");
|
|
1789
|
+
console.log(" Install fswatch or inotify-tools for event-driven turn detection.");
|
|
1790
|
+
}
|
|
1791
|
+
// Node version
|
|
1792
|
+
const nodeVersion = process.version;
|
|
1793
|
+
const majorVersion = parseInt(nodeVersion.slice(1), 10);
|
|
1794
|
+
if (majorVersion >= 18) {
|
|
1795
|
+
console.log(` OK Node.js ${nodeVersion}`);
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
console.log(` WARNING Node.js ${nodeVersion} (requires >= 18)`);
|
|
1799
|
+
allOk = false;
|
|
1800
|
+
}
|
|
1801
|
+
console.log("");
|
|
1802
|
+
if (allOk) {
|
|
1803
|
+
console.log("All required dependencies satisfied.");
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
console.log("Some required dependencies missing. Install them and re-run 'ralph-lisa doctor'.");
|
|
1807
|
+
}
|
|
1808
|
+
console.log(line());
|
|
1809
|
+
if (strict && !allOk) {
|
|
1810
|
+
process.exit(1);
|
|
1811
|
+
}
|
|
1812
|
+
}
|