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,215 @@
1
+ import { mkdir, writeFile, readFile, readdir, rename, rm, unlink, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ /**
6
+ * Persistent session storage for detached agents.
7
+ *
8
+ * Writes session and agent state to ~/.loreli/ (or LORELI_HOME)
9
+ * so agents survive orchestrator shutdown and can be reconnected.
10
+ *
11
+ * Directory layout:
12
+ * ```
13
+ * ~/.loreli/sessions/<id>/
14
+ * config.json
15
+ * agents/
16
+ * optimus-0.json
17
+ * megatron-0.json
18
+ * registry.json
19
+ * ```
20
+ */
21
+ export class Storage {
22
+ /**
23
+ * @param {object} [opts]
24
+ * @param {string} [opts.home] - Override the base directory.
25
+ */
26
+ constructor(opts = {}) {
27
+ /** @type {string} Base storage directory. */
28
+ this.home = opts.home ?? process.env.LORELI_HOME ?? join(homedir(), '.loreli');
29
+ }
30
+
31
+ /**
32
+ * Initialize a session directory.
33
+ *
34
+ * @param {string} id - Session ID.
35
+ * @param {object} [config] - Session config to persist.
36
+ * @returns {Promise<string>} The session ID.
37
+ */
38
+ async init(id, config = {}) {
39
+ const dir = this.sessionDir(id);
40
+ await mkdir(join(dir, 'agents'), { recursive: true });
41
+ await mkdir(join(dir, 'logs'), { recursive: true });
42
+
43
+ const safe = { ...config };
44
+ if (safe.github) {
45
+ safe.github = { ...safe.github };
46
+ delete safe.github.token;
47
+ }
48
+
49
+ const configPath = join(dir, 'config.json');
50
+ await this.atomic(configPath, JSON.stringify({
51
+ id,
52
+ created: new Date().toISOString(),
53
+ ...safe
54
+ }, null, 2));
55
+
56
+ return id;
57
+ }
58
+
59
+ /**
60
+ * Save agent state to disk.
61
+ *
62
+ * @param {string} sessionId - Session ID.
63
+ * @param {string} agentName - Agent identity name.
64
+ * @param {object} data - Agent state data.
65
+ * @returns {Promise<void>}
66
+ */
67
+ async save(sessionId, agentName, data) {
68
+ const filePath = join(this.sessionDir(sessionId), 'agents', `${agentName}.json`);
69
+ await this.atomic(filePath, JSON.stringify(data, null, 2));
70
+ }
71
+
72
+ /**
73
+ * Load agent state from disk.
74
+ *
75
+ * @param {string} sessionId - Session ID.
76
+ * @param {string} agentName - Agent identity name.
77
+ * @returns {Promise<object>} Parsed agent state data.
78
+ */
79
+ async load(sessionId, agentName) {
80
+ const filePath = join(this.sessionDir(sessionId), 'agents', `${agentName}.json`);
81
+ try {
82
+ const content = await readFile(filePath, 'utf8');
83
+ return JSON.parse(content);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * List all agent files in a session.
91
+ *
92
+ * @param {string} sessionId - Session ID.
93
+ * @returns {Promise<string[]>} Array of agent names.
94
+ */
95
+ async agents(sessionId) {
96
+ const dir = join(this.sessionDir(sessionId), 'agents');
97
+ try {
98
+ const files = await readdir(dir);
99
+ return files.filter(function isJson(f) { return f.endsWith('.json'); })
100
+ .map(function stripExt(f) { return f.replace('.json', ''); });
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * List all session IDs.
108
+ *
109
+ * @returns {Promise<string[]>} Array of session ID strings.
110
+ */
111
+ async sessions() {
112
+ const dir = join(this.home, 'sessions');
113
+ try {
114
+ return await readdir(dir);
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Get the directory path for a session.
122
+ *
123
+ * @param {string} id - Session ID.
124
+ * @returns {string} Absolute path to the session directory.
125
+ */
126
+ sessionDir(id) {
127
+ return join(this.home, 'sessions', id);
128
+ }
129
+
130
+ /**
131
+ * Remove a single session and all its contents.
132
+ *
133
+ * @param {string} id - Session ID to remove.
134
+ * @returns {Promise<void>}
135
+ */
136
+ async remove(id) {
137
+ await rm(this.sessionDir(id), { recursive: true, force: true });
138
+ }
139
+
140
+ /**
141
+ * Prune sessions older than a given age.
142
+ *
143
+ * Reads each session's `config.json` for the `created` timestamp.
144
+ * Sessions older than `maxAge` milliseconds are removed. Sessions
145
+ * with missing or unparseable config are treated as stale and pruned.
146
+ *
147
+ * @param {number} maxAge - Maximum age in milliseconds.
148
+ * @returns {Promise<string[]>} IDs of pruned sessions.
149
+ */
150
+ async prune(maxAge) {
151
+ const ids = await this.sessions();
152
+ const now = Date.now();
153
+ const pruned = [];
154
+
155
+ for (const id of ids) {
156
+ let created = 0;
157
+ try {
158
+ const raw = await readFile(join(this.sessionDir(id), 'config.json'), 'utf8');
159
+ const config = JSON.parse(raw);
160
+ created = new Date(config.created).getTime();
161
+ } catch {
162
+ // Missing or invalid config — treat as infinitely old
163
+ }
164
+
165
+ if (now - created > maxAge) {
166
+ await this.remove(id);
167
+ pruned.push(id);
168
+ }
169
+ }
170
+
171
+ return pruned;
172
+ }
173
+
174
+ /**
175
+ * Remove known stray files from the loreli home root.
176
+ *
177
+ * Files like `mcp-ready` can end up at the home root level when
178
+ * the MCP server's cwd happens to be `~/.loreli`. This sweeps
179
+ * only files from a known allowlist — directories and unknown
180
+ * files are left untouched.
181
+ *
182
+ * @returns {Promise<string[]>} Names of removed files.
183
+ */
184
+ async sweep() {
185
+ const STRAYS = ['mcp-ready'];
186
+ const removed = [];
187
+
188
+ for (const name of STRAYS) {
189
+ const path = join(this.home, name);
190
+ try {
191
+ const s = await stat(path);
192
+ if (!s.isFile()) continue;
193
+ await unlink(path);
194
+ removed.push(name);
195
+ } catch {
196
+ // File doesn't exist — nothing to sweep
197
+ }
198
+ }
199
+
200
+ return removed;
201
+ }
202
+
203
+ /**
204
+ * Write a file atomically (temp file + rename).
205
+ *
206
+ * @param {string} filePath - Target file path.
207
+ * @param {string} content - File content.
208
+ * @returns {Promise<void>}
209
+ */
210
+ async atomic(filePath, content) {
211
+ const tmp = `${filePath}.tmp`;
212
+ await writeFile(tmp, content, 'utf8');
213
+ await rename(tmp, filePath);
214
+ }
215
+ }
@@ -0,0 +1,96 @@
1
+ # loreli/test-utils
2
+
3
+ Shared test infrastructure for Loreli integration tests. Consolidates helpers from `hub/test/helpers.js` and `mcp/test/helpers.js` into a single canonical location.
4
+
5
+ ## API Reference
6
+
7
+ ### Constants
8
+
9
+ | Name | Description |
10
+ |------|-------------|
11
+ | `TEST_REPO` | Test repository — set via `LORELI_TEST_REPO` env var (no default; tests skip when unset) |
12
+ | `TEST_SESSION` | Tmux session name for test agents (`loreli-test-<pid>`) |
13
+
14
+ ## Test Modes
15
+
16
+ Set `LORELI_TEST_MODE=unit` to disable agentic tests that require real CLI backends. This matters for CI environments where LLM CLIs are unavailable or expensive, and you should expect backend-dependent suites to skip with a clear reason when this mode is active.
17
+
18
+ ### Functions
19
+
20
+ #### `testIdentity()` → Identity
21
+
22
+ Create a stable test identity for integration tests. All automated test interactions use this identity.
23
+
24
+ #### `ensureTestLabels(hub, repo)` → Promise\<void\>
25
+
26
+ Ensure the test identity's labels exist in the test repository. Must be called once before scoped hub operations.
27
+
28
+ #### `agenticSkipReason()` → string | false
29
+
30
+ Return a skip reason when agentic tests are disabled.
31
+
32
+ #### `skipWithoutToken(t)` → boolean
33
+
34
+ Skip guard for tests requiring `GITHUB_TOKEN`.
35
+
36
+ #### `skipWithoutTestRepo(t)` → boolean
37
+
38
+ Skip guard for tests requiring `LORELI_TEST_REPO`.
39
+
40
+ #### `skipWithoutBackends(t, backends)` → Promise\<boolean\>
41
+
42
+ Skip guard for tests requiring real CLI backends (claude, codex, etc.) on PATH and a working tmux install. This guard skips immediately when `LORELI_TEST_MODE=unit`.
43
+
44
+ #### `createHub()` → GitHubHub
45
+
46
+ Create a real GitHubHub instance from the environment token.
47
+
48
+ #### `createConfig(hub?, overrides?)` → Promise\<Config\>
49
+
50
+ Create a real Config instance, optionally loading from the test repo.
51
+
52
+ #### `createAgent(backends, registry, role, provider?, opts?)` → Promise\<object\>
53
+
54
+ Discover real backends and create a real agent for the given provider and role. Agents are assigned to the `loreli-test` tmux session.
55
+
56
+ #### `createTmpDir(prefix?)` → Promise\<string\>
57
+
58
+ Create a temporary directory for test artifacts.
59
+
60
+ #### `resetTestSession()` → Promise\<void\>
61
+
62
+ Kill and recreate the test tmux session with retry logic.
63
+
64
+ #### `killTestSession()` → Promise\<void\>
65
+
66
+ Kill the test tmux session entirely.
67
+
68
+ ### Classes
69
+
70
+ #### `Cleanup`
71
+
72
+ Tracks GitHub resources (issues, PRs, branches, discussions) created during tests and provides `flush()` for best-effort cleanup.
73
+
74
+ The example below demonstrates tracking a created issue for cleanup. This matters because integration tests create real GitHub resources, and you should expect `flush()` to close or delete tracked items without throwing even if some were already removed.
75
+
76
+ ```js
77
+ const cleanup = new Cleanup(hub, TEST_REPO);
78
+ cleanup.issues.push(issue.number);
79
+ await cleanup.flush();
80
+ ```
81
+
82
+ ### Re-exports
83
+
84
+ This package re-exports core classes used frequently in integration tests so test files can import from one place:
85
+
86
+ - `GitHubHub` (from `loreli/hub`)
87
+ - `Config` (from `loreli/config`)
88
+ - `Identity`, `Registry` (from `loreli/identity`)
89
+ - `BackendRegistry` (from `loreli/agent`)
90
+ - `Storage` (from `loreli/session`)
91
+
92
+ ## Scope Boundary
93
+
94
+ **In scope**: Test skip guards, GitHub resource cleanup, agent creation helpers, tmux session management, test identity.
95
+
96
+ **Out of scope**: Test framework configuration, assertions, test runner setup.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Consolidated test infrastructure for loreli integration tests.
3
+ *
4
+ * Combines helpers from hub/test/helpers.js and mcp/test/helpers.js
5
+ * into a single canonical location. Uses real backends, real GitHub,
6
+ * and real agents — no stubs, no mocks.
7
+ */
8
+ import { mkdtemp } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { GitHubHub, definitions } from 'loreli/hub';
12
+ import { Config } from 'loreli/config';
13
+ import { Identity, Registry } from 'loreli/identity';
14
+ import { BackendRegistry } from 'loreli/agent';
15
+ import { Storage } from 'loreli/session';
16
+ import { Tmux } from 'loreli/tmux';
17
+
18
+ /**
19
+ * Test repository — set via LORELI_TEST_REPO env var.
20
+ * No default is provided to avoid leaking org-specific names.
21
+ *
22
+ * @type {string|undefined}
23
+ */
24
+ export const TEST_REPO = process.env.LORELI_TEST_REPO;
25
+
26
+ /**
27
+ * Tmux session name used by all test agents. Isolated from the
28
+ * production `loreli` session to prevent cross-contamination.
29
+ *
30
+ * Appends process.pid so parallel test processes get their own
31
+ * tmux sessions — prevents "can't find pane" and "duplicate session"
32
+ * errors when multiple test files share the same name.
33
+ *
34
+ * @type {string}
35
+ */
36
+ export const TEST_SESSION = `loreli-test-${process.pid}`;
37
+
38
+ // ── Identity & Labels ───────────────────────────────────
39
+
40
+ /**
41
+ * Create a stable test identity for integration tests.
42
+ * All automated test interactions use this identity so every
43
+ * issue, PR, comment, and review carries a signature and labels.
44
+ *
45
+ * @returns {Identity} A test-runner identity.
46
+ */
47
+ export function testIdentity() {
48
+ return new Identity({
49
+ name: 'test-runner', instance: 0, faction: 'autobots',
50
+ provider: 'loreli', model: 'integration-test', theme: 'transformers'
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Ensure the test identity's labels exist in the test repository.
56
+ * Must be called once before any scoped hub operations in tests.
57
+ *
58
+ * @param {GitHubHub} hub - Real hub instance.
59
+ * @param {string} repo - "owner/name" repository string.
60
+ * @returns {Promise<void>}
61
+ */
62
+ export async function ensureTestLabels(hub, repo) {
63
+ const id = testIdentity();
64
+ await hub.ensure(repo, definitions(id.labels('action')));
65
+ }
66
+
67
+ // ── Skip Guards ─────────────────────────────────────────
68
+
69
+ /**
70
+ * Resolve the current test mode from the environment.
71
+ *
72
+ * @returns {string} Normalized test mode string.
73
+ */
74
+ function testMode() {
75
+ const raw = process.env.LORELI_TEST_MODE ?? 'full';
76
+ const normalized = raw.trim().toLowerCase();
77
+ return normalized.length ? normalized : 'full';
78
+ }
79
+
80
+ /**
81
+ * Return a skip reason when agentic tests are disabled.
82
+ *
83
+ * @returns {string|false} Skip reason string or false when enabled.
84
+ */
85
+ export function agenticSkipReason() {
86
+ if (testMode() !== 'unit') return false;
87
+ return 'Agentic tests disabled (LORELI_TEST_MODE=unit)';
88
+ }
89
+
90
+ /**
91
+ * Skip guard — call at the start of tests that need GitHub.
92
+ *
93
+ * @param {object} t - Test context with skip().
94
+ * @returns {boolean} True if the test should be skipped.
95
+ */
96
+ export function skipWithoutToken(t) {
97
+ if (!process.env.GITHUB_TOKEN) {
98
+ t.skip('GITHUB_TOKEN not set');
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Skip guard — call at the start of tests that need a test repo.
106
+ *
107
+ * @param {object} t - Test context with skip().
108
+ * @returns {boolean} True if the test should be skipped.
109
+ */
110
+ export function skipWithoutTestRepo(t) {
111
+ if (!TEST_REPO) {
112
+ t.skip('LORELI_TEST_REPO not set');
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Skip guard for tests that need real backends (claude, codex, etc.)
120
+ * on PATH and a working tmux install. Discovers backends and returns
121
+ * true if none are found or tmux is missing.
122
+ *
123
+ * @param {object} t - Test context with skip().
124
+ * @param {BackendRegistry} backends - Backend registry instance.
125
+ * @returns {Promise<boolean>} True if the test should be skipped.
126
+ */
127
+ export async function skipWithoutBackends(t, backends) {
128
+ const reason = agenticSkipReason();
129
+ if (reason) {
130
+ t.skip(reason);
131
+ return true;
132
+ }
133
+ if (!Tmux.available()) {
134
+ t.skip('tmux not available');
135
+ return true;
136
+ }
137
+
138
+ await backends.discover();
139
+ if (!backends.available().length) {
140
+ t.skip('No CLI backends available (install claude or codex)');
141
+ return true;
142
+ }
143
+ return false;
144
+ }
145
+
146
+ // ── Factory Helpers ─────────────────────────────────────
147
+
148
+ /**
149
+ * Create a real GitHubHub instance from the environment token.
150
+ *
151
+ * @returns {GitHubHub}
152
+ */
153
+ export function createHub() {
154
+ return new GitHubHub({ token: process.env.GITHUB_TOKEN });
155
+ }
156
+
157
+ /**
158
+ * Create a real Config instance, optionally loading from the test repo.
159
+ *
160
+ * @param {GitHubHub} [hub] - Hub to load loreli.yml from.
161
+ * @param {object} [overrides] - Overrides to merge.
162
+ * @returns {Promise<Config>}
163
+ */
164
+ export async function createConfig(hub, overrides) {
165
+ const config = new Config();
166
+ if (hub) await config.load(hub, TEST_REPO);
167
+ if (overrides) config.merge(overrides);
168
+ return config;
169
+ }
170
+
171
+ /**
172
+ * Discover real backends and create a real agent for the given provider
173
+ * and role. Uses BackendRegistry.discover() to find what's on PATH,
174
+ * then creates a real agent via backendRegistry.create().
175
+ *
176
+ * Agents are assigned to the per-PID `loreli-test-<pid>` tmux
177
+ * session to keep test panes isolated from both production
178
+ * sessions and other parallel test processes.
179
+ *
180
+ * By default, `readyTimeout` is set to 0, which skips the CLI
181
+ * readiness poll (60s for Claude/Cursor). State-management tests
182
+ * don't need the CLI to initialize — they only need entries in the
183
+ * orchestrator's agent map. Tests that exercise real prompt delivery
184
+ * should pass `readyTimeout: 60000` (or omit 0) to restore the wait.
185
+ *
186
+ * @param {BackendRegistry} backends - Backend registry instance.
187
+ * @param {Registry} registry - Identity registry instance.
188
+ * @param {string} role - Agent role ('action', 'reviewer', 'planner').
189
+ * @param {string} [provider='openai'] - Provider for identity.
190
+ * @param {object} [opts] - Additional options.
191
+ * @param {string} [opts.backend] - Force a specific backend name.
192
+ * @param {string} [opts.prompt] - Override prompt text.
193
+ * @param {number} [opts.readyTimeout=0] - Max ms to wait for CLI readiness.
194
+ * @returns {Promise<object>} Real agent instance.
195
+ */
196
+ export async function createAgent(backends, registry, role, provider = 'openai', opts = {}) {
197
+ await backends.discover();
198
+
199
+ const identity = registry.acquire('transformers', provider);
200
+ const cwd = await mkdtemp(join(tmpdir(), `loreli-test-${identity.name}-`));
201
+ const name = opts.backend ?? backends.defaultBackend();
202
+ const config = new Config();
203
+
204
+ return backends.create(name, {
205
+ identity,
206
+ role,
207
+ cwd,
208
+ model: 'balanced',
209
+ session: TEST_SESSION,
210
+ prompt: opts.prompt,
211
+ readyTimeout: opts.readyTimeout ?? 0,
212
+ config
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Create a temporary directory for test artifacts.
218
+ *
219
+ * @param {string} [prefix='loreli-test-'] - Directory name prefix.
220
+ * @returns {Promise<string>} Path to the temporary directory.
221
+ */
222
+ export async function createTmpDir(prefix = 'loreli-test-') {
223
+ return mkdtemp(join(tmpdir(), prefix));
224
+ }
225
+
226
+ // ── Tmux Session Management ─────────────────────────────
227
+
228
+ /**
229
+ * Reset the test tmux session. Kills an existing session and creates
230
+ * a fresh one with generous dimensions so split-window has enough
231
+ * space for multiple agent panes.
232
+ *
233
+ * Test sessions are created without a command (bare session) since
234
+ * tests explicitly call killTestSession() in teardown — no need for
235
+ * tmux auto-destruction here.
236
+ *
237
+ * Uses retry logic because tmux's kill-session is asynchronous —
238
+ * the session name may not be fully released by the time new-session
239
+ * runs, causing a "duplicate session" error.
240
+ *
241
+ * @returns {Promise<void>}
242
+ */
243
+ export async function resetTestSession() {
244
+ const { Tmux } = await import('loreli/tmux');
245
+ const tmux = new Tmux();
246
+
247
+ try { await tmux.kill(TEST_SESSION); } catch { /* may not exist */ }
248
+
249
+ for (let attempt = 0; attempt < 5; attempt++) {
250
+ try {
251
+ await tmux.create(TEST_SESSION, { width: 200, height: 50 });
252
+ return;
253
+ } catch (err) {
254
+ // tmux reports both "duplicate session" and "already exists" depending
255
+ // on version — match either. Without this, fast tests outrun tmux's
256
+ // async session teardown and the retry logic never fires.
257
+ if (!err.message?.includes('duplicate session') && !err.message?.includes('already exists')) throw err;
258
+ try { await tmux.kill(TEST_SESSION); } catch { /* ignore */ }
259
+ await new Promise(function wait(r) { setTimeout(r, 100 * (attempt + 1)); });
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Kill the test tmux session entirely. Call in suite-level after() hooks.
266
+ *
267
+ * @returns {Promise<void>}
268
+ */
269
+ export async function killTestSession() {
270
+ const { Tmux } = await import('loreli/tmux');
271
+ const tmux = new Tmux();
272
+ try {
273
+ if (await tmux.has(TEST_SESSION)) {
274
+ await tmux.kill(TEST_SESSION);
275
+ }
276
+ } catch { /* session may not exist */ }
277
+ }
278
+
279
+ // ── Cleanup ─────────────────────────────────────────────
280
+
281
+ /**
282
+ * Tracks GitHub resources created during integration tests and
283
+ * provides a flush() method to clean them all up.
284
+ */
285
+ export class Cleanup {
286
+ /**
287
+ * @param {GitHubHub} hub - Real hub instance.
288
+ * @param {string} repo - "owner/name" repository string.
289
+ */
290
+ constructor(hub, repo) {
291
+ this.hub = hub;
292
+ this.repo = repo;
293
+
294
+ /** @type {number[]} Issue numbers to close. */
295
+ this.issues = [];
296
+
297
+ /** @type {number[]} PR numbers to close. */
298
+ this.prs = [];
299
+
300
+ /** @type {string[]} Branch names to delete. */
301
+ this.branches = [];
302
+
303
+ /** @type {string[]} Discussion node IDs to delete. */
304
+ this.discussions = [];
305
+
306
+ /** @type {string[]} Label names to delete. */
307
+ this.labels = [];
308
+ }
309
+
310
+ /**
311
+ * Clean up all tracked resources. Errors are swallowed — best-effort
312
+ * cleanup should not cause test failures.
313
+ */
314
+ async flush() {
315
+ const [owner, name] = this.repo.split('/');
316
+
317
+ for (const num of this.prs) {
318
+ try {
319
+ await this.hub.client.pulls.update({ owner, repo: name, pull_number: num, state: 'closed' });
320
+ } catch { /* best-effort */ }
321
+ }
322
+
323
+ for (const num of this.issues) {
324
+ try {
325
+ await this.hub.client.issues.update({ owner, repo: name, issue_number: num, state: 'closed' });
326
+ } catch { /* best-effort */ }
327
+ }
328
+
329
+ for (const ref of this.branches) {
330
+ try {
331
+ await this.hub.client.git.deleteRef({ owner, repo: name, ref: `heads/${ref}` });
332
+ } catch { /* best-effort */ }
333
+ }
334
+
335
+ for (const discId of this.discussions) {
336
+ try { await this.hub.deleteDiscussion(discId); } catch { /* best-effort */ }
337
+ }
338
+
339
+ for (const label of this.labels) {
340
+ try {
341
+ await this.hub.client.issues.deleteLabel({ owner, repo: name, name: label });
342
+ } catch { /* best-effort */ }
343
+ }
344
+
345
+ this.issues.length = 0;
346
+ this.prs.length = 0;
347
+ this.branches.length = 0;
348
+ this.discussions.length = 0;
349
+ this.labels.length = 0;
350
+ }
351
+ }
352
+
353
+ // Re-export dependencies for convenience
354
+ export { GitHubHub, Config, Identity, Registry, BackendRegistry, Storage };