loreli 1.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 (63) hide show
  1. package/README.md +66 -26
  2. package/package.json +17 -14
  3. package/packages/action/prompts/action.md +172 -0
  4. package/packages/action/src/index.js +33 -5
  5. package/packages/agent/README.md +107 -18
  6. package/packages/agent/src/backends/claude.js +111 -11
  7. package/packages/agent/src/backends/codex.js +78 -5
  8. package/packages/agent/src/backends/cursor.js +104 -27
  9. package/packages/agent/src/backends/index.js +162 -5
  10. package/packages/agent/src/cli.js +80 -3
  11. package/packages/agent/src/discover.js +396 -0
  12. package/packages/agent/src/factory.js +39 -34
  13. package/packages/agent/src/models.js +24 -6
  14. package/packages/classify/README.md +136 -0
  15. package/packages/classify/prompts/blocker.md +12 -0
  16. package/packages/classify/prompts/feedback.md +14 -0
  17. package/packages/classify/prompts/pane-state.md +20 -0
  18. package/packages/classify/src/index.js +81 -0
  19. package/packages/config/README.md +156 -91
  20. package/packages/config/src/defaults.js +32 -21
  21. package/packages/config/src/index.js +33 -2
  22. package/packages/config/src/schema.js +57 -39
  23. package/packages/hub/src/github.js +59 -20
  24. package/packages/identity/README.md +1 -1
  25. package/packages/identity/src/index.js +2 -2
  26. package/packages/knowledge/README.md +86 -106
  27. package/packages/knowledge/src/index.js +56 -225
  28. package/packages/mcp/README.md +51 -7
  29. package/packages/mcp/instructions.md +6 -1
  30. package/packages/mcp/scaffolding/loreli.yml +115 -77
  31. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
  32. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
  33. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
  34. package/packages/mcp/src/index.js +45 -16
  35. package/packages/mcp/src/tools/agent-context.js +44 -0
  36. package/packages/mcp/src/tools/agents.js +34 -13
  37. package/packages/mcp/src/tools/context.js +3 -2
  38. package/packages/mcp/src/tools/github.js +11 -47
  39. package/packages/mcp/src/tools/hitl.js +19 -6
  40. package/packages/mcp/src/tools/index.js +2 -1
  41. package/packages/mcp/src/tools/refactor.js +227 -0
  42. package/packages/mcp/src/tools/repo.js +44 -0
  43. package/packages/mcp/src/tools/start.js +159 -90
  44. package/packages/mcp/src/tools/status.js +5 -2
  45. package/packages/mcp/src/tools/work.js +18 -8
  46. package/packages/orchestrator/src/index.js +345 -79
  47. package/packages/planner/README.md +84 -1
  48. package/packages/planner/prompts/plan-reviewer.md +109 -0
  49. package/packages/planner/prompts/planner.md +191 -0
  50. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  51. package/packages/planner/src/index.js +326 -111
  52. package/packages/review/README.md +2 -2
  53. package/packages/review/prompts/reviewer.md +158 -0
  54. package/packages/review/src/index.js +196 -76
  55. package/packages/risk/README.md +81 -22
  56. package/packages/risk/prompts/risk.md +272 -0
  57. package/packages/risk/src/index.js +44 -33
  58. package/packages/tmux/src/index.js +61 -12
  59. package/packages/workflow/README.md +18 -14
  60. package/packages/workflow/prompts/preamble.md +14 -0
  61. package/packages/workflow/src/index.js +191 -12
  62. package/packages/workspace/README.md +2 -2
  63. package/packages/workspace/src/index.js +69 -18
@@ -88,7 +88,10 @@ export default {
88
88
  }
89
89
 
90
90
  const { role } = args;
91
- const modelAlias = args.model ?? ctx.config?.get?.('model') ?? 'balanced';
91
+ const modelAlias = args.model
92
+ ?? ctx.config?.get?.(`workflows.${role}.model`)
93
+ ?? ctx.config?.get?.('model')
94
+ ?? 'balanced';
92
95
 
93
96
  // Theme coherence: explicit override > peer inheritance > random pick from config.
94
97
  // When multiple themes are configured, the first agent picks randomly
