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.
- package/README.md +66 -26
- package/package.json +17 -14
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +33 -5
- package/packages/agent/README.md +107 -18
- package/packages/agent/src/backends/claude.js +111 -11
- package/packages/agent/src/backends/codex.js +78 -5
- package/packages/agent/src/backends/cursor.js +104 -27
- package/packages/agent/src/backends/index.js +162 -5
- package/packages/agent/src/cli.js +80 -3
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +39 -34
- package/packages/agent/src/models.js +24 -6
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +156 -91
- package/packages/config/src/defaults.js +32 -21
- package/packages/config/src/index.js +33 -2
- package/packages/config/src/schema.js +57 -39
- package/packages/hub/src/github.js +59 -20
- package/packages/identity/README.md +1 -1
- package/packages/identity/src/index.js +2 -2
- package/packages/knowledge/README.md +86 -106
- package/packages/knowledge/src/index.js +56 -225
- package/packages/mcp/README.md +51 -7
- package/packages/mcp/instructions.md +6 -1
- package/packages/mcp/scaffolding/loreli.yml +115 -77
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
- package/packages/mcp/src/index.js +45 -16
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +34 -13
- package/packages/mcp/src/tools/context.js +3 -2
- package/packages/mcp/src/tools/github.js +11 -47
- package/packages/mcp/src/tools/hitl.js +19 -6
- package/packages/mcp/src/tools/index.js +2 -1
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +159 -90
- package/packages/mcp/src/tools/status.js +5 -2
- package/packages/mcp/src/tools/work.js +18 -8
- package/packages/orchestrator/src/index.js +345 -79
- package/packages/planner/README.md +84 -1
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +326 -111
- package/packages/review/README.md +2 -2
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +196 -76
- package/packages/risk/README.md +81 -22
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +44 -33
- package/packages/tmux/src/index.js +61 -12
- package/packages/workflow/README.md +18 -14
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +191 -12
- package/packages/workspace/README.md +2 -2
- 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
|
|
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
|
|
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,
|
|
367
|
-
'- "kill": Immediate forced termination
|
|
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 "
|
|
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'
|
|
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
|
|
60
|
+
const repo = repoOf(ctx);
|
|
60
61
|
if (!repo) {
|
|
61
62
|
return {
|
|
62
|
-
content: [{ type: 'text', text: 'No repository configured.
|
|
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: ['
|
|
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
|
|
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: ['
|
|
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
|
|
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
|
+
}
|