loreli 0.0.0 → 2.0.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/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CliAgent } from '../cli.js';
|
|
5
|
+
import { resolve, env } from '../models.js';
|
|
6
|
+
import { mcpJson } from 'loreli/workspace';
|
|
7
|
+
import { logger } from 'loreli/log';
|
|
8
|
+
|
|
9
|
+
const log = logger('claude');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Interval for polling pane content during readiness detection.
|
|
13
|
+
* @type {number}
|
|
14
|
+
*/
|
|
15
|
+
const READY_POLL = 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum time to wait for the Claude CLI to become ready.
|
|
19
|
+
* @type {number}
|
|
20
|
+
*/
|
|
21
|
+
const READY_TIMEOUT = 60000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code CLI backend. Interactive: stays running in a tmux pane.
|
|
25
|
+
*
|
|
26
|
+
* Uses `--dangerously-skip-permissions` to bypass ALL startup dialogs
|
|
27
|
+
* (workspace trust, permission bypass, "continue anyway"). This flag
|
|
28
|
+
* eliminates the need for fragile interactive dialog navigation.
|
|
29
|
+
*
|
|
30
|
+
* Uses `--mcp-config` to load the scaffolded `.mcp.json` that points
|
|
31
|
+
* back to the Loreli MCP server. The agent discovers Loreli tools
|
|
32
|
+
* automatically on startup.
|
|
33
|
+
*
|
|
34
|
+
* After the CLI is ready (welcome banner visible), prompts are
|
|
35
|
+
* delivered via file-based send (multi-line) or tmux send-keys
|
|
36
|
+
* (single-line).
|
|
37
|
+
*
|
|
38
|
+
* @extends CliAgent
|
|
39
|
+
*/
|
|
40
|
+
export class ClaudeBackend extends CliAgent {
|
|
41
|
+
/**
|
|
42
|
+
* Regex patterns that identify known Claude CLI blocking dialogs.
|
|
43
|
+
*
|
|
44
|
+
* @type {Array<{pattern: RegExp, category: string, reasoning: string, remedy?: string[]}>}
|
|
45
|
+
*/
|
|
46
|
+
static patterns = [
|
|
47
|
+
{
|
|
48
|
+
pattern: /yes,\s*i\s+accept/i,
|
|
49
|
+
category: 'option_dialog',
|
|
50
|
+
reasoning: 'Claude CLI showing bypass-permissions dialog requiring selection',
|
|
51
|
+
remedy: ['Enter']
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
pattern: /do you want to proceed\?[\s\S]*yes,\s*and don't ask again(?:\s+for loreli\s*-\s*[a-z-]+\s*commands)?/i,
|
|
55
|
+
category: 'option_dialog',
|
|
56
|
+
reasoning: 'Claude CLI asking MCP tool approval for a Loreli command',
|
|
57
|
+
remedy: ['2', 'Enter']
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect known Claude CLI blocking patterns in pane output.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} output - Captured pane content.
|
|
65
|
+
* @returns {{category: string, reasoning: string, remedy?: string[]}|null} Diagnosis, or null if unrecognized.
|
|
66
|
+
*/
|
|
67
|
+
static diagnose(output) {
|
|
68
|
+
if (!output) return null;
|
|
69
|
+
for (const { pattern, category, reasoning, remedy } of ClaudeBackend.patterns) {
|
|
70
|
+
if (pattern.test(output)) return { category, reasoning, ...(remedy && { remedy }) };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Return the scaffold descriptor for Claude Code workspaces.
|
|
77
|
+
*
|
|
78
|
+
* Claude uses `${GITHUB_TOKEN}` interpolation in `.mcp.json` —
|
|
79
|
+
* Claude Code resolves this from its parent process environment.
|
|
80
|
+
* Hooks use PreToolUse to delegate to deny.sh and protect.sh.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} [context] - Agent context for env/token injection.
|
|
83
|
+
* @returns {object} Scaffold descriptor.
|
|
84
|
+
*/
|
|
85
|
+
static scaffold(context) {
|
|
86
|
+
const denied = context?.denied ?? [];
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
configs: [
|
|
90
|
+
{
|
|
91
|
+
path: '.mcp.json',
|
|
92
|
+
content: mcpJson(context, context ? { tokenRef: '${GITHUB_TOKEN}' } : {}),
|
|
93
|
+
marker: 'loreli',
|
|
94
|
+
format: 'json'
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
hooks: [
|
|
98
|
+
{
|
|
99
|
+
path: '.claude/settings.local.json',
|
|
100
|
+
key: 'PreToolUse',
|
|
101
|
+
entry: {
|
|
102
|
+
matcher: 'Bash',
|
|
103
|
+
hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/deny.sh"' }]
|
|
104
|
+
},
|
|
105
|
+
marker: 'deny.sh'
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
path: '.claude/settings.local.json',
|
|
109
|
+
key: 'PreToolUse',
|
|
110
|
+
entry: {
|
|
111
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
112
|
+
hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/protect.sh"' }]
|
|
113
|
+
},
|
|
114
|
+
marker: 'protect.sh'
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
files: []
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One-shot LLM call via `claude -p` (print mode).
|
|
122
|
+
*
|
|
123
|
+
* No tmux, no workspace, no identity — just a subprocess that
|
|
124
|
+
* prints the response and exits. Uses the same model resolution
|
|
125
|
+
* and env var collection as interactive agents.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} prompt - Text prompt to send.
|
|
128
|
+
* @param {object} [opts] - Options.
|
|
129
|
+
* @param {string} [opts.model='fast'] - Model alias or exact string.
|
|
130
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
131
|
+
* @param {number} [opts.timeout=60000] - Max execution time in ms.
|
|
132
|
+
* @returns {Promise<string>} LLM response text.
|
|
133
|
+
*/
|
|
134
|
+
static async oneshot(prompt, { model, config, timeout, discovered } = {}) {
|
|
135
|
+
const resolved = resolve(model ?? 'fast', 'claude', 'anthropic', config, discovered);
|
|
136
|
+
const vars = env('claude', config) ?? {};
|
|
137
|
+
return CliAgent._exec('claude', [
|
|
138
|
+
'-p', '--model', resolved, '--output-format', 'text'
|
|
139
|
+
], vars, timeout, prompt);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {object} opts
|
|
144
|
+
* @param {object} opts.identity - Agent identity.
|
|
145
|
+
* @param {string} opts.role - Agent role.
|
|
146
|
+
* @param {string} opts.cwd - Working directory.
|
|
147
|
+
* @param {string} [opts.model='balanced'] - Model alias or exact string.
|
|
148
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
149
|
+
* @param {string} [opts.session='loreli'] - Tmux session name.
|
|
150
|
+
* @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
|
|
151
|
+
*/
|
|
152
|
+
constructor(opts) {
|
|
153
|
+
const model = resolve(opts.model ?? 'balanced', 'claude', 'anthropic', opts.config, opts.discovered);
|
|
154
|
+
|
|
155
|
+
const denied = opts.config?.get?.('agents.disallowedTools') ?? [];
|
|
156
|
+
|
|
157
|
+
const mode = opts.role === 'planner' ? 'plan' : undefined;
|
|
158
|
+
|
|
159
|
+
super({
|
|
160
|
+
...opts,
|
|
161
|
+
command: buildCommand(model, opts.cwd, denied, mode)
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** @type {string} Resolved model identifier. */
|
|
165
|
+
this.model = model;
|
|
166
|
+
|
|
167
|
+
/** @type {object|undefined} Backend-specific environment variables. */
|
|
168
|
+
this._env = env('claude', opts.config) ?? {};
|
|
169
|
+
|
|
170
|
+
if (opts.context?.token) this._env.GITHUB_TOKEN = opts.context.token;
|
|
171
|
+
|
|
172
|
+
/** @type {number} Max ms to wait for CLI readiness. */
|
|
173
|
+
this.readyTimeout = opts.readyTimeout ?? READY_TIMEOUT;
|
|
174
|
+
|
|
175
|
+
/** @type {boolean} Whether MCP readiness (Phase 2) was confirmed. */
|
|
176
|
+
this._mcpReady = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn the Claude CLI and wait for readiness.
|
|
181
|
+
*
|
|
182
|
+
* Builds an inline shell command with env exports and passes it
|
|
183
|
+
* directly to tmux — no launcher scripts on disk. The user's
|
|
184
|
+
* `.zshrc` is bypassed because tmux runs the command via `/bin/sh`.
|
|
185
|
+
*
|
|
186
|
+
* All trust and permission dialogs are skipped by
|
|
187
|
+
* `--dangerously-skip-permissions` in the command — no interactive
|
|
188
|
+
* navigation is needed.
|
|
189
|
+
*
|
|
190
|
+
* @returns {Promise<void>}
|
|
191
|
+
*/
|
|
192
|
+
async spawn() {
|
|
193
|
+
const cmd = this._shell(`exec ${this.command}`, this._env);
|
|
194
|
+
this.paneId = await this._launch(cmd);
|
|
195
|
+
|
|
196
|
+
log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
|
|
197
|
+
this.transition('spawned');
|
|
198
|
+
|
|
199
|
+
await this._navigate();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Wait for Claude CLI to finish initializing, including MCP tool discovery.
|
|
204
|
+
*
|
|
205
|
+
* Two-phase readiness check:
|
|
206
|
+
* 1. Navigate the bypass-permissions dialog and wait for the welcome banner
|
|
207
|
+
* 2. Wait for the MCP server to write a `.loreli-mcp-ready` marker file
|
|
208
|
+
*
|
|
209
|
+
* Phase 2 prevents a race condition where the prompt is sent before
|
|
210
|
+
* Claude Code finishes its MCP handshake, causing the agent to work
|
|
211
|
+
* without access to MCP tools.
|
|
212
|
+
*
|
|
213
|
+
* @returns {Promise<void>}
|
|
214
|
+
*/
|
|
215
|
+
async _navigate() {
|
|
216
|
+
if (this.readyTimeout <= 0) return;
|
|
217
|
+
|
|
218
|
+
const deadline = Date.now() + this.readyTimeout;
|
|
219
|
+
const name = this.identity?.name ?? '?';
|
|
220
|
+
let accepted = false;
|
|
221
|
+
let banner = false;
|
|
222
|
+
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
// Guard against pane death — if Claude Code crashes during
|
|
225
|
+
// startup, the pane disappears and capture throws. Fall through
|
|
226
|
+
// to the timeout warning rather than crashing the spawn pipeline.
|
|
227
|
+
let output;
|
|
228
|
+
try {
|
|
229
|
+
output = await this.tmux.capture(this.paneId);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new Error(`${name} pane died during readiness check`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Detect the bypass-permissions TUI confirmation dialog.
|
|
235
|
+
// The cursor defaults to "No, exit" — we need Down then Enter
|
|
236
|
+
// to select "Yes, I accept".
|
|
237
|
+
if (!accepted && output.includes('Yes, I accept')) {
|
|
238
|
+
log.info(`${name} accepting bypass-permissions dialog`);
|
|
239
|
+
await this.tmux.keys(this.paneId, 'Down');
|
|
240
|
+
await new Promise(function pause(r) { setTimeout(r, 300); });
|
|
241
|
+
await this.tmux.keys(this.paneId, 'Enter');
|
|
242
|
+
accepted = true;
|
|
243
|
+
await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Phase 1: Welcome banner signals Claude CLI is initialized
|
|
248
|
+
if (!banner && output.includes('Claude Code')) {
|
|
249
|
+
if (accepted || !output.includes('Bypass Permissions')) {
|
|
250
|
+
log.info(`${name} ready — Claude Code welcome banner detected`);
|
|
251
|
+
banner = true;
|
|
252
|
+
// Fall through to Phase 2 — don't return yet
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Phase 2: Wait for MCP tools to be discoverable. The loreli MCP
|
|
257
|
+
// server writes this file in its oninitialized callback, which fires
|
|
258
|
+
// after the client (Claude Code) completes the MCP handshake.
|
|
259
|
+
if (banner) {
|
|
260
|
+
const ready = join(this.cwd, '.loreli', 'mcp-ready');
|
|
261
|
+
if (existsSync(ready)) {
|
|
262
|
+
log.info(`${name} MCP tools ready`);
|
|
263
|
+
this._mcpReady = true;
|
|
264
|
+
try { await unlink(ready); } catch { /* already cleaned */ }
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
log.warn(`${name} readiness timeout after ${this.readyTimeout}ms — proceeding anyway`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Wait for MCP readiness when `_navigate()` Phase 2 timed out.
|
|
277
|
+
*
|
|
278
|
+
* Polls for the `.loreli/mcp-ready` marker file that the MCP server
|
|
279
|
+
* writes after the handshake completes. Without this, prompts sent
|
|
280
|
+
* immediately after a readiness timeout are dropped because Claude
|
|
281
|
+
* Code is still in its MCP initialization state.
|
|
282
|
+
*
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
async _awaitMcp() {
|
|
286
|
+
if (this._mcpReady) return;
|
|
287
|
+
|
|
288
|
+
const ready = join(this.cwd, '.loreli', 'mcp-ready');
|
|
289
|
+
const deadline = Date.now() + 30000;
|
|
290
|
+
const name = this.identity?.name ?? '?';
|
|
291
|
+
|
|
292
|
+
while (Date.now() < deadline) {
|
|
293
|
+
if (existsSync(ready)) {
|
|
294
|
+
log.info(`${name} MCP tools ready (late)`);
|
|
295
|
+
this._mcpReady = true;
|
|
296
|
+
try { await unlink(ready); } catch { /* already cleaned */ }
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
log.warn(`${name} MCP readiness not confirmed — sending prompt anyway`);
|
|
303
|
+
this._mcpReady = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Send a prompt to Claude's interactive session.
|
|
308
|
+
*
|
|
309
|
+
* Multi-line prompts are written to a Markdown file and a single-line
|
|
310
|
+
* reference is sent via tmux send-keys. This avoids newline bytes
|
|
311
|
+
* being interpreted as Enter and fragmenting the prompt.
|
|
312
|
+
*
|
|
313
|
+
* Single-line messages are sent directly via send-keys.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} message - The prompt text.
|
|
316
|
+
* @returns {Promise<void>}
|
|
317
|
+
*/
|
|
318
|
+
async send(message) {
|
|
319
|
+
if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
|
|
320
|
+
if (this.canTransition('working')) this.transition('working');
|
|
321
|
+
|
|
322
|
+
await this._awaitMcp();
|
|
323
|
+
|
|
324
|
+
if (!message.includes('\n')) {
|
|
325
|
+
await this.tmux.send(this.paneId, message);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const dir = join(this.cwd, '.loreli');
|
|
330
|
+
await mkdir(dir, { recursive: true });
|
|
331
|
+
const file = join(dir, `task-${Date.now()}.md`);
|
|
332
|
+
await writeFile(file, message, 'utf8');
|
|
333
|
+
log.info(`${this.identity?.name ?? '?'} prompt written to ${file}`);
|
|
334
|
+
|
|
335
|
+
await this.tmux.send(this.paneId,
|
|
336
|
+
`Read the complete task instructions from ${file} and execute them. Follow every instruction precisely.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build the claude CLI command string.
|
|
343
|
+
*
|
|
344
|
+
* Flags:
|
|
345
|
+
* - `--dangerously-skip-permissions`: bypass all trust/permission dialogs (action/reviewer)
|
|
346
|
+
* - `--permission-mode plan`: read-only plan mode (planner)
|
|
347
|
+
* - `--mcp-config .mcp.json`: load scaffolded Loreli MCP config
|
|
348
|
+
* - `--model`: resolved model identifier
|
|
349
|
+
*
|
|
350
|
+
* No `--prompt` flag — prompts are delivered via `send()` after spawn
|
|
351
|
+
* to avoid shell injection risks from unescaped special characters.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} model - Resolved model identifier.
|
|
354
|
+
* @param {string} [cwd] - Working directory (for --mcp-config path).
|
|
355
|
+
* @param {string[]} [denied=[]] - Commands to block via --disallowedTools.
|
|
356
|
+
* @param {string} [mode] - Permission mode ('plan' for read-only).
|
|
357
|
+
* @returns {string} CLI command string.
|
|
358
|
+
*/
|
|
359
|
+
function buildCommand(model, cwd, denied = [], mode) {
|
|
360
|
+
const parts = ['claude'];
|
|
361
|
+
|
|
362
|
+
if (mode === 'plan') {
|
|
363
|
+
parts.push('--permission-mode plan');
|
|
364
|
+
} else {
|
|
365
|
+
parts.push('--dangerously-skip-permissions');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
parts.push(`--model ${model}`);
|
|
369
|
+
|
|
370
|
+
// Defense-in-depth: --disallowedTools is a hard guardrail that cannot
|
|
371
|
+
// be bypassed by the agent. The hooks file (.claude/settings.local.json)
|
|
372
|
+
// provides richer feedback, but this flag is the safety net.
|
|
373
|
+
if (denied.length) {
|
|
374
|
+
const globs = denied.map(function toGlob(cmd) {
|
|
375
|
+
return `"Bash(${cmd}:*)"`;
|
|
376
|
+
});
|
|
377
|
+
parts.push(`--disallowedTools ${globs.join(' ')}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Only add --mcp-config when the scaffolded file exists. A missing
|
|
381
|
+
// file causes Claude CLI to hang indefinitely with zero output,
|
|
382
|
+
// blocking _navigate() for the full READY_TIMEOUT.
|
|
383
|
+
const mcpPath = cwd ? join(cwd, '.mcp.json') : null;
|
|
384
|
+
if (mcpPath && existsSync(mcpPath)) parts.push(`--mcp-config ${mcpPath}`);
|
|
385
|
+
|
|
386
|
+
return parts.join(' ');
|
|
387
|
+
}
|