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,165 @@
1
+ # loreli/session
2
+
3
+ Persistent session storage for detached Loreli agents.
4
+
5
+ This package manages the `~/.loreli/` state directory where agent sessions and their runtime data are persisted. It exists as a standalone package because session persistence is needed by multiple packages (agent, orchestrator, MCP server) — centralizing it avoids duplication and provides a single, generic solution for `~/.loreli` state management.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add loreli
11
+ ```
12
+
13
+ ## Directory Layout
14
+
15
+ Storage organizes state into per-session directories under `~/.loreli/sessions/`:
16
+
17
+ ```
18
+ ~/.loreli/
19
+ sessions/
20
+ <session-id>/
21
+ config.json # Session metadata (id, created, repo, etc.)
22
+ agents/
23
+ optimus-0.json # Agent runtime state
24
+ megatron-0.json
25
+ logs/ # Session-scoped log files
26
+ registry.json # Identity registry snapshot
27
+ ```
28
+
29
+ ## API Reference
30
+
31
+ ### `new Storage(opts?)`
32
+
33
+ Create a new Storage instance.
34
+
35
+ - `opts.home` `{string}` — Override the base directory. Falls back to `LORELI_HOME` env var, then `~/.loreli`.
36
+
37
+ The following example shows the precedence order for resolving the home directory. Options take priority over environment variables, which take priority over the default.
38
+
39
+ ```js
40
+ import { Storage } from 'loreli/session';
41
+
42
+ // Uses ~/.loreli by default
43
+ const storage = new Storage();
44
+
45
+ // Explicit override
46
+ const custom = new Storage({ home: '/tmp/test-loreli' });
47
+
48
+ // Environment variable fallback
49
+ // LORELI_HOME=/opt/loreli node app.js
50
+ const envStorage = new Storage(); // uses /opt/loreli
51
+ ```
52
+
53
+ ### `storage.init(id, config?)` → `Promise<string>`
54
+
55
+ Initialize a session directory. Creates the `agents/` and `logs/` subdirectories and writes `config.json` with the session ID, creation timestamp, and any additional config fields. Returns the session ID.
56
+
57
+ Safe to call multiple times (idempotent) — existing directories are preserved. The `github.token` field is automatically stripped from the persisted `config.json` to prevent plaintext credential storage on disk.
58
+
59
+ ```js
60
+ const id = await storage.init('session-abc', { repo: 'owner/repo', theme: 'transformers' });
61
+ ```
62
+
63
+ ### `storage.save(sessionId, agentName, data)` → `Promise<void>`
64
+
65
+ Save agent state to disk. Writes atomically (temp file + rename) to prevent corruption from crashes during write.
66
+
67
+ The following example shows saving an agent's runtime state, which is typically called during state transitions or periodic checkpoints.
68
+
69
+ ```js
70
+ await storage.save('session-abc', 'optimus-0', {
71
+ identity: { name: 'optimus-0', provider: 'openai' },
72
+ state: 'working',
73
+ paneId: '%3',
74
+ claimedIssue: 42
75
+ });
76
+ ```
77
+
78
+ ### `storage.load(sessionId, agentName)` → `Promise<object|null>`
79
+
80
+ Load agent state from disk. Returns `null` if the agent file does not exist.
81
+
82
+ ```js
83
+ const data = await storage.load('session-abc', 'optimus-0');
84
+ if (data) {
85
+ console.log(`Agent ${data.identity.name} was in state: ${data.state}`);
86
+ }
87
+ ```
88
+
89
+ ### `storage.agents(sessionId)` → `Promise<string[]>`
90
+
91
+ List all agent names in a session (filenames without `.json` extension). Returns empty array for missing sessions.
92
+
93
+ ```js
94
+ const names = await storage.agents('session-abc');
95
+ // ['optimus-0', 'megatron-0']
96
+ ```
97
+
98
+ ### `storage.sessions()` → `Promise<string[]>`
99
+
100
+ List all session IDs (directory names under `sessions/`). Returns empty array when no sessions exist.
101
+
102
+ ```js
103
+ const ids = await storage.sessions();
104
+ // ['session-abc', 'session-def']
105
+ ```
106
+
107
+ ### `storage.sessionDir(id)` → `string`
108
+
109
+ Get the absolute path to a session's directory. Synchronous.
110
+
111
+ ```js
112
+ storage.sessionDir('session-abc');
113
+ // '/Users/you/.loreli/sessions/session-abc'
114
+ ```
115
+
116
+ ### `storage.remove(id)` → `Promise<void>`
117
+
118
+ Remove a single session and all its contents (agents, logs, config). Does not throw when the session does not exist.
119
+
120
+ ```js
121
+ await storage.remove('session-abc');
122
+ ```
123
+
124
+ ### `storage.prune(maxAge)` → `Promise<string[]>`
125
+
126
+ Remove sessions older than `maxAge` milliseconds. Reads each session's `config.json` for the `created` timestamp. Sessions with missing or unparseable config are treated as stale and pruned. Returns an array of pruned session IDs.
127
+
128
+ Called automatically at start when `cleanup.autoprune` is `true` (the default), using `cleanup.retention` as the max age.
129
+
130
+ ```js
131
+ const pruned = await storage.prune(12 * 60 * 60 * 1000); // 12 hours
132
+ // ['old-session-1', 'old-session-2']
133
+ ```
134
+
135
+ ### `storage.sweep()` → `Promise<string[]>`
136
+
137
+ Remove known stray files from the loreli home root directory. Files like `mcp-ready` can end up at the root level when the MCP server's working directory happens to be `~/.loreli`. Returns an array of removed file names.
138
+
139
+ ```js
140
+ const swept = await storage.sweep();
141
+ // ['mcp-ready']
142
+ ```
143
+
144
+ ### `storage.atomic(filePath, content)` → `Promise<void>`
145
+
146
+ Write a file atomically using temp file + rename. Used internally by `save()` and `init()`, but exposed for custom state files that need crash-safe writes.
147
+
148
+ ## Error Handling
149
+
150
+ | Scenario | Behavior |
151
+ |----------|----------|
152
+ | Session directory missing | `load()` and `agents()` return `null` / `[]` — no throw |
153
+ | No sessions directory | `sessions()` and `prune()` return `[]` — no throw |
154
+ | Missing session for `remove()` | Silent no-op |
155
+ | Missing/invalid `config.json` during prune | Session treated as stale and pruned |
156
+ | `github.token` in config | Automatically stripped during `init()` |
157
+ | File write interrupted | Atomic writes prevent partial files from appearing at the target path |
158
+
159
+ ## Testing
160
+
161
+ ```bash
162
+ node --test packages/session/test/index.test.js
163
+ ```
164
+
165
+ Tests use temporary directories and do not require tmux.
@@ -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.