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.
Files changed (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. 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
+ }