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