loreli 0.0.0 → 1.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 (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,452 @@
1
+ import { execFile as execFileCb, execFileSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFile = promisify(execFileCb);
5
+
6
+ /**
7
+ * Clean, modern, ESM Node.js wrapper for tmux with pane-level control.
8
+ *
9
+ * Every method maps ~1:1 to a tmux CLI command using `node:child_process`.
10
+ * Zero npm dependencies.
11
+ *
12
+ * Sessions created via {@link Tmux#create} are tracked in an in-memory
13
+ * set so they can be batch-killed via {@link Tmux#cleanup}. For crash
14
+ * recovery where in-memory state is lost, {@link Tmux#prune} queries
15
+ * tmux directly by session-name prefix.
16
+ *
17
+ * @example
18
+ * ```js
19
+ * import { Tmux } from 'loreli/tmux';
20
+ *
21
+ * const tmux = new Tmux();
22
+ *
23
+ * // First agent IS the session's initial window — no garbage default window.
24
+ * const paneId = await tmux.create('my-session', { cwd: '/tmp', command: './run.sh' });
25
+ * await tmux.send(paneId, 'echo hello');
26
+ * const output = await tmux.capture(paneId);
27
+ *
28
+ * // Subsequent agents join as additional windows.
29
+ * const pane2 = await tmux.window('my-session', { cwd: '/tmp', command: './run2.sh' });
30
+ * ```
31
+ */
32
+ export class Tmux {
33
+ /** @type {Set<string>} Sessions created by this instance. */
34
+ #sessions = new Set();
35
+
36
+ /**
37
+ * Check whether tmux is available on PATH without throwing.
38
+ *
39
+ * @returns {boolean} True if the tmux binary is reachable.
40
+ */
41
+ static available() {
42
+ try {
43
+ execFileSync('which', ['tmux'], { stdio: 'ignore' });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Execute a tmux command and return trimmed stdout.
52
+ *
53
+ * @param {string[]} args - Arguments to pass to the tmux binary.
54
+ * @returns {Promise<string>} Trimmed stdout from the command.
55
+ * @throws {Error} When the tmux command exits with non-zero status.
56
+ */
57
+ async #exec(args) {
58
+ try {
59
+ const { stdout } = await execFile('tmux', args);
60
+ return stdout.trimEnd();
61
+ } catch (err) {
62
+ throw new Error(err.stderr?.trim() || err.message);
63
+ }
64
+ }
65
+
66
+ // ── Session Management ──────────────────────────────────
67
+
68
+ /**
69
+ * Create a new detached tmux session.
70
+ *
71
+ * When `opts.command` is provided, it becomes the initial window's
72
+ * process — no empty default window is created. This lets tmux
73
+ * auto-destroy the session when all windows die, preventing orphans.
74
+ *
75
+ * When called without a command, a bare session with an empty default
76
+ * window is created (useful for test sessions with specific dimensions).
77
+ *
78
+ * @param {string} name - The session name.
79
+ * @param {object} [opts] - Session options.
80
+ * @param {number} [opts.width] - Initial window width (tmux -x flag).
81
+ * @param {number} [opts.height] - Initial window height (tmux -y flag).
82
+ * @param {string} [opts.cwd] - Working directory for the initial window.
83
+ * @param {string} [opts.command] - Command for the initial window.
84
+ * @returns {Promise<string|void>} Pane ID when command is provided, void otherwise.
85
+ * @throws {Error} When a session with the same name already exists.
86
+ */
87
+ async create(name, opts = {}) {
88
+ if (await this.has(name)) {
89
+ throw new Error(`Session "${name}" already exists`);
90
+ }
91
+
92
+ const args = ['new-session', '-d', '-s', name];
93
+
94
+ if (opts.width) args.push('-x', String(opts.width));
95
+ if (opts.height) args.push('-y', String(opts.height));
96
+
97
+ if (opts.command) {
98
+ args.push('-P', '-F', '#{pane_id}');
99
+ if (opts.cwd) args.push('-c', opts.cwd);
100
+ args.push(opts.command);
101
+ const paneId = await this.#exec(args);
102
+ this.#sessions.add(name);
103
+ return paneId;
104
+ }
105
+
106
+ if (opts.cwd) args.push('-c', opts.cwd);
107
+ await this.#exec(args);
108
+ this.#sessions.add(name);
109
+ }
110
+
111
+ /**
112
+ * Kill (destroy) a tmux session.
113
+ *
114
+ * @param {string} name - The session name to kill.
115
+ * @returns {Promise<void>}
116
+ * @throws {Error} When the session does not exist.
117
+ */
118
+ async kill(name) {
119
+ await this.#exec(['kill-session', '-t', name]);
120
+ this.#sessions.delete(name);
121
+ }
122
+
123
+ /**
124
+ * List all active tmux session names.
125
+ *
126
+ * @returns {Promise<string[]>} Array of session name strings.
127
+ */
128
+ async list() {
129
+ try {
130
+ const output = await this.#exec(['list-sessions', '-F', '#{session_name}']);
131
+ if (!output) return [];
132
+ return output.split('\n').filter(Boolean);
133
+ } catch {
134
+ // tmux returns error when no server is running
135
+ return [];
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Check whether a session with the given name exists.
141
+ *
142
+ * @param {string} name - The session name to check.
143
+ * @returns {Promise<boolean>} True if the session exists.
144
+ */
145
+ async has(name) {
146
+ try {
147
+ await this.#exec(['has-session', '-t', name]);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Return session names created by this Tmux instance.
156
+ *
157
+ * @returns {string[]} Array of tracked session names.
158
+ */
159
+ owned() {
160
+ return [...this.#sessions];
161
+ }
162
+
163
+ /**
164
+ * Kill all sessions created by this instance. Best-effort — errors
165
+ * on individual sessions are swallowed so one stuck session does
166
+ * not prevent others from being cleaned. Clears the tracking set.
167
+ *
168
+ * @returns {Promise<void>}
169
+ */
170
+ async cleanup() {
171
+ for (const name of this.#sessions) {
172
+ try { await this.kill(name); } catch { /* session may already be gone */ }
173
+ }
174
+ this.#sessions.clear();
175
+ }
176
+
177
+ /**
178
+ * Kill all tmux sessions whose name starts with the given prefix.
179
+ * Queries tmux directly — does not rely on in-memory tracking.
180
+ *
181
+ * This is the crash-recovery / manual-reset path. It is destructive:
182
+ * any matching session is killed regardless of whether its agents are
183
+ * still doing useful work. For graceful lifecycle cleanup, prefer
184
+ * {@link Tmux#cleanup} which targets only sessions this instance created.
185
+ *
186
+ * @param {string} [prefix='loreli'] - Session name prefix to match.
187
+ * @returns {Promise<string[]>} Names of sessions that were killed.
188
+ */
189
+ async prune(prefix = 'loreli') {
190
+ const all = await this.list();
191
+ const targets = all.filter(function matches(n) { return n.startsWith(prefix); });
192
+ const killed = [];
193
+
194
+ for (const name of targets) {
195
+ try {
196
+ await this.kill(name);
197
+ killed.push(name);
198
+ } catch { /* session may have died between list and kill */ }
199
+ }
200
+
201
+ return killed;
202
+ }
203
+
204
+ // ── Stale Session Detection ─────────────────────────────
205
+
206
+ /**
207
+ * Check whether a session exists but was not created by this instance.
208
+ *
209
+ * A session is stale when it survives from a previous MCP server
210
+ * process. The current Tmux instance has no record of it because
211
+ * in-memory tracking is per-process. Stale sessions contain agent
212
+ * panes running with outdated environment variables (API keys,
213
+ * budgets, tokens) — spawning new agents into them inherits the
214
+ * old environment from the tmux server.
215
+ *
216
+ * @param {string} name - Session name to check.
217
+ * @returns {Promise<boolean>} True when the session exists and is unowned.
218
+ */
219
+ async stale(name) {
220
+ if (this.#sessions.has(name)) return false;
221
+ return this.has(name);
222
+ }
223
+
224
+ /**
225
+ * Destroy a stale session and report what was killed.
226
+ *
227
+ * Only acts on sessions not owned by this instance. Owned sessions
228
+ * are left untouched — this prevents accidental self-destruction
229
+ * during a start re-run within the same process.
230
+ *
231
+ * @param {string} name - Session name to reap.
232
+ * @returns {Promise<{killed: boolean, panes: number}>} Whether the
233
+ * session was destroyed and how many panes it contained.
234
+ */
235
+ async reap(name) {
236
+ if (this.#sessions.has(name)) return { killed: false, panes: 0 };
237
+ if (!await this.has(name)) return { killed: false, panes: 0 };
238
+
239
+ let paneCount = 0;
240
+ try {
241
+ // -s lists panes across ALL windows in the session, not just the active one
242
+ const fmt = '#{pane_id}';
243
+ const output = await this.#exec(['list-panes', '-s', '-t', name, '-F', fmt]);
244
+ paneCount = output.split('\n').filter(Boolean).length;
245
+ } catch { /* session may be in a bad state — kill it anyway */ }
246
+
247
+ await this.kill(name);
248
+ return { killed: true, panes: paneCount };
249
+ }
250
+
251
+ // ── Pane Management ─────────────────────────────────────
252
+
253
+ /**
254
+ * Create a new window in the session and return its pane ID.
255
+ *
256
+ * Each agent gets its own full-size window instead of splitting an
257
+ * existing one. This avoids the `no space for new pane` error that
258
+ * occurs when too many agents share a single window via split-window.
259
+ *
260
+ * When `opts.command` is provided, it becomes the window's shell
261
+ * command — the process runs directly without an intermediate shell.
262
+ * This avoids issues with `.zshrc` prompts or other shell
263
+ * initialization that can intercept the first send-keys input.
264
+ *
265
+ * @param {string} session - The target session name.
266
+ * @param {object} [opts] - Window options.
267
+ * @param {string} [opts.cwd] - Working directory for the new window.
268
+ * @param {string} [opts.command] - Shell command to run directly.
269
+ * @returns {Promise<string>} The new pane ID (e.g. "%3").
270
+ */
271
+ async window(session, opts = {}) {
272
+ const args = ['new-window', '-d', '-P', '-F', '#{pane_id}', '-t', session];
273
+
274
+ if (opts.cwd) args.push('-c', opts.cwd);
275
+ if (opts.command) args.push(opts.command);
276
+
277
+ return this.#exec(args);
278
+ }
279
+
280
+ /**
281
+ * Split the current window into a new pane.
282
+ *
283
+ * @param {string} session - The target session name.
284
+ * @param {object} [opts] - Split options.
285
+ * @param {string} [opts.cwd] - Working directory for the new pane.
286
+ * @param {boolean} [opts.vertical] - Split vertically instead of horizontally.
287
+ * @returns {Promise<string>} The new pane ID (e.g. "%3").
288
+ */
289
+ async split(session, opts = {}) {
290
+ const args = ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', session];
291
+
292
+ if (opts.vertical) args.push('-h');
293
+ if (opts.cwd) args.push('-c', opts.cwd);
294
+
295
+ return this.#exec(args);
296
+ }
297
+
298
+ /**
299
+ * Send text to a pane followed by Enter.
300
+ *
301
+ * Text and Enter are sent as separate operations with a brief delay.
302
+ * TUI applications (Claude Code, Cursor Agent) run in raw TTY mode
303
+ * and process keystrokes via an async event loop. When send-keys
304
+ * delivers text + Enter in a single call, both arrive in one PTY
305
+ * read buffer. The TUI's event loop processes them in a single tick,
306
+ * causing Enter to be consumed by the text rendering pipeline instead
307
+ * of triggering "submit". Splitting into two calls guarantees Enter
308
+ * arrives in a separate stdin data event.
309
+ *
310
+ * @param {string} target - Pane ID or session:window.pane target.
311
+ * @param {string} text - The text to send.
312
+ * @returns {Promise<void>}
313
+ */
314
+ async send(target, text) {
315
+ await this.#exec(['send-keys', '-t', target, text]);
316
+ await new Promise(function settle(r) { setTimeout(r, 200); });
317
+ await this.#exec(['send-keys', '-t', target, 'Enter']);
318
+ }
319
+
320
+ /**
321
+ * Send raw key sequences to a pane without appending Enter.
322
+ *
323
+ * Useful for TUI navigation (arrow keys, Escape, Tab) where an
324
+ * automatic Enter would confirm the wrong selection. Each argument
325
+ * is a tmux key name: `Down`, `Up`, `Enter`, `Escape`, etc.
326
+ *
327
+ * @param {string} target - Pane ID or session:window.pane target.
328
+ * @param {...string} keys - One or more tmux key names to send.
329
+ * @returns {Promise<void>}
330
+ */
331
+ async keys(target, ...keys) {
332
+ await this.#exec(['send-keys', '-t', target, ...keys]);
333
+ }
334
+
335
+ /**
336
+ * Capture the visible content of a pane.
337
+ *
338
+ * Uses -J to join wrapped lines for clean output.
339
+ *
340
+ * @param {string} target - Pane ID or session:window.pane target.
341
+ * @param {number} [lines=500] - Number of history lines to capture.
342
+ * @returns {Promise<string>} The captured pane content.
343
+ */
344
+ async capture(target, lines = 500) {
345
+ return this.#exec([
346
+ 'capture-pane', '-p', '-J', '-t', target, '-S', `-${lines}`
347
+ ]);
348
+ }
349
+
350
+ /**
351
+ * List panes in a session with metadata.
352
+ *
353
+ * @param {string} session - The session name.
354
+ * @returns {Promise<Array<{id: string, pid: string, active: boolean, title: string}>>}
355
+ */
356
+ async panes(session) {
357
+ const fmt = '#{pane_id}:#{pane_pid}:#{pane_active}:#{pane_title}';
358
+ const output = await this.#exec(['list-panes', '-t', session, '-F', fmt]);
359
+
360
+ return output.split('\n').filter(Boolean).map(function parsePane(line) {
361
+ const [id, pid, active, title] = line.split(':');
362
+ return { id, pid, active: active === '1', title: title ?? '' };
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Check whether a pane's process is still running.
368
+ *
369
+ * Uses list-panes with a filter to reliably detect if the specific
370
+ * pane exists and is not dead. display-message can silently fall back
371
+ * to the active pane when the target no longer exists.
372
+ *
373
+ * @param {string} target - Pane ID.
374
+ * @returns {Promise<boolean>} True if the pane is alive.
375
+ */
376
+ async alive(target) {
377
+ try {
378
+ const output = await this.#exec([
379
+ 'list-panes', '-a', '-F', '#{pane_id}:#{pane_dead}',
380
+ '-f', `#{==:#{pane_id},${target}}`
381
+ ]);
382
+
383
+ if (!output) return false;
384
+ const [, dead] = output.split(':');
385
+ return dead !== '1';
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Query the current foreground command running in a pane.
393
+ *
394
+ * Uses `pane_current_command` which reports the process name
395
+ * occupying the pane (e.g. 'zsh', 'claude', 'codex').
396
+ *
397
+ * @param {string} target - Pane ID.
398
+ * @returns {Promise<string>} Process name, or empty string if unknown.
399
+ */
400
+ async command(target) {
401
+ try {
402
+ return await this.#exec([
403
+ 'display-message', '-t', target, '-p', '#{pane_current_command}'
404
+ ]);
405
+ } catch {
406
+ return '';
407
+ }
408
+ }
409
+
410
+ /**
411
+ * List all panes across all windows in a session.
412
+ *
413
+ * Unlike {@link Tmux#panes} which targets the current window,
414
+ * this uses `-s` to enumerate every pane in every window of the
415
+ * session. Essential for garbage collection and session-wide
416
+ * orphan detection.
417
+ *
418
+ * @param {string} session - The session name.
419
+ * @returns {Promise<Array<{id: string, pid: string, dead: boolean, title: string}>>}
420
+ */
421
+ async allPanes(session) {
422
+ const fmt = '#{pane_id}:#{pane_pid}:#{pane_dead}:#{pane_title}';
423
+ const output = await this.#exec(['list-panes', '-s', '-t', session, '-F', fmt]);
424
+
425
+ return output.split('\n').filter(Boolean).map(function parsePane(line) {
426
+ const [id, pid, dead, title] = line.split(':');
427
+ return { id, pid, dead: dead === '1', title: title ?? '' };
428
+ });
429
+ }
430
+
431
+ /**
432
+ * Kill a specific pane.
433
+ *
434
+ * @param {string} target - Pane ID to kill.
435
+ * @returns {Promise<void>}
436
+ */
437
+ async killPane(target) {
438
+ await this.#exec(['kill-pane', '-t', target]);
439
+ }
440
+
441
+ /**
442
+ * Set a pane option.
443
+ *
444
+ * @param {string} target - Pane ID.
445
+ * @param {string} option - The tmux option name (e.g. 'remain-on-exit').
446
+ * @param {string} value - The option value (e.g. 'on').
447
+ * @returns {Promise<void>}
448
+ */
449
+ async set(target, option, value) {
450
+ await this.#exec(['set-option', '-p', '-t', target, option, value]);
451
+ }
452
+ }