ralph-lisa-loop 0.3.8 → 0.3.9

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
@@ -36,7 +36,7 @@ Ralph writes → Lisa reviews → Consensus → Next step
36
36
  ### 1. Install
37
37
 
38
38
  ```bash
39
- npm i -g ralph-lisa
39
+ npm i -g ralph-lisa-loop
40
40
  ```
41
41
 
42
42
  ### 2. Initialize Project
@@ -61,22 +61,16 @@ ralph-lisa auto "implement login feature"
61
61
  **Terminal 1 - Ralph (Claude Code)**:
62
62
  ```bash
63
63
  ralph-lisa whose-turn # Check turn
64
- # ... do work ...
65
- ralph-lisa submit-ralph "[PLAN] Login feature design
66
-
67
- 1. Create login form component
68
- 2. Add validation
69
- 3. Connect to API"
64
+ # ... do work, write submission to .dual-agent/submit.md ...
65
+ ralph-lisa submit-ralph --file .dual-agent/submit.md
70
66
  ```
71
67
 
72
68
  **Terminal 2 - Lisa (Codex)**:
73
69
  ```bash
74
70
  ralph-lisa whose-turn # Check turn
75
71
  ralph-lisa read work.md # Read Ralph's work
76
- ralph-lisa submit-lisa "[PASS] Plan looks good
77
-
78
- - Clear structure
79
- - Good separation of concerns"
72
+ # ... write review to .dual-agent/submit.md ...
73
+ ralph-lisa submit-lisa --file .dual-agent/submit.md
80
74
  ```
81
75
 
82
76
  ## Features
@@ -126,7 +120,7 @@ export RL_POLICY_MODE=warn
126
120
  # Enable block mode (rejects non-compliant submissions)
127
121
  export RL_POLICY_MODE=block
128
122
 
129
- # Disable (default)
123
+ # Disable
130
124
  export RL_POLICY_MODE=off
