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,450 @@
1
+ import { Session, models } from 'loreli/agent';
2
+ import { prepare, pathFor, mirror, create as createWorktree } from 'loreli/workspace';
3
+ import { definitions } from 'loreli/hub';
4
+ import { vendor, side, pick } from 'loreli/identity';
5
+ import { logger } from 'loreli/log';
6
+ import { check } from 'loreli/config';
7
+
8
+ const log = logger('agents');
9
+
10
+ /**
11
+ * Persist session state and build the MCP result for a spawned agent.
12
+ *
13
+ * @param {object} identity - Agent identity.
14
+ * @param {string} provider - AI provider name.
15
+ * @param {string} model - Resolved model identifier.
16
+ * @param {string} role - Agent role.
17
+ * @param {string} backendName - Backend used.
18
+ * @param {object} agent - The spawned agent instance.
19
+ * @param {object} ctx - Execution context.
20
+ * @returns {Promise<object>} MCP tool result.
21
+ */
22
+ async function buildResult(identity, provider, model, role, backendName, agent, ctx) {
23
+ const session = new Session({
24
+ identity: identity.toJSON(),
25
+ role,
26
+ backend: backendName,
27
+ paneId: agent.paneId ?? null,
28
+ repo: ctx.repo
29
+ });
30
+
31
+ if (ctx.sessionId) {
32
+ await ctx.storage.save(ctx.sessionId, identity.name, session.toJSON());
33
+ }
34
+
35
+ return {
36
+ content: [{
37
+ type: 'text',
38
+ text: [
39
+ `Agent spawned: ${identity.name}`,
40
+ `Faction: ${identity.faction}`,
41
+ `Provider: ${provider}`,
42
+ `Model: ${model}`,
43
+ `Role: ${role}`,
44
+ `Backend: ${backendName}`,
45
+ agent.paneId ? `Pane: ${agent.paneId}` : ''
46
+ ].filter(Boolean).join('\n')
47
+ }]
48
+ };
49
+ }
50
+
51
+ export default {
52
+ add_agent: {
53
+ title: 'Add Agent',
54
+ description: 'Add an agent to the team. Acquires a themed identity, selects a backend, and prepares the agent for spawning.',
55
+ schema: {
56
+ type: 'object',
57
+ properties: {
58
+ provider: { type: 'string', description: 'AI provider: openai, anthropic, cursor-openai, or cursor-anthropic. Omit to randomly select from available providers.' },
59
+ role: { type: 'string', description: 'Agent role: planner, action, or reviewer.' },
60
+ model: { type: 'string', description: 'Model alias (fast/balanced/powerful) or exact model string.' },
61
+ theme: { type: 'string', description: 'Override theme for this agent (single theme name).' }
62
+ },
63
+ required: ['role']
64
+ },
65
+
66
+ /**
67
+ * @param {object} args - Tool arguments.
68
+ * @param {object} ctx - Execution context.
69
+ * @returns {Promise<object>} Agent spawn result.
70
+ */
71
+ async exec(args, ctx) {
72
+ // Validate inputs before any side effects
73
+ check.role(args.role);
74
+ if (args.theme) check.theme(args.theme);
75
+
76
+ // Resolve provider: use explicit value, or pick randomly from
77
+ // discovered backends. Randomization ensures different models
78
+ // get exposure to all roles across runs — no single provider
79
+ // monopolizes planning or coding.
80
+ let provider = args.provider;
81
+ if (provider) {
82
+ check.provider(provider);
83
+ } else {
84
+ const available = ctx.backendRegistry.providers();
85
+ if (!available.length) throw new Error('No providers available. Ensure at least one CLI backend (claude, codex, cursor-agent) is on PATH.');
86
+ provider = available[Math.floor(Math.random() * available.length)];
87
+ log.info(`add_agent: no provider specified — randomly selected "${provider}" from [${available.join(', ')}]`);
88
+ }
89
+
90
+ const { role } = args;
91
+ const modelAlias = args.model
92
+ ?? ctx.config?.get?.(`workflows.${role}.model`)
93
+ ?? ctx.config?.get?.('model')
94
+ ?? 'balanced';
95
+
96
+ // Theme coherence: explicit override > peer inheritance > random pick from config.
97
+ // When multiple themes are configured, the first agent picks randomly
98
+ // and all subsequent agents inherit from the existing peer so
99
+ // antagonist pairs always share the same theme universe.
100
+ let theme;
101
+ if (args.theme) {
102
+ theme = args.theme;
103
+ } else {
104
+ const existing = [...(ctx.orchestrator?.agents?.values?.() ?? [])];
105
+ const peer = existing.find(function hasIdentity(a) { return a.identity?.theme; });
106
+ theme = peer?.identity?.theme ?? pick(ctx.config?.get?.('theme') ?? 'transformers');
107
+ }
108
+
109
+ const repo = ctx.repo;
110
+
111
+ // Resolve backend first — model resolution needs the backend name
112
+ const backendName = ctx.backendRegistry.resolve(provider, args.backend);
113
+
114
+ // Resolve model alias to concrete identifier via backend-specific config.
115
+ // Virtual providers (cursor-openai, cursor-anthropic) normalize to the
116
+ // canonical vendor for config lookup (backends.cursor.models.balanced.openai).
117
+ const model = models.resolve(modelAlias, backendName, vendor(provider), ctx.config, ctx.backendRegistry?.models);
118
+ log.info(`add_agent: provider=${provider} role=${role} model=${model} backend=${backendName} theme=${theme}`);
119
+
120
+ // Seed taken names before first acquire — one-time cost, zero
121
+ // ongoing overhead because reactor ticks keep the set current.
122
+ await ctx.orchestrator.seed?.();
123
+
124
+ // Acquire themed identity — tracked for rollback
125
+ const identity = ctx.identityRegistry.acquire(theme, provider, model, ctx.orchestrator.takenNames);
126
+
127
+ /** @type {Array<{step: string, undo: function}>} Steps for rollback. */
128
+ const steps = [{
129
+ step: 'identity',
130
+ undo() { ctx.identityRegistry.release(identity); }
131
+ }];
132
+
133
+ try {
134
+ // Ensure model-specific labels exist (model labels are not known at start)
135
+ if (ctx.hub && repo && ctx.config?.get?.('labels.track') !== false) {
136
+ await ctx.hub.ensure(repo, definitions(identity.labels(role)));
137
+ // Labels are idempotent — no meaningful undo needed
138
+ }
139
+
140
+ log.debug(`resolved backend: ${backendName} for identity ${identity.name}`);
141
+
142
+ const denied = ctx.config?.get?.('agents.disallowedTools') ?? [];
143
+ const wsContext = ctx.sessionId ? {
144
+ session: ctx.sessionId,
145
+ agent: identity.name,
146
+ repo,
147
+ home: ctx.storage?.home,
148
+ token: process.env.GITHUB_TOKEN,
149
+ denied
150
+ } : undefined;
151
+
152
+ // When we have full orchestration context, create a git worktree
153
+ // from a shared bare mirror. The agent starts with the repo
154
+ // already checked out — no cloning needed. Falls back to plain
155
+ // config scaffolding when repo is unknown.
156
+ let cwd;
157
+ if (wsContext?.repo && wsContext?.home) {
158
+ const url = `https://github.com/${repo}.git`;
159
+ const bare = await mirror(url, { home: wsContext.home, token: wsContext.token });
160
+ cwd = await createWorktree(bare, 'HEAD', identity.name, undefined, wsContext);
161
+ log.info(`worktree ready at ${cwd} from mirror of ${repo}`);
162
+ } else {
163
+ cwd = repo ? pathFor(identity.name) : process.cwd();
164
+ await prepare(cwd, wsContext);
165
+ }
166
+
167
+ // Build prompt from the workflow's template for this role
168
+ const workflow = { planner: ctx.planner, action: ctx.action, reviewer: ctx.review }[role];
169
+ const prompt = await workflow.render({
170
+ name: identity.name,
171
+ repo: repo ?? 'unknown',
172
+ faction: identity.faction,
173
+ provider,
174
+ model,
175
+ labels: identity.labels(role)
176
+ });
177
+
178
+ // Create the agent instance via backend registry.
179
+ const opts = { identity, role, cwd, model: modelAlias, prompt, config: ctx.config };
180
+ if (wsContext) opts.context = wsContext;
181
+ const agent = ctx.backendRegistry.create(backendName, opts);
182
+
183
+ // Persist session data BEFORE spawn so the agent's MCP server
184
+ // subprocess can hydrate from storage on startup. The agent
185
+ // process starts during spawn() and immediately reads session
186
+ // data via _hydrate() — a race condition if we save afterward.
187
+ if (ctx.sessionId) {
188
+ const session = new Session({
189
+ identity: identity.toJSON(),
190
+ role,
191
+ backend: backendName,
192
+ paneId: null, // not yet known — updated after spawn
193
+ repo: ctx.repo
194
+ });
195
+ await ctx.storage.save(ctx.sessionId, identity.name, session.toJSON());
196
+ }
197
+
198
+ // Spawn via orchestrator (has its own internal rollback)
199
+ await ctx.orchestrator.spawn(agent);
200
+
201
+ // Cross-side validation: warn when yin/yang adversarial pairing is incomplete.
202
+ // Uses side-based matching so cursor-openai (yang) is satisfied by any yin agent.
203
+ const mySide = side(provider);
204
+ if (role === 'reviewer') {
205
+ const hasAction = [...ctx.orchestrator.agents.values()]
206
+ .some(function has(a) { return a.role === 'action' && side(a.identity.provider) !== mySide; });
207
+ if (!hasAction) {
208
+ log.warn(`reviewer ${identity.name} (${provider}) has no opposing-side action agent — adversarial review unavailable`);
209
+ }
210
+ }
211
+
212
+ if (role === 'action') {
213
+ const hasReviewer = [...ctx.orchestrator.agents.values()]
214
+ .some(function has(a) { return a.role === 'reviewer' && side(a.identity.provider) !== mySide; });
215
+ if (!hasReviewer) {
216
+ log.warn(`action ${identity.name} (${provider}) has no opposing-side reviewer — adversarial review unavailable`);
217
+ }
218
+ }
219
+
220
+ return buildResult(identity, provider, model, role, backendName, agent, ctx);
221
+ } catch (err) {
222
+ // Rollback all completed steps in reverse
223
+ for (let i = steps.length - 1; i >= 0; i--) {
224
+ try {
225
+ await steps[i].undo();
226
+ log.debug(`add_agent rollback: undid "${steps[i].step}"`);
227
+ } catch (rollbackErr) {
228
+ log.warn(`add_agent rollback "${steps[i].step}" failed: ${rollbackErr.message}`);
229
+ }
230
+ }
231
+ throw err;
232
+ }
233
+
234
+ }
235
+ },
236
+
237
+ agents: {
238
+ title: 'Agents',
239
+ description: [
240
+ 'Query the state of your agent team. Use this to list all registered agents',
241
+ 'or check whether a specific agent is alive and responsive.',
242
+ '',
243
+ 'Actions:',
244
+ '- "list": Show all agents with their name, faction, provider, role, and state.',
245
+ '- "check": Query liveness of a specific agent (provide name) or all agents.',
246
+ ' Returns health status including alive flag, state, and pane ID.'
247
+ ].join('\n'),
248
+ schema: {
249
+ type: 'object',
250
+ properties: {
251
+ action: {
252
+ type: 'string',
253
+ description: 'Operation: "list" or "check".',
254
+ enum: ['list', 'check']
255
+ },
256
+ name: { type: 'string', description: 'Agent identity name (required for single-agent check, omit for all).' },
257
+ lines: { type: 'number', description: 'Number of terminal output lines to capture per agent in "list" mode. Defaults to 40.' }
258
+ },
259
+ required: ['action']
260
+ },
261
+
262
+ /**
263
+ * @param {object} args - Tool arguments.
264
+ * @param {object} ctx - Execution context.
265
+ * @returns {Promise<object>} Agent list or health report.
266
+ */
267
+ async exec(args, ctx) {
268
+ const { action } = args;
269
+
270
+ if (action !== 'list' && action !== 'check') {
271
+ return {
272
+ content: [{ type: 'text', text: `Unknown action: "${action}". Valid: list, check` }],
273
+ isError: true
274
+ };
275
+ }
276
+
277
+ if (args.name) check.name(args.name);
278
+
279
+ if (ctx.orchestrator?.reconcile) await ctx.orchestrator.reconcile();
280
+
281
+ if (action === 'list') {
282
+ const agents = [...ctx.orchestrator.agents.entries()];
283
+
284
+ if (!agents.length) {
285
+ return { content: [{ type: 'text', text: 'No agents registered.' }] };
286
+ }
287
+
288
+ const captureLines = args.lines ?? 40;
289
+ const parts = [];
290
+ parts.push('Name | Theme | Faction | Provider | Role | State');
291
+
292
+ for (const [name, agent] of agents) {
293
+ parts.push([
294
+ name,
295
+ agent.identity?.theme ?? 'unknown',
296
+ agent.identity?.faction ?? 'unknown',
297
+ agent.identity?.provider ?? 'unknown',
298
+ agent.role ?? 'unknown',
299
+ agent.state ?? 'unknown'
300
+ ].join(' | '));
301
+
302
+ try {
303
+ if (typeof agent.capture === 'function') {
304
+ const output = (await agent.capture(captureLines))?.trim();
305
+ if (output) {
306
+ parts.push(` Terminal:\n${output.split('\n').map(function indent(l) { return ` ${l}`; }).join('\n')}`);
307
+ } else {
308
+ parts.push(' Terminal: (no output)');
309
+ }
310
+ }
311
+ } catch { parts.push(' Terminal: (unavailable)'); }
312
+ }
313
+
314
+ return {
315
+ content: [{ type: 'text', text: parts.join('\n') }]
316
+ };
317
+ }
318
+
319
+ // action === 'check'
320
+ const orch = ctx.orchestrator;
321
+
322
+ if (args.name) {
323
+ const agent = orch.agents.get(args.name);
324
+ if (!agent) {
325
+ return { content: [{ type: 'text', text: `Agent "${args.name}" not found.` }], isError: true };
326
+ }
327
+
328
+ const alive = await agent.alive();
329
+ return {
330
+ content: [{
331
+ type: 'text',
332
+ text: JSON.stringify({
333
+ name: args.name,
334
+ alive,
335
+ state: agent.state,
336
+ role: agent.role,
337
+ paneId: agent.paneId ?? null
338
+ }, null, 2)
339
+ }]
340
+ };
341
+ }
342
+
343
+ // Check all agents
344
+ const results = [];
345
+ for (const [name, agent] of orch.agents) {
346
+ const alive = await agent.alive();
347
+ results.push({ name, alive, state: agent.state, role: agent.role });
348
+ }
349
+
350
+ return {
351
+ content: [{
352
+ type: 'text',
353
+ text: results.length
354
+ ? JSON.stringify(results, null, 2)
355
+ : 'No agents to check.'
356
+ }]
357
+ };
358
+ }
359
+ },
360
+
361
+ stop: {
362
+ title: 'Stop Agent',
363
+ description: [
364
+ 'Stop agents or halt the entire system. Three escalation levels:',
365
+ '',
366
+ 'Actions:',
367
+ '- "shutdown": Graceful stop of a single agent by name. Agent completes current task, then exits.',
368
+ '- "kill": Immediate forced termination of a single agent by name. Last resort.',
369
+ '- "halt": Full system stop. Kills the reactor loop, stall monitor, and all agents.',
370
+ ' The MCP server stays alive so `start` can resume the system. Use when you need',
371
+ ' everything to stop and stay stopped. The "name" parameter is ignored for halt.'
372
+ ].join('\n'),
373
+ schema: {
374
+ type: 'object',
375
+ properties: {
376
+ action: {
377
+ type: 'string',
378
+ description: 'Operation: "shutdown", "kill", or "halt".',
379
+ enum: ['shutdown', 'kill', 'halt']
380
+ },
381
+ name: { type: 'string', description: 'Agent identity name to stop (not required for "halt").' }
382
+ },
383
+ required: ['action']
384
+ },
385
+
386
+ /**
387
+ * @param {object} args - Tool arguments.
388
+ * @param {object} ctx - Execution context.
389
+ * @returns {Promise<object>} Stop result.
390
+ */
391
+ async exec(args, ctx) {
392
+ const { action, name } = args;
393
+
394
+ if (action !== 'shutdown' && action !== 'kill' && action !== 'halt') {
395
+ return {
396
+ content: [{ type: 'text', text: `Unknown action: "${action}". Valid: shutdown, kill, halt` }],
397
+ isError: true
398
+ };
399
+ }
400
+
401
+ if (action === 'halt') {
402
+ try {
403
+ const result = await ctx.orchestrator.halt();
404
+ const parts = [`System halted.`];
405
+ if (result.reactor) parts.push('Reactor loop stopped.');
406
+ if (result.monitor) parts.push('Stall monitor stopped.');
407
+ if (result.agents.length) {
408
+ parts.push(`Killed ${result.agents.length} agent(s): ${result.agents.join(', ')}.`);
409
+ } else {
410
+ parts.push('No agents were running.');
411
+ }
412
+ return { content: [{ type: 'text', text: parts.join(' ') }] };
413
+ } catch (err) {
414
+ return { content: [{ type: 'text', text: err.message }], isError: true };
415
+ }
416
+ }
417
+
418
+ check.name(name);
419
+
420
+ if (ctx.orchestrator?.reconcile) await ctx.orchestrator.reconcile();
421
+
422
+ try {
423
+ if (action === 'shutdown') {
424
+ await ctx.orchestrator.shutdown(name);
425
+ } else {
426
+ await ctx.orchestrator.kill(name);
427
+ }
428
+ } catch (err) {
429
+ return { content: [{ type: 'text', text: err.message }], isError: true };
430
+ }
431
+
432
+ // Update storage to reflect dormant state
433
+ if (ctx.sessionId) {
434
+ try {
435
+ const data = await ctx.storage.load(ctx.sessionId, name);
436
+ if (data) {
437
+ data.state = 'dormant';
438
+ data.lastActivity = new Date().toISOString();
439
+ await ctx.storage.save(ctx.sessionId, name, data);
440
+ }
441
+ } catch { /* storage update is best-effort */ }
442
+ }
443
+
444
+ const verb = action === 'shutdown' ? 'shut down' : 'force killed';
445
+ return {
446
+ content: [{ type: 'text', text: `Agent "${name}" ${verb} successfully.` }]
447
+ };
448
+ }
449
+ }
450
+ };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Context resolution MCP tool.
3
+ *
4
+ * Exposes the context resolution engine as an MCP tool, auto-exposed
5
+ * as CLI commands by @mcp-layer/cli. Agents invoke via terminal:
6
+ * `loreli tools context --action blame --file src/index.js --line 42`
7
+ *
8
+ * Deliberately excluded from AGENT_TOOLS — agents access via CLI
9
+ * to avoid bloating the conversation context window.
10
+ *
11
+ * @module loreli/mcp/tools/context
12
+ */
13
+
14
+ import { hub as createHub } from 'loreli/hub';
15
+ import { blame, history, search, resolve } from 'loreli/context';
16
+ import * as workspace from 'loreli/workspace';
17
+ import { logger } from 'loreli/log';
18
+ import { repoOf } from './repo.js';
19
+
20
+ const log = logger('context');
21
+
22
+ export default {
23
+ context: {
24
+ title: 'Context',
25
+ description: 'Resolve code context — trace lines to PRs, search decisions, view patterns. Best used via CLI: loreli tools context --action blame --file <path> --line <N>',
26
+ schema: {
27
+ type: 'object',
28
+ properties: {
29
+ action: {
30
+ type: 'string',
31
+ enum: ['blame', 'history', 'search', 'patterns'],
32
+ description: 'The context action to perform.'
33
+ },
34
+ file: { type: 'string', description: 'Relative file path (blame/history).' },
35
+ line: { type: 'number', description: 'Line number (blame).' },
36
+ query: { type: 'string', description: 'Search keywords (search).' },
37
+ limit: { type: 'number', description: 'Max results (history, default 10).' },
38
+ category: { type: 'string', description: 'Filter by category (patterns).' },
39
+ threshold: { type: 'number', description: 'Min occurrences (patterns).' }
40
+ },
41
+ required: ['action']
42
+ },
43
+ /**
44
+ * @param {object} args - Tool arguments.
45
+ * @param {object} ctx - Execution context.
46
+ * @returns {Promise<object>} MCP tool response.
47
+ */
48
+ async exec(args, ctx) {
49
+ if (!ctx.hub) {
50
+ try {
51
+ ctx.hub = createHub({ config: ctx.config });
52
+ } catch (err) {
53
+ return {
54
+ content: [{ type: 'text', text: `Hub initialization failed: ${err.message}. Set GITHUB_TOKEN.` }],
55
+ isError: true
56
+ };
57
+ }
58
+ }
59
+
60
+ const repo = repoOf(ctx);
61
+ if (!repo) {
62
+ return {
63
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
64
+ isError: true
65
+ };
66
+ }
67
+
68
+ try {
69
+ switch (args.action) {
70
+ case 'blame': {
71
+ if (!args.file || !args.line) {
72
+ return {
73
+ content: [{ type: 'text', text: 'blame requires --file and --line' }],
74
+ isError: true
75
+ };
76
+ }
77
+ const cwd = process.cwd();
78
+ const result = await blame(workspace, ctx.hub, repo, cwd, args.file, args.line);
79
+ log.info(`blame: ${args.file}:${args.line} -> ${result.sha?.slice(0, 7)}`);
80
+
81
+ const lines = [
82
+ `## Blame: ${args.file}:${args.line}`,
83
+ '',
84
+ `**Commit**: ${result.sha}`,
85
+ `**Author**: ${result.author}`,
86
+ `**Date**: ${result.date}`,
87
+ `**Summary**: ${result.summary}`
88
+ ];
89
+
90
+ if (result.prs.length) {
91
+ lines.push('', '### Associated PRs');
92
+ for (const pr of result.prs) {
93
+ lines.push(`- #${pr.number}: ${pr.title} (${pr.state})`);
94
+ }
95
+
96
+ // Resolve the first PR's full chain
97
+ const full = await resolve(ctx.hub, repo, result.prs[0].number);
98
+ if (full.issues.length) {
99
+ lines.push('', '### Linked Issues');
100
+ for (const issue of full.issues) {
101
+ lines.push(`- #${issue.number}: ${issue.title}`);
102
+ }
103
+ }
104
+ if (full.discussions.length) {
105
+ lines.push('', '### Linked Discussions');
106
+ for (const disc of full.discussions) {
107
+ lines.push(`- #${disc.number}: ${disc.title}`);
108
+ }
109
+ }
110
+ }
111
+
112
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
113
+ }
114
+
115
+ case 'history': {
116
+ if (!args.file) {
117
+ return {
118
+ content: [{ type: 'text', text: 'history requires --file' }],
119
+ isError: true
120
+ };
121
+ }
122
+ const cwd = process.cwd();
123
+ const limit = args.limit ?? 10;
124
+ const commits = await history(workspace, ctx.hub, repo, cwd, args.file, { limit });
125
+ log.info(`history: ${args.file} -> ${commits.length} commits`);
126
+
127
+ const lines = [`## History: ${args.file}`, ''];
128
+ for (const c of commits) {
129
+ const prRefs = c.prs.length
130
+ ? ` (${c.prs.map(function ref(p) { return `#${p.number}`; }).join(', ')})`
131
+ : '';
132
+ lines.push(`- \`${c.sha.slice(0, 7)}\` ${c.date} — ${c.message}${prRefs}`);
133
+ }
134
+
135
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
136
+ }
137
+
138
+ case 'search': {
139
+ if (!args.query) {
140
+ return {
141
+ content: [{ type: 'text', text: 'search requires --query' }],
142
+ isError: true
143
+ };
144
+ }
145
+ const results = await search(ctx.hub, repo, args.query);
146
+ log.info(`search: "${args.query}" -> ${results.length} results`);
147
+
148
+ const lines = [`## Search: "${args.query}"`, ''];
149
+ for (const r of results) {
150
+ lines.push(`- [${r.type}] #${r.number}: ${r.title}`);
151
+ }
152
+ if (!results.length) lines.push('No results found.');
153
+
154
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
155
+ }
156
+
157
+ case 'patterns': {
158
+ // Patterns delegates to packages/knowledge (Phase 6)
159
+ // For now, return a placeholder until knowledge package exists
160
+ try {
161
+ const { patterns } = await import('loreli/knowledge');
162
+ const opts = {};
163
+ if (args.category) opts.category = args.category;
164
+ if (args.threshold) opts.threshold = args.threshold;
165
+ const found = await patterns(ctx.hub, repo, opts);
166
+
167
+ const lines = ['## Review Patterns', ''];
168
+ for (const p of found) {
169
+ lines.push(`### ${p.summary} (${p.category}, ${p.count} occurrences)`);
170
+ for (const ref of p.refs) {
171
+ lines.push(` - PR #${ref.pr}: "${ref.excerpt}"`);
172
+ }
173
+ lines.push('');
174
+ }
175
+ if (!found.length) lines.push('No patterns detected above threshold.');
176
+
177
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
178
+ } catch {
179
+ return {
180
+ content: [{ type: 'text', text: 'Knowledge package not yet available. Patterns will be enabled after packages/knowledge is implemented.' }]
181
+ };
182
+ }
183
+ }
184
+
185
+ default:
186
+ return {
187
+ content: [{ type: 'text', text: `Unknown action: ${args.action}. Use blame, history, search, or patterns.` }],
188
+ isError: true
189
+ };
190
+ }
191
+ } catch (err) {
192
+ log.error(`context/${args.action} failed: ${err.message}`);
193
+ return {
194
+ content: [{ type: 'text', text: `context/${args.action} failed: ${err.message}` }],
195
+ isError: true
196
+ };
197
+ }
198
+ }
199
+ }
200
+ };