@@ -111,7 +114,7 @@ export default {
111
114
  // Resolve model alias to concrete identifier via backend-specific config.
112
115
  // Virtual providers (cursor-openai, cursor-anthropic) normalize to the
113
116
  // canonical vendor for config lookup (backends.cursor.models.balanced.openai).
114
- const model = models.resolve(modelAlias, backendName, vendor(provider), ctx.config);
117
+ const model = models.resolve(modelAlias, backendName, vendor(provider), ctx.config, ctx.backendRegistry?.models);
115
118
  log.info(`add_agent: provider=${provider} role=${role} model=${model} backend=${backendName} theme=${theme}`);
116
119
 
117
120
  // Seed taken names before first acquire — one-time cost, zero
@@ -358,25 +361,26 @@ export default {
358
361
  stop: {
359
362
  title: 'Stop Agent',
360
363
  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
+ 'Stop agents or halt the entire system. Three escalation levels:',
364
365
  '',
365
366
  '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.'
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.'
368
372
  ].join('\n'),
369
373
  schema: {
370
374
  type: 'object',
371
375
  properties: {
372
376
  action: {
373
377
  type: 'string',
374
- description: 'Operation: "shutdown" or "kill".',
375
- enum: ['shutdown', 'kill']
378
+ description: 'Operation: "shutdown", "kill", or "halt".',
379
+ enum: ['shutdown', 'kill', 'halt']
376
380
  },
377
- name: { type: 'string', description: 'Agent identity name to stop.' }
381
+ name: { type: 'string', description: 'Agent identity name to stop (not required for "halt").' }
378
382
  },
379
- required: ['action', 'name']
383
+ required: ['action']
380
384
  },
381
385
 
382
386
  /**
@@ -387,13 +391,30 @@ export default {
387
391
  async exec(args, ctx) {
388
392
  const { action, name } = args;
389
393
 
390
- if (action !== 'shutdown' && action !== 'kill') {
394
+ if (action !== 'shutdown' && action !== 'kill' && action !== 'halt') {
391
395
  return {
392
- content: [{ type: 'text', text: `Unknown action: "${action}". Valid: shutdown, kill` }],
396
+ content: [{ type: 'text', text: `Unknown action: "${action}". Valid: shutdown, kill, halt` }],
393
397
  isError: true
394
398
  };
395
399
  }
396
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
+
397
418
  check.name(name);
398
419
 
399
420
  if (ctx.orchestrator?.reconcile) await ctx.orchestrator.reconcile();
@@ -15,6 +15,7 @@ import { hub as createHub } from 'loreli/hub';
15
15
  import { blame, history, search, resolve } from 'loreli/context';
16
16
  import * as workspace from 'loreli/workspace';
17
17
  import { logger } from 'loreli/log';
18
+ import { repoOf } from './repo.js';
18
19
 
19
20
  const log = logger('context');
20
21
 
@@ -56,10 +57,10 @@ export default {
56
57
  }
57
58
  }
58
59
 
59
- const repo = ctx.repo;
60
+ const repo = repoOf(ctx);
60
61
  if (!repo) {
61
62
  return {
62
- content: [{ type: 'text', text: 'No repository configured. Run start first or set LORELI_REPO.' }],
63
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
63
64
  isError: true
64
65
  };
65
66
  }
@@ -3,6 +3,7 @@ import { commitAndPush, pathFor } from 'loreli/workspace';
3
3
  import { logger } from 'loreli/log';
4
4
  import { mark, excise, has, parse } from 'loreli/marker';
5
5
  import { trace, output } from 'loreli/agent';
6
+ import { context } from './agent-context.js';
6
7
  import { execFile } from 'node:child_process';
7
8
  import { promisify } from 'node:util';
8
9
 
@@ -67,51 +68,6 @@ const EVENT_LABEL_INVERSE = {
67
68
  REQUEST_CHANGES: 'loreli:approved'
68
69
  };
69
70
 
70
- /**
71
- * Resolve agent context from session storage.
72
- *
73
- * Reads the agent's persisted session data using the sessionId and
74
- * agentName from ctx (hydrated at MCP server startup from env vars).
75
- * Reconstructs a live Identity instance so hub stamping and label
76
- * methods work correctly. Throws when context is not available — this
77
- * means the tool was called from a non-agent MCP client.
78
- *
79
- * @param {object} ctx - Execution context with sessionId, agentName, storage.
80
- * @returns {Promise<{repo: string, identity: Identity, role: string, task: object|null, issue: number|null}>}
81
- * @throws {Error} When agent context is not available.
82
- */
83
- async function context(ctx) {
84
- if (!ctx.sessionId || !ctx.agentName) {
85
- throw new Error('Agent context not available. This tool can only be called by spawned agents.');
86
- }
87
-
88
- const data = await ctx.storage.load(ctx.sessionId, ctx.agentName);
89
- if (!data) {
90
- throw new Error(`Session data not found for agent "${ctx.agentName}" in session "${ctx.sessionId}".`);
91
- }
92
-
93
- // Reconstruct a live Identity from stored JSON — constructor expects
94
- // `name` as the character name (without instance suffix), while toJSON
95
- // stores `name` as the full name and `character` as the raw character.
96
- const raw = data.identity;
97
- const identity = new Identity({
98
- name: raw.character ?? raw.name,
99
- instance: raw.instance ?? 0,
100
- faction: raw.faction,
101
- provider: raw.provider,
102
- model: raw.model,
103
- theme: raw.theme
104
- });
105
-
106
- return {
107
- repo: data.repo,
108
- identity,
109
- role: data.role,
110
- task: data.task ?? null,
111
- issue: data.claimedIssue ?? data.task?.issue ?? null,
112
- paneId: data.paneId ?? null
113
- };
114
- }
115
71
 
116
72
  /**
117
73
  * Capture tmux pane output and build a trace block for the given agent.
@@ -429,7 +385,12 @@ export default {
429
385
 
430
386
  const cat = await ctx.hub.category(agent.repo, 'Loreli');
431
387
  const scoped = ctx.hub.as(agent.identity, agent.role);
432
- const labels = agent.identity.labels?.(agent.role) ?? ['loreli'];
388
+ const labels = [...(agent.identity.labels?.(agent.role) ?? ['loreli'])];
389
+
390
+ const feedbackData = parse(args.body, 'feedback');
391
+ if (feedbackData?.category) {
392
+ labels.push(`loreli:feedback:${feedbackData.category}`);
393
+ }
433
394
 
434
395
  let body = args.body;
435
396
  const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'plan/create' });
@@ -524,7 +485,10 @@ export default {
524
485
  if (args.decision === 'changes-requested') {
525
486
  try {
526
487
  const { classify } = await import('loreli/knowledge');
527
- const { category, confidence } = classify(args.comment);
488
+ const { category, confidence } = await classify(args.comment, {
489
+ backends: ctx.backendRegistry ?? ctx.orchestrator?.backendRegistry,
490
+ config: ctx.config ?? ctx.orchestrator?.cfg
491
+ });
528
492
  if (confidence > 0) {
529
493
  const provider = agent.identity?.provider ?? 'unknown';
530
494
  feedbackTag = mark('feedback', {
@@ -1,6 +1,7 @@
1
1
  import { logger } from 'loreli/log';
2
2
  import { check } from 'loreli/config';
3
3
  import { has, parse } from 'loreli/marker';
4
+ import { select } from './repo.js';
4
5
 
5
6
  const log = logger('hitl');
6
7
 
@@ -20,7 +21,7 @@ export default {
20
21
  repo: { type: 'string', description: 'Target repository (owner/name).' },
21
22
  pr: { type: 'number', description: 'Pull request number.' }
22
23
  },
23
- required: ['repo', 'pr']
24
+ required: ['pr']
24
25
  },
25
26
  /**
26
27
  * @param {object} args - Tool arguments.
@@ -28,9 +29,15 @@ export default {
28
29
  * @returns {Promise<object>} HITL result with reviewer list.
29
30
  */
30
31
  async exec(args, ctx) {
31
- check.repo(args.repo);
32
32
  check.positive(args.pr, 'PR number');
33
- const { repo, pr } = args;
33
+ const repo = select(args.repo, ctx);
34
+ const pr = args.pr;
35
+ if (!repo) {
36
+ return {
37
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
38
+ isError: true
39
+ };
40
+ }
34
41
  const reviewers = ctx.config?.get?.('reviewers') ?? [];
35
42
 
36
43
  if (!reviewers.length) {
@@ -87,7 +94,7 @@ export default {
87
94
  pr: { type: 'number', description: 'Pull request number.' },
88
95
  since: { type: 'string', description: 'ISO timestamp to filter comments after (hitlAt).' }
89
96
  },
90
- required: ['repo', 'pr', 'since']
97
+ required: ['pr', 'since']
91
98
  },
92
99
  /**
93
100
  * @param {object} args - Tool arguments.
@@ -95,9 +102,15 @@ export default {
95
102
  * @returns {Promise<object>} Watch result with status and optional feedback.
96
103
  */
97
104
  async exec(args, ctx) {
98
- check.repo(args.repo);
99
105
  check.positive(args.pr, 'PR number');
100
- const { repo, pr, since } = args;
106
+ const repo = select(args.repo, ctx);
107
+ const { pr, since } = args;
108
+ if (!repo) {
109
+ return {
110
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
111
+ isError: true
112
+ };
113
+ }
101
114
 
102
115
  // Inline: pending() was a 3-line hub method — timestamp filter on comments()
103
116
  const allComments = await ctx.hub.comments(repo, pr);
@@ -5,6 +5,7 @@ import status from './status.js';
5
5
  import hitl from './hitl.js';
6
6
  import github from './github.js';
7
7
  import context from './context.js';
8
+ import refactor from './refactor.js';
8
9
 
9
10
  /**
10
11
  * All tool definitions aggregated from individual modules.
@@ -12,6 +13,6 @@ import context from './context.js';
12
13
  * @returns {Record<string, object>} Map of tool name to tool definition.
13
14
  */
14
15
  export function allTools() {
15
- return { ...start, ...agents, ...work, ...status, ...hitl, ...github, ...context };
16
+ return { ...start, ...agents, ...work, ...status, ...hitl, ...github, ...context, ...refactor };
16
17
  }
17
18
 
@@ -0,0 +1,227 @@
1
+ import { context } from './agent-context.js';
2
+ import { logger } from 'loreli/log';
3
+
4
+ const log = logger('refactor-tool');
5
+
6
+ /**
7
+ * Valid observation categories.
8
+ *
9
+ * @type {Set<string>}
10
+ */
11
+ const KINDS = new Set(['bug', 'refactor', 'smell', 'debt']);
12
+
13
+ /**
14
+ * Valid impact levels.
15
+ *
16
+ * @type {Set<string>}
17
+ */
18
+ const IMPACTS = new Set(['low', 'medium', 'high']);
19
+
20
+ /**
21
+ * Validates file path entries against the allowed formats:
22
+ * plain path or path with GitHub-style line range hint.
23
+ *
24
+ * @type {RegExp}
25
+ */
26
+ const FILE_PATTERN = /^[^\s#]+(?:#R\d+-R\d+)?$/;
27
+
28
+ /**
29
+ * Check whether a normalized title appears in an existing item's title.
30
+ *
31
+ * @param {string} needle - Normalized title to search for.
32
+ * @param {string} haystack - Item title to compare against.
33
+ * @returns {boolean} True when considered a match.
34
+ */
35
+ function titleMatch(needle, haystack) {
36
+ return haystack.toLowerCase().includes(needle);
37
+ }
38
+
39
+ /**
40
+ * Build a structured discussion body from the refactor observation.
41
+ *
42
+ * @param {object} agent - Resolved agent context.
43
+ * @param {object} args - Tool arguments.
44
+ * @returns {string} Formatted Markdown body.
45
+ */
46
+ function body(agent, args) {
47
+ const lines = [
48
+ `## ${args.kind}: ${args.title}`,
49
+ '',
50
+ args.description,
51
+ '',
52
+ `**Kind**: ${args.kind}`,
53
+ `**Impact**: ${args.impact ?? 'medium'}`,
54
+ `**Reported by**: ${agent.identity.name} (${agent.role})`,
55
+ ];
56
+
57
+ if (agent.issue) lines.push(`**Source issue**: #${agent.issue}`);
58
+
59
+ if (args.files?.length) {
60
+ lines.push('', '### Affected files', '');
61
+ for (const f of args.files) lines.push(`- \`${f}\``);
62
+ }
63
+
64
+ return lines.join('\n');
65
+ }
66
+
67
+ /**
68
+ * Refactor tool — opportunistic improvement intake for action agents.
69
+ *
70
+ * Creates a labeled discussion in the Loreli category after dedup
71
+ * checks. Existing planner review/revise/promote handles the rest.
72
+ */
73
+ export default {
74
+ refactor: {
75
+ title: 'Refactor',
76
+ description: [
77
+ 'Flag a bug, code smell, or tech debt you encounter during implementation.',
78
+ 'Call immediately on discovery — do not wait until your PR is complete.',
79
+ 'Provide kind, title, and description with stable identifiers (function names, class names, patterns).',
80
+ 'Optionally include affected files (with optional #R<start>-R<end> range hints) and impact level.',
81
+ 'The tool checks for duplicates and creates a Loreli discussion for planning. Action agents only.'
82
+ ].join(' '),
83
+ schema: {
84
+ type: 'object',
85
+ properties: {
86
+ kind: {
87
+ type: 'string',
88
+ description: 'Category of observation.',
89
+ enum: ['bug', 'refactor', 'smell', 'debt']
90
+ },
91
+ title: {
92
+ type: 'string',
93
+ description: 'Concise improvement title.'
94
+ },
95
+ description: {
96
+ type: 'string',
97
+ description: 'Problem statement with stable identifiers (function names, class names, error messages). Not line numbers.'
98
+ },
99
+ files: {
100
+ type: 'array',
101
+ items: { type: 'string' },
102
+ description: 'Affected file paths. Optionally append #R<start>-R<end> for line range hints, e.g. "src/hub.js#R40-R50".'
103
+ },
104
+ impact: {
105
+ type: 'string',
106
+ description: 'Estimated impact: low, medium, or high. Defaults to medium.',
107
+ enum: ['low', 'medium', 'high']
108
+ }
109
+ },
110
+ required: ['kind', 'title', 'description']
111
+ },
112
+
113
+ /**
114
+ * @param {object} args - Tool arguments.
115
+ * @param {object} ctx - Execution context.
116
+ * @returns {Promise<object>} MCP tool result.
117
+ */
118
+ async exec(args, ctx) {
119
+ if (!KINDS.has(args.kind)) {
120
+ return {
121
+ content: [{ type: 'text', text: `Invalid kind: "${args.kind}". Valid: ${[...KINDS].join(', ')}` }],
122
+ isError: true
123
+ };
124
+ }
125
+
126
+ if (!args.title?.trim()) {
127
+ return { content: [{ type: 'text', text: 'title is required.' }], isError: true };
128
+ }
129
+
130
+ if (!args.description?.trim()) {
131
+ return { content: [{ type: 'text', text: 'description is required.' }], isError: true };
132
+ }
133
+
134
+ if (args.impact && !IMPACTS.has(args.impact)) {
135
+ return {
136
+ content: [{ type: 'text', text: `Invalid impact: "${args.impact}". Valid: ${[...IMPACTS].join(', ')}` }],
137
+ isError: true
138
+ };
139
+ }
140
+
141
+ if (args.files?.length) {
142
+ for (const f of args.files) {
143
+ if (!FILE_PATTERN.test(f)) {
144
+ return {
145
+ content: [{ type: 'text', text: `Invalid file entry: "${f}". Use "<path>" or "<path>#R<start>-R<end>".` }],
146
+ isError: true
147
+ };
148
+ }
149
+ }
150
+ }
151
+
152
+ if (!ctx.hub) {
153
+ return { content: [{ type: 'text', text: 'Hub not initialized. Run start first.' }], isError: true };
154
+ }
155
+
156
+ let agent;
157
+ try {
158
+ agent = await context(ctx);
159
+ } catch (err) {
160
+ return { content: [{ type: 'text', text: err.message }], isError: true };
161
+ }
162
+
163
+ if (agent.role !== 'action') {
164
+ return {
165
+ content: [{ type: 'text', text: 'refactor is restricted to action agents.' }],
166
+ isError: true
167
+ };
168
+ }
169
+
170
+ // ── Dedup gate ────────────────────────────────────────
171
+ const normalized = args.title.trim().toLowerCase();
172
+ const cat = await ctx.hub.category(agent.repo, 'Loreli');
173
+
174
+ // 1. Search existing issues and PRs
175
+ try {
176
+ const existing = await ctx.hub.searchIssues(agent.repo, args.title);
177
+ const match = existing.find(function dup(i) { return titleMatch(normalized, i.title); });
178
+ if (match) {
179
+ log.info(`refactor: dedup hit via issue search — #${match.number}: ${match.title}`);
180
+ return {
181
+ content: [{ type: 'text', text: `Already tracked at #${match.number} (${match.url}). Skipping.` }]
182
+ };
183
+ }
184
+ } catch (err) {
185
+ log.debug(`refactor: issue search failed, proceeding: ${err.message}`);
186
+ }
187
+
188
+ // 2. Scan open Loreli discussions with loreli:refactor label
189
+ try {
190
+ const discussions = await ctx.hub.discussions(agent.repo, cat.id);
191
+ const match = discussions.find(function dup(d) {
192
+ return !d.closed &&
193
+ d.labels.includes('loreli:refactor') &&
194
+ titleMatch(normalized, d.title);
195
+ });
196
+ if (match) {
197
+ log.info(`refactor: dedup hit via discussion scan — #${match.number}: ${match.title}`);
198
+ return {
199
+ content: [{ type: 'text', text: `Already tracked at discussion #${match.number} (${match.url}). Skipping.` }]
200
+ };
201
+ }
202
+ } catch (err) {
203
+ log.debug(`refactor: discussion scan failed, proceeding: ${err.message}`);
204
+ }
205
+
206
+ // ── Create discussion ─────────────────────────────────
207
+ const scoped = ctx.hub.as(agent.identity, agent.role);
208
+ const labels = [...(agent.identity.labels?.(agent.role) ?? ['loreli']), 'loreli:refactor'];
209
+
210
+ const disc = await scoped.discuss(agent.repo, {
211
+ title: `[${args.kind}] ${args.title}`,
212
+ body: body(agent, args),
213
+ categoryId: cat.id,
214
+ repositoryId: cat.repositoryId,
215
+ labels
216
+ });
217
+
218
+ log.info(`refactor: created discussion #${disc.number} — [${args.kind}] ${args.title}`);
219
+ return {
220
+ content: [{
221
+ type: 'text',
222
+ text: `Refactor flagged as discussion #${disc.number}: [${args.kind}] ${args.title}\nURL: ${disc.url}`
223
+ }]
224
+ };
225
+ }
226
+ }
227
+ };
@@ -0,0 +1,44 @@
1
+ import { check } from 'loreli/config';
2
+
3
+ /**
4
+ * Resolve repository context from the shared execution context.
5
+ *
6
+ * Priority order:
7
+ * 1. Runtime `ctx.repo` (set by start/session hydration)
8
+ * 2. Config fallback (`repo` key from loreli.yml or env)
9
+ *
10
+ * @param {object} ctx - Tool execution context.
11
+ * @returns {string|null} Repository slug (`owner/name`) or null.
12
+ */
13
+ export function repoOf(ctx) {
14
+ if (ctx?.repo) return ctx.repo;
15
+
16
+ const repo = ctx?.config?.get?.('repo') ?? null;
17
+ if (!repo) return null;
18
+
19
+ // Validate env/config fallback immediately so tool errors are explicit.
20
+ check.repo(repo);
21
+ if (ctx && typeof ctx === 'object') ctx.repo = repo;
22
+ return repo;
23
+ }
24
+
25
+ /**
26
+ * Resolve a repository from tool arguments with shared fallback behavior.
27
+ *
28
+ * Priority order:
29
+ * 1. Explicit `--repo` argument
30
+ * 2. `repoOf(ctx)` fallback
31
+ *
32
+ * @param {string|undefined} arg - Explicit repo argument from tool input.
33
+ * @param {object} ctx - Tool execution context.
34
+ * @returns {string|null} Repository slug (`owner/name`) or null.
35
+ */
36
+ export function select(arg, ctx) {
37
+ if (arg) {
38
+ check.repo(arg);
39
+ if (ctx && typeof ctx === 'object') ctx.repo = arg;
40
+ return arg;
41
+ }
42
+
43
+ return repoOf(ctx);
44
+ }