131
125
  ```
132
126
 
@@ -182,17 +176,27 @@ ralph-lisa auto --full-auto "task" # Auto mode without permission prompts
182
176
 
183
177
  # Turn control
184
178
  ralph-lisa whose-turn # Check whose turn
185
- ralph-lisa submit-ralph "[TAG] ..." # Ralph submits
186
- ralph-lisa submit-lisa "[TAG] ..." # Lisa submits
179
+ ralph-lisa check-turn # Alias for whose-turn
180
+ ralph-lisa submit-ralph --file f.md # Ralph submits (recommended)
181
+ ralph-lisa submit-lisa --file f.md # Lisa submits (recommended)
182
+ ralph-lisa submit-ralph --stdin # Submit via stdin pipe
183
+ ralph-lisa submit-lisa --stdin # Lisa submit via stdin pipe
184
+ ralph-lisa submit-ralph "[TAG] ..." # Inline (deprecated)
187
185
 
188
186
  # Information
189
187
  ralph-lisa status # Current status
190
188
  ralph-lisa read work.md # Ralph's latest
191
189
  ralph-lisa read review.md # Lisa's latest
190
+ ralph-lisa read-review # Alias for read review.md
191
+ ralph-lisa read review --round N # Read review from round N
192
192
  ralph-lisa history # Full history
193
+ ralph-lisa recap # Context recovery summary
194
+ ralph-lisa logs # List transcript logs
195
+ ralph-lisa logs cat [name] # View a specific log
193
196
 
194
197
  # Flow control
195
- ralph-lisa step "phase-name" # Enter new phase
198
+ ralph-lisa step "phase-name" # Enter new phase (requires consensus)
199
+ ralph-lisa step --force "phase-name" # Enter new phase (skip consensus check)
196
200
  ralph-lisa update-task "new direction" # Update task direction mid-session
197
201
  ralph-lisa archive [name] # Archive session
198
202
  ralph-lisa clean # Clean session
@@ -247,7 +251,7 @@ For auto mode:
247
251
 
248
252
  | Variable | Default | Description |
249
253
  |----------|---------|-------------|
250
- | `RL_POLICY_MODE` | `off` | Policy check mode: `off`, `warn`, `block` |
254
+ | `RL_POLICY_MODE` | `warn` | Policy check mode: `off`, `warn`, `block` |
251
255
  | `RL_CHECKPOINT_ROUNDS` | `0` (disabled) | Pause for human review every N rounds |
252
256
  | `RL_LOG_MAX_MB` | `5` | Pane log truncation threshold in MB (min 1) |
253
257
 
@@ -257,8 +261,7 @@ Part of the [TigerHill](https://github.com/Click-Intelligence-LLC/TigerHill) pro
257
261
 
258
262
  ## See Also
259
263
 
260
- - [CONCEPT.md](CONCEPT.md) - Why dual-agent collaboration works
261
- - [UPGRADE_PLAN_V3.md](UPGRADE_PLAN_V3.md) - V3 design document
264
+ - [CONCEPT.md](../CONCEPT.md) - Why dual-agent collaboration works
262
265
 
263
266
  ## License
264
267
 
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ switch (cmd) {
21
21
  (0, commands_js_1.cmdUninit)();
22
22
  break;
23
23
  case "whose-turn":
24
+ case "check-turn":
24
25
  (0, commands_js_1.cmdWhoseTurn)();
25
26
  break;
26
27
  case "submit-ralph":
@@ -35,10 +36,14 @@ switch (cmd) {
35
36
  case "read":
36
37
  (0, commands_js_1.cmdRead)(rest);
37
38
  break;
39
+ case "read-review":
40
+ (0, commands_js_1.cmdRead)(["review.md", ...rest]);
41
+ break;
38
42
  case "recap":
39
43
  (0, commands_js_1.cmdRecap)();
40
44
  break;
41
45
  case "step":
46
+ case "next-step":
42
47
  (0, commands_js_1.cmdStep)(rest);
43
48
  break;
44
49
  case "history":
@@ -96,11 +101,13 @@ function showHelp() {
96
101
  console.log(' ralph-lisa auto --full-auto "task" Auto mode without permission prompts');
97
102
  console.log("");
98
103
  console.log("Turn Control:");
99
- console.log(" ralph-lisa whose-turn Check whose turn");
100
- console.log(' ralph-lisa submit-ralph "[TAG]..." Ralph submits');
101
- console.log(" ralph-lisa submit-ralph --file <f> Ralph submits from file");
102
- console.log(' ralph-lisa submit-lisa "[TAG]..." Lisa submits');
103
- console.log(" ralph-lisa submit-lisa --file <f> Lisa submits from file");
104
+ console.log(" ralph-lisa check-turn Check whose turn (alias: whose-turn)");
105
+ console.log(" ralph-lisa submit-ralph --file <f> Ralph submits from file (recommended)");
106
+ console.log(" ralph-lisa submit-lisa --file <f> Lisa submits from file (recommended)");
107
+ console.log(" ralph-lisa submit-ralph --stdin Ralph submits from stdin");
108
+ console.log(" ralph-lisa submit-lisa --stdin Lisa submits from stdin");
109
+ console.log(' ralph-lisa submit-ralph "[TAG]..." Ralph submits inline (deprecated)');
110
+ console.log(' ralph-lisa submit-lisa "[TAG]..." Lisa submits inline (deprecated)');
104
111
  console.log("");
105
112
  console.log("Tags:");
106
113
  console.log(" Ralph: [PLAN] [RESEARCH] [CODE] [FIX] [CHALLENGE] [DISCUSS] [QUESTION] [CONSENSUS]");
@@ -108,12 +115,13 @@ function showHelp() {
108
115
  console.log("");
109
116
  console.log("Information:");
110
117
  console.log(" ralph-lisa status Show current status");
111
- console.log(" ralph-lisa read <file> Read work.md/review.md");
118
+ console.log(" ralph-lisa read-review Read Lisa's review (alias: read review.md)");
119
+ console.log(" ralph-lisa read <file> Read work.md/review.md/etc");
112
120
  console.log(" ralph-lisa recap Context recovery summary");
113
121
  console.log(" ralph-lisa history Show full history");
114
122
  console.log("");
115
123
  console.log("Flow Control:");
116
- console.log(' ralph-lisa step "name" Enter new step');
124
+ console.log(' ralph-lisa next-step "name" Enter new step (alias: step)');
117
125
  console.log(' ralph-lisa update-task "desc" Update task direction');
118
126
  console.log(" ralph-lisa archive [name] Archive session");
119
127
  console.log(" ralph-lisa clean Clean session");
@@ -141,6 +149,6 @@ function showVersion() {
141
149
  console.log(`ralph-lisa-loop v${pkg.version}`);
142
150
  }
143
151
  catch {
144
- console.log("ralph-lisa-loop v0.3.0");
152
+ console.log("ralph-lisa-loop v0.3.9");
145
153
  }
146
154
  }
@@ -2,6 +2,13 @@
2
2
  * CLI commands for Ralph-Lisa Loop.
3
3
  * Direct port of io.sh logic to Node/TS.
4
4
  */
5
+ /**
6
+ * Generate a project-specific tmux session name to avoid conflicts
7
+ * when running multiple projects simultaneously.
8
+ * Format: rll-{sanitized-dirname}-{short-hash}
9
+ * tmux session names cannot contain '.' or ':'.
10
+ */
11
+ export declare function generateSessionName(projectDir: string): string;
5
12
  export declare function cmdInit(args: string[]): void;
6
13
  export declare function cmdWhoseTurn(): void;
7
14
  export declare function cmdSubmitRalph(args: string[]): void;
package/dist/commands.js CHANGED
@@ -37,6 +37,7 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  };
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.generateSessionName = generateSessionName;
40
41
  exports.cmdInit = cmdInit;
41
42
  exports.cmdWhoseTurn = cmdWhoseTurn;
42
43
  exports.cmdSubmitRalph = cmdSubmitRalph;
@@ -58,12 +59,26 @@ exports.cmdLogs = cmdLogs;
58
59
  exports.cmdDoctor = cmdDoctor;
59
60
  const fs = __importStar(require("node:fs"));
60
61
  const path = __importStar(require("node:path"));
62
+ const crypto = __importStar(require("node:crypto"));
61
63
  const node_child_process_1 = require("node:child_process");
62
64
  const state_js_1 = require("./state.js");
63
65
  const policy_js_1 = require("./policy.js");
64
66
  function line(ch = "=", len = 40) {
65
67
  return ch.repeat(len);
66
68
  }
69
+ /**
70
+ * Generate a project-specific tmux session name to avoid conflicts
71
+ * when running multiple projects simultaneously.
72
+ * Format: rll-{sanitized-dirname}-{short-hash}
73
+ * tmux session names cannot contain '.' or ':'.
74
+ */
75
+ function generateSessionName(projectDir) {
76
+ const dirName = path.basename(projectDir);
77
+ const hash = crypto.createHash("md5").update(projectDir).digest("hex").slice(0, 6);
78
+ // Sanitize: keep alphanumeric and hyphens only, truncate to 20 chars
79
+ const sanitized = dirName.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 20);
80
+ return `rll-${sanitized || "project"}-${hash}`;
81
+ }
67
82
  /**
68
83
  * Resolve submission content from args, --file, or --stdin.
69
84
  * Returns content and whether it came from an external source (file/stdin).
@@ -92,7 +107,8 @@ function resolveContent(args) {
92
107
  process.exit(1);
93
108
  }
94
109
  }
95
- return { content: args.join(" "), external: false };
110
+ // Replace literal \n sequences with real newlines (IMP-3: inline format fix)
111
+ return { content: args.join(" ").replace(/\\n/g, "\n"), external: false };
96
112
  }
97
113
  /**
98
114
  * Get list of changed files via git diff.
@@ -119,7 +135,8 @@ function cmdInit(args) {
119
135
  console.error('Usage: ralph-lisa init "task description"');
120
136
  process.exit(1);
121
137
  }
122
- const dir = (0, state_js_1.stateDir)();
138
+ // Always create in CWD — init is a creation command, not a discovery command
139
+ const dir = (0, state_js_1.stateDir)(process.cwd());
123
140
  if (fs.existsSync(dir)) {
124
141
  console.log("Warning: Existing session will be overwritten");
125
142
  }
@@ -141,7 +158,7 @@ function cmdInit(args) {
141
158
  console.log(`Task: ${task}`);
142
159
  console.log("Turn: ralph");
143
160
  console.log("");
144
- console.log('Ralph should start with: ralph-lisa submit-ralph "[PLAN] summary..."');
161
+ console.log("Ralph should start with: ralph-lisa submit-ralph --file .dual-agent/submit.md");
145
162
  console.log(line());
146
163
  }
147
164
  // ─── whose-turn ──────────────────────────────────
@@ -154,9 +171,9 @@ function cmdSubmitRalph(args) {
154
171
  (0, state_js_1.checkSession)();
155
172
  const { content, external } = resolveContent(args);
156
173
  if (!content) {
157
- console.error('Usage: ralph-lisa submit-ralph "[TAG] summary\\n\\ndetails..."');
158
- console.error(' ralph-lisa submit-ralph --file <path>');
174
+ console.error("Usage: ralph-lisa submit-ralph --file <path> (recommended)");
159
175
  console.error(" echo content | ralph-lisa submit-ralph --stdin");
176
+ console.error(' ralph-lisa submit-ralph "[TAG] ..." (deprecated, shell escaping issues)');
160
177
  console.error("");
161
178
  console.error("Valid tags: PLAN, RESEARCH, CODE, FIX, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
162
179
  process.exit(1);
@@ -175,8 +192,15 @@ function cmdSubmitRalph(args) {
175
192
  console.error("Valid tags: PLAN, RESEARCH, CODE, FIX, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
176
193
  process.exit(1);
177
194
  }
178
- // Policy check
179
- if (!(0, policy_js_1.runPolicyCheck)("ralph", tag, content)) {
195
+ // Policy check (IMP-4: clear status/warning separation)
196
+ const { proceed, violations } = (0, policy_js_1.runPolicyCheck)("ralph", tag, content);
197
+ if (!proceed) {
198
+ console.error(line());
199
+ console.error("Submission BLOCKED by policy:");
200
+ for (const v of violations) {
201
+ console.error(` - ${v.message}`);
202
+ }
203
+ console.error(line());
180
204
  process.exit(1);
181
205
  }
182
206
  const round = (0, state_js_1.getRound)();
@@ -207,7 +231,18 @@ function cmdSubmitRalph(args) {
207
231
  (0, state_js_1.updateLastAction)("Ralph", content);
208
232
  (0, state_js_1.setTurn)("lisa");
209
233
  console.log(line());
210
- console.log(`Submitted: [${tag}] ${summary}`);
234
+ if (violations.length > 0) {
235
+ console.log(`Submitted OK (with warnings): [${tag}] ${summary}`);
236
+ console.log("");
237
+ console.log("Policy warnings:");
238
+ for (const v of violations) {
239
+ console.log(` - ${v.message}`);
240
+ }
241
+ console.log("");
242
+ }
243
+ else {
244
+ console.log(`Submitted: [${tag}] ${summary}`);
245
+ }
211
246
  console.log("Turn passed to: Lisa");
212
247
  console.log(line());
213
248
  console.log("");
@@ -218,9 +253,9 @@ function cmdSubmitLisa(args) {
218
253
  (0, state_js_1.checkSession)();
219
254
  const { content, external } = resolveContent(args);
220
255
  if (!content) {
221
- console.error('Usage: ralph-lisa submit-lisa "[TAG] summary\\n\\ndetails..."');
222
- console.error(' ralph-lisa submit-lisa --file <path>');
256
+ console.error("Usage: ralph-lisa submit-lisa --file <path> (recommended)");
223
257
  console.error(" echo content | ralph-lisa submit-lisa --stdin");
258
+ console.error(' ralph-lisa submit-lisa "[TAG] ..." (deprecated, shell escaping issues)');
224
259
  console.error("");
225
260
  console.error("Valid tags: PASS, NEEDS_WORK, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
226
261
  process.exit(1);
@@ -239,8 +274,15 @@ function cmdSubmitLisa(args) {
239
274
  console.error("Valid tags: PASS, NEEDS_WORK, CHALLENGE, DISCUSS, QUESTION, CONSENSUS");
240
275
  process.exit(1);
241
276
  }
242
- // Policy check
243
- if (!(0, policy_js_1.runPolicyCheck)("lisa", tag, content)) {
277
+ // Policy check (IMP-4: clear status/warning separation)
278
+ const { proceed, violations } = (0, policy_js_1.runPolicyCheck)("lisa", tag, content);
279
+ if (!proceed) {
280
+ console.error(line());
281
+ console.error("Submission BLOCKED by policy:");
282
+ for (const v of violations) {
283
+ console.error(` - ${v.message}`);
284
+ }
285
+ console.error(line());
244
286
  process.exit(1);
245
287
  }
246
288
  const round = (0, state_js_1.getRound)();
@@ -277,7 +319,18 @@ function cmdSubmitLisa(args) {
277
319
  const nextRound = (parseInt(round, 10) || 0) + 1;
278
320
  (0, state_js_1.setRound)(nextRound);
279
321
  console.log(line());
280
- console.log(`Submitted: [${tag}] ${summary}`);
322
+ if (violations.length > 0) {
323
+ console.log(`Submitted OK (with warnings): [${tag}] ${summary}`);
324
+ console.log("");
325
+ console.log("Policy warnings:");
326
+ for (const v of violations) {
327
+ console.log(` - ${v.message}`);
328
+ }
329
+ console.log("");
330
+ }
331
+ else {
332
+ console.log(`Submitted: [${tag}] ${summary}`);
333
+ }
281
334
  console.log("Turn passed to: Ralph");
282
335
  console.log(`Round: ${round} -> ${nextRound}`);
283
336
  console.log(line());
@@ -507,7 +560,9 @@ function cmdHistory() {
507
560
  function cmdArchive(args) {
508
561
  (0, state_js_1.checkSession)();
509
562
  const name = args[0] || new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
510
- const archiveDir = path.join(process.cwd(), state_js_1.ARCHIVE_DIR);
563
+ // Place archive next to .dual-agent/ (in project root, not CWD)
564
+ const root = (0, state_js_1.findProjectRoot)() || process.cwd();
565
+ const archiveDir = path.join(root, state_js_1.ARCHIVE_DIR);
511
566
  const dest = path.join(archiveDir, name);
512
567
  fs.mkdirSync(dest, { recursive: true });
513
568
  fs.cpSync((0, state_js_1.stateDir)(), dest, { recursive: true });
@@ -938,7 +993,7 @@ end tell'`, { stdio: "pipe" });
938
993
  try {
939
994
  execSync("which tmux", { stdio: "pipe" });
940
995
  console.log("Launching with tmux...");
941
- const sessionName = "ralph-lisa";
996
+ const sessionName = generateSessionName(projectDir);
942
997
  execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`);
943
998
  execSync(`tmux new-session -d -s "${sessionName}" -n "Ralph" "bash -c '${ralphCmd}; exec bash'"`);
944
999
  execSync(`tmux split-window -h -t "${sessionName}" "bash -c '${lisaCmd}; exec bash'"`);
@@ -1040,7 +1095,7 @@ function cmdAuto(args) {
1040
1095
  cmdInit(task.split(" "));
1041
1096
  console.log("");
1042
1097
  }
1043
- const sessionName = "ralph-lisa-auto";
1098
+ const sessionName = generateSessionName(projectDir);
1044
1099
  const dir = (0, state_js_1.stateDir)(projectDir);
1045
1100
  fs.mkdirSync(dir, { recursive: true });
1046
1101
  // Archive pane logs from previous runs (for transcript preservation)
package/dist/policy.d.ts CHANGED
@@ -22,7 +22,10 @@ export declare function checkRalph(tag: string, content: string): PolicyViolatio
22
22
  */
23
23
  export declare function checkLisa(tag: string, content: string): PolicyViolation[];
24
24
  /**
25
- * Run policy checks and handle output/exit based on mode.
26
- * Returns true if submission should proceed, false if blocked.
25
+ * Run policy checks based on mode.
26
+ * Returns { proceed, violations } so callers can format output clearly (IMP-4).
27
27
  */
28
- export declare function runPolicyCheck(role: "ralph" | "lisa", tag: string, content: string): boolean;
28
+ export declare function runPolicyCheck(role: "ralph" | "lisa", tag: string, content: string): {
29
+ proceed: boolean;
30
+ violations: PolicyViolation[];
31
+ };
package/dist/policy.js CHANGED
@@ -87,26 +87,19 @@ function checkLisa(tag, content) {
87
87
  return violations;
88
88
  }
89
89
  /**
90
- * Run policy checks and handle output/exit based on mode.
91
- * Returns true if submission should proceed, false if blocked.
90
+ * Run policy checks based on mode.
91
+ * Returns { proceed, violations } so callers can format output clearly (IMP-4).
92
92
  */
93
93
  function runPolicyCheck(role, tag, content) {
94
94
  const mode = getPolicyMode();
95
95
  if (mode === "off")
96
- return true;
96
+ return { proceed: true, violations: [] };
97
97
  const violations = role === "ralph" ? checkRalph(tag, content) : checkLisa(tag, content);
98
98
  if (violations.length === 0)
99
- return true;
100
- console.error("");
101
- console.error("⚠️ Policy warnings:");
102
- for (const v of violations) {
103
- console.error(` - ${v.message}`);
104
- }
105
- console.error("");
99
+ return { proceed: true, violations: [] };
106
100
  if (mode === "block") {
107
- console.error("Policy mode is 'block'. Submission rejected.");
108
- return false;
101
+ return { proceed: false, violations };
109
102
  }
110
- // warn mode: print but continue
111
- return true;
103
+ // warn mode: proceed but pass violations to caller
104
+ return { proceed: true, violations };
112
105
  }
package/dist/state.d.ts CHANGED
@@ -5,7 +5,20 @@
5
5
  export declare const STATE_DIR = ".dual-agent";
6
6
  export declare const ARCHIVE_DIR = ".dual-agent-archive";
7
7
  export declare const VALID_TAGS = "PLAN|RESEARCH|CODE|FIX|PASS|NEEDS_WORK|CHALLENGE|DISCUSS|QUESTION|CONSENSUS";
8
+ export declare function findProjectRoot(startDir?: string): string | null;
9
+ /**
10
+ * Reset the cached project root. Used in tests.
11
+ */
12
+ export declare function resetProjectRootCache(): void;
13
+ /**
14
+ * Get the .dual-agent/ state directory path.
15
+ * When projectDir is explicitly given, uses that path directly.
16
+ * When omitted, searches upward from CWD to find .dual-agent/ (like git).
17
+ */
8
18
  export declare function stateDir(projectDir?: string): string;
19
+ /**
20
+ * Check that a session exists. Searches upward from CWD when no explicit dir given.
21
+ */
9
22
  export declare function checkSession(projectDir?: string): void;
10
23
  export declare function readFile(filePath: string): string;
11
24
  export declare function writeFile(filePath: string, content: string): void;
package/dist/state.js CHANGED
@@ -38,6 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.VALID_TAGS = exports.ARCHIVE_DIR = exports.STATE_DIR = void 0;
41
+ exports.findProjectRoot = findProjectRoot;
42
+ exports.resetProjectRootCache = resetProjectRootCache;
41
43
  exports.stateDir = stateDir;
42
44
  exports.checkSession = checkSession;
43
45
  exports.readFile = readFile;
@@ -61,11 +63,71 @@ exports.STATE_DIR = ".dual-agent";
61
63
  exports.ARCHIVE_DIR = ".dual-agent-archive";
62
64
  exports.VALID_TAGS = "PLAN|RESEARCH|CODE|FIX|PASS|NEEDS_WORK|CHALLENGE|DISCUSS|QUESTION|CONSENSUS";
63
65
  const TAG_RE = new RegExp(`^\\[(${exports.VALID_TAGS})\\]`);
64
- function stateDir(projectDir = process.cwd()) {
65
- return path.join(projectDir, exports.STATE_DIR);
66
+ /**
67
+ * Walk up from startDir to find the nearest directory containing .dual-agent/.
68
+ * Similar to how git finds .git/ from any subdirectory.
69
+ * Result is cached per process invocation for efficiency.
70
+ */
71
+ /**
72
+ * Cache keyed by resolved startDir to avoid returning wrong root
73
+ * when called with different directories in the same process.
74
+ */
75
+ let _cachedStartDir;
76
+ let _cachedProjectRoot;
77
+ function findProjectRoot(startDir = process.cwd()) {
78
+ const resolved = path.resolve(startDir);
79
+ // Cache hit: same startDir as last call
80
+ if (_cachedStartDir === resolved && _cachedProjectRoot !== undefined) {
81
+ if (_cachedProjectRoot === null)
82
+ return null;
83
+ // Validate cached root still exists
84
+ if (fs.existsSync(path.join(_cachedProjectRoot, exports.STATE_DIR))) {
85
+ return _cachedProjectRoot;
86
+ }
87
+ // Invalidate stale cache
88
+ _cachedStartDir = undefined;
89
+ _cachedProjectRoot = undefined;
90
+ }
91
+ let dir = resolved;
92
+ while (true) {
93
+ if (fs.existsSync(path.join(dir, exports.STATE_DIR))) {
94
+ _cachedStartDir = resolved;
95
+ _cachedProjectRoot = dir;
96
+ return dir;
97
+ }
98
+ const parent = path.dirname(dir);
99
+ if (parent === dir)
100
+ break; // reached filesystem root
101
+ dir = parent;
102
+ }
103
+ _cachedStartDir = resolved;
104
+ _cachedProjectRoot = null;
105
+ return null;
66
106
  }
67
- function checkSession(projectDir = process.cwd()) {
68
- const dir = stateDir(projectDir);
107
+ /**
108
+ * Reset the cached project root. Used in tests.
109
+ */
110
+ function resetProjectRootCache() {
111
+ _cachedStartDir = undefined;
112
+ _cachedProjectRoot = undefined;
113
+ }
114
+ /**
115
+ * Get the .dual-agent/ state directory path.
116
+ * When projectDir is explicitly given, uses that path directly.
117
+ * When omitted, searches upward from CWD to find .dual-agent/ (like git).
118
+ */
119
+ function stateDir(projectDir) {
120
+ if (projectDir !== undefined) {
121
+ return path.join(projectDir, exports.STATE_DIR);
122
+ }
123
+ const root = findProjectRoot();
124
+ return path.join(root || process.cwd(), exports.STATE_DIR);
125
+ }
126
+ /**
127
+ * Check that a session exists. Searches upward from CWD when no explicit dir given.
128
+ */
129
+ function checkSession(projectDir) {
130
+ const dir = projectDir !== undefined ? stateDir(projectDir) : stateDir();
69
131
  if (!fs.existsSync(dir)) {
70
132
  console.error('Error: Session not initialized. Run: ralph-lisa init "task description"');
71
133
  process.exit(1);
@@ -87,22 +149,22 @@ function appendFile(filePath, content) {
87
149
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
88
150
  fs.appendFileSync(filePath, content, "utf-8");
89
151
  }
90
- function getTurn(projectDir = process.cwd()) {
152
+ function getTurn(projectDir) {
91
153
  return readFile(path.join(stateDir(projectDir), "turn.txt")) || "ralph";
92
154
  }
93
- function setTurn(turn, projectDir = process.cwd()) {
155
+ function setTurn(turn, projectDir) {
94
156
  writeFile(path.join(stateDir(projectDir), "turn.txt"), turn);
95
157
  }
96
- function getRound(projectDir = process.cwd()) {
158
+ function getRound(projectDir) {
97
159
  return readFile(path.join(stateDir(projectDir), "round.txt")) || "?";
98
160
  }
99
- function setRound(round, projectDir = process.cwd()) {
161
+ function setRound(round, projectDir) {
100
162
  writeFile(path.join(stateDir(projectDir), "round.txt"), String(round));
101
163
  }
102
- function getStep(projectDir = process.cwd()) {
164
+ function getStep(projectDir) {
103
165
  return readFile(path.join(stateDir(projectDir), "step.txt")) || "?";
104
166
  }
105
- function setStep(step, projectDir = process.cwd()) {
167
+ function setStep(step, projectDir) {
106
168
  writeFile(path.join(stateDir(projectDir), "step.txt"), step);
107
169
  }
108
170
  function extractTag(content) {
@@ -124,7 +186,7 @@ function timeShort() {
124
186
  const pad = (n) => String(n).padStart(2, "0");
125
187
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
126
188
  }
127
- function appendHistory(role, content, projectDir = process.cwd()) {
189
+ function appendHistory(role, content, projectDir) {
128
190
  const tag = extractTag(content);
129
191
  const summary = extractSummary(content);
130
192
  const round = getRound(projectDir);
@@ -142,7 +204,7 @@ ${content}
142
204
  `;
143
205
  appendFile(path.join(stateDir(projectDir), "history.md"), entry);
144
206
  }
145
- function updateLastAction(role, content, projectDir = process.cwd()) {
207
+ function updateLastAction(role, content, projectDir) {
146
208
  const tag = extractTag(content);
147
209
  const summary = extractSummary(content);
148
210
  const ts = timeShort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-lisa-loop",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Turn-based dual-agent collaboration: Ralph codes, Lisa reviews, consensus required.",
5
5
  "bin": {
6
6
  "ralph-lisa": "dist/cli.js"
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "dist/",
16
+ "!dist/test/",
16
17
  "templates/"
17
18
  ],
18
19
  "keywords": [