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.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- 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 +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- 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 +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- 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 +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
import { Identity } from 'loreli/identity';
|
|
2
|
+
import { commitAndPush, pathFor } from 'loreli/workspace';
|
|
3
|
+
import { logger } from 'loreli/log';
|
|
4
|
+
import { mark, excise, has, parse } from 'loreli/marker';
|
|
5
|
+
import { trace, output } from 'loreli/agent';
|
|
6
|
+
import { context } from './agent-context.js';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const log = logger('github-tools');
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In-memory self-review checkpoints keyed by session/agent.
|
|
15
|
+
* Requires an explicit preview call before confirm=true can proceed.
|
|
16
|
+
*
|
|
17
|
+
* @type {Map<string, {head: string, base: string, at: string}>}
|
|
18
|
+
*/
|
|
19
|
+
const SELF_REVIEW_PENDING = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Valid actions for the plan tool.
|
|
23
|
+
*
|
|
24
|
+
* @type {Set<string>}
|
|
25
|
+
*/
|
|
26
|
+
const PLAN_ACTIONS = new Set(['create', 'revise', 'verdict', 'escalate', 'resolve']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Valid decisions for the verdict action.
|
|
30
|
+
*
|
|
31
|
+
* @type {Set<string>}
|
|
32
|
+
*/
|
|
33
|
+
const DECISIONS = new Set(['approved', 'changes-requested', 'rejected']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Valid actions for the pr tool.
|
|
37
|
+
*
|
|
38
|
+
* @type {Set<string>}
|
|
39
|
+
*/
|
|
40
|
+
const PR_ACTIONS = new Set(['create', 'review']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Valid review events for the pr tool.
|
|
44
|
+
*
|
|
45
|
+
* @type {Set<string>}
|
|
46
|
+
*/
|
|
47
|
+
const EVENTS = new Set(['APPROVE', 'REQUEST_CHANGES']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Maps review events to the loreli label that should be applied.
|
|
51
|
+
* Mirrors the plan verdict's label behavior for PRs.
|
|
52
|
+
*
|
|
53
|
+
* @type {Record<string, string>}
|
|
54
|
+
*/
|
|
55
|
+
const EVENT_LABELS = {
|
|
56
|
+
APPROVE: 'loreli:approved',
|
|
57
|
+
REQUEST_CHANGES: 'loreli:changes-requested'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The inverse label for each event — removed when the new label is
|
|
62
|
+
* applied so a PR never carries both at the same time.
|
|
63
|
+
*
|
|
64
|
+
* @type {Record<string, string>}
|
|
65
|
+
*/
|
|
66
|
+
const EVENT_LABEL_INVERSE = {
|
|
67
|
+
APPROVE: 'loreli:changes-requested',
|
|
68
|
+
REQUEST_CHANGES: 'loreli:approved'
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Capture tmux pane output and build a trace block for the given agent.
|
|
74
|
+
* Reused across plan, pr, and comment tool actions.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} agent - Agent context with identity, paneId.
|
|
77
|
+
* @param {object} [opts] - Options.
|
|
78
|
+
* @param {string} [opts.reasoning] - Agent reasoning text.
|
|
79
|
+
* @param {string} [opts.prefix] - Log prefix for debug messages.
|
|
80
|
+
* @returns {Promise<string>} Formatted trace block, or empty string.
|
|
81
|
+
*/
|
|
82
|
+
async function captureTrace(agent, opts = {}) {
|
|
83
|
+
const { reasoning, prefix = 'trace' } = opts;
|
|
84
|
+
let captured = '';
|
|
85
|
+
if (agent.paneId) {
|
|
86
|
+
try {
|
|
87
|
+
const { Tmux } = await import('loreli/tmux');
|
|
88
|
+
const tmux = new Tmux();
|
|
89
|
+
captured = await tmux.capture(agent.paneId);
|
|
90
|
+
captured = output.clean(captured);
|
|
91
|
+
} catch (err) { log.debug(`${prefix}: pane capture failed: ${err.message}`); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const usage = captured ? trace.tokens(captured) : null;
|
|
95
|
+
return trace.format(agent.identity.name, {
|
|
96
|
+
reasoning,
|
|
97
|
+
output: captured || undefined,
|
|
98
|
+
usage,
|
|
99
|
+
model: agent.identity.model
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a stable key for per-agent pending state.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} ctx - Execution context.
|
|
107
|
+
* @returns {string|null} Pending-state key, or null when unavailable.
|
|
108
|
+
*/
|
|
109
|
+
function pendingKey(ctx) {
|
|
110
|
+
if (!ctx.sessionId || !ctx.agentName) return null;
|
|
111
|
+
return `${ctx.sessionId}:${ctx.agentName}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Truncate long command output for MCP responses.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} text - Raw output.
|
|
118
|
+
* @param {number} [max=4000] - Max output characters.
|
|
119
|
+
* @returns {string} Truncated output.
|
|
120
|
+
*/
|
|
121
|
+
function clip(text, max = 4000) {
|
|
122
|
+
if (!text) return '';
|
|
123
|
+
if (text.length <= max) return text;
|
|
124
|
+
return `${text.slice(0, max)}\n...[truncated]`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Determine whether a GitHub API error indicates an existing PR for head/base.
|
|
129
|
+
*
|
|
130
|
+
* @param {any} err - Error object from hub.propose().
|
|
131
|
+
* @returns {boolean} True when the API reports an already-existing PR.
|
|
132
|
+
*/
|
|
133
|
+
function isDuplicatePrError(err) {
|
|
134
|
+
return err?.status === 422 &&
|
|
135
|
+
JSON.stringify(err?.response?.data ?? '').includes('already exists');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Determine whether a GitHub API error indicates invalid PR head/base refs.
|
|
140
|
+
*
|
|
141
|
+
* @param {any} err - Error object from hub.propose().
|
|
142
|
+
* @returns {boolean} True when pull request head/base refs are invalid.
|
|
143
|
+
*/
|
|
144
|
+
function isInvalidRefError(err) {
|
|
145
|
+
if (err?.status !== 422) return false;
|
|
146
|
+
const errors = err?.response?.data?.errors;
|
|
147
|
+
if (!Array.isArray(errors)) return false;
|
|
148
|
+
|
|
149
|
+
return errors.some(function invalidRef(e) {
|
|
150
|
+
return e?.resource === 'PullRequest' &&
|
|
151
|
+
e?.code === 'invalid' &&
|
|
152
|
+
(e?.field === 'head' || e?.field === 'base');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Determine whether an API error indicates a missing resource.
|
|
158
|
+
*
|
|
159
|
+
* @param {any} err - Error object from hub calls.
|
|
160
|
+
* @returns {boolean} True when the API response is 404/not found.
|
|
161
|
+
*/
|
|
162
|
+
function isNotFoundError(err) {
|
|
163
|
+
return err?.status === 404;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Determine whether a branch creation request raced with another creator.
|
|
168
|
+
*
|
|
169
|
+
* @param {any} err - Error object from hub.fork().
|
|
170
|
+
* @returns {boolean} True when the branch already exists remotely.
|
|
171
|
+
*/
|
|
172
|
+
function isBranchExistsError(err) {
|
|
173
|
+
if (err?.status !== 422) return false;
|
|
174
|
+
const body = JSON.stringify(err?.response?.data ?? '').toLowerCase();
|
|
175
|
+
if (body.includes('reference already exists')) return true;
|
|
176
|
+
|
|
177
|
+
const errors = err?.response?.data?.errors;
|
|
178
|
+
if (!Array.isArray(errors)) return false;
|
|
179
|
+
|
|
180
|
+
return errors.some(function alreadyExists(e) {
|
|
181
|
+
return e?.code === 'already_exists' || e?.message?.toLowerCase?.().includes('already exists');
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Ensure the requested PR base branch exists remotely.
|
|
187
|
+
* When missing, create it from the repository default branch.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} ctx - Execution context with hub.
|
|
190
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
191
|
+
* @param {string} base - Target base branch name.
|
|
192
|
+
* @returns {Promise<void>}
|
|
193
|
+
*/
|
|
194
|
+
async function ensureBase(ctx, repo, base) {
|
|
195
|
+
if (typeof ctx?.hub?.branch !== 'function') return;
|
|
196
|
+
if (typeof ctx?.hub?.repo !== 'function') return;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await ctx.hub.branch(repo, base);
|
|
200
|
+
return;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (!isNotFoundError(err)) throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof ctx?.hub?.fork !== 'function') {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Configured merge base "${base}" does not exist and this hub cannot create branches.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const info = await ctx.hub.repo(repo);
|
|
212
|
+
const from = info?.default_branch ?? 'main';
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await ctx.hub.fork(repo, { name: base, from });
|
|
216
|
+
log.info(`pr/create: provisioned missing base branch "${base}" from "${from}"`);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!isBranchExistsError(err)) throw err;
|
|
219
|
+
log.info(`pr/create: base branch "${base}" already exists (race), continuing`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await ctx.hub.branch(repo, base);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a git command in a workspace and return stdout.
|
|
227
|
+
* Returns an empty string on failure so preview generation degrades safely.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} cwd - Workspace path.
|
|
230
|
+
* @param {string[]} args - Git args (without `-C`).
|
|
231
|
+
* @returns {Promise<string>} Trimmed stdout, or empty string on error.
|
|
232
|
+
*/
|
|
233
|
+
async function gitOut(cwd, args) {
|
|
234
|
+
try {
|
|
235
|
+
const { stdout } = await run('git', ['-C', cwd, ...args], {
|
|
236
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
237
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
238
|
+
});
|
|
239
|
+
return stdout.trim();
|
|
240
|
+
} catch {
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build a self-review summary for the current workspace.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} cwd - Workspace path.
|
|
249
|
+
* @param {string} base - Base branch for comparison.
|
|
250
|
+
* @returns {Promise<string>} Human-readable diff/stat preview.
|
|
251
|
+
*/
|
|
252
|
+
async function preview(cwd, base) {
|
|
253
|
+
const [status, staged, unstaged, committed] = await Promise.all([
|
|
254
|
+
gitOut(cwd, ['status', '--short']),
|
|
255
|
+
gitOut(cwd, ['diff', '--cached', '--stat']),
|
|
256
|
+
gitOut(cwd, ['diff', '--stat']),
|
|
257
|
+
gitOut(cwd, ['diff', '--stat', `origin/${base}...HEAD`])
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
const lines = ['Self-review preview before PR creation', `Workspace: ${cwd}`, `Base: ${base}`, ''];
|
|
261
|
+
|
|
262
|
+
lines.push('## Working tree status');
|
|
263
|
+
lines.push(status ? `\`\`\`\n${clip(status)}\n\`\`\`` : '_clean_');
|
|
264
|
+
lines.push('');
|
|
265
|
+
|
|
266
|
+
lines.push('## Staged diff stat');
|
|
267
|
+
lines.push(staged ? `\`\`\`\n${clip(staged)}\n\`\`\`` : '_none_');
|
|
268
|
+
lines.push('');
|
|
269
|
+
|
|
270
|
+
lines.push('## Unstaged diff stat');
|
|
271
|
+
lines.push(unstaged ? `\`\`\`\n${clip(unstaged)}\n\`\`\`` : '_none_');
|
|
272
|
+
lines.push('');
|
|
273
|
+
|
|
274
|
+
lines.push(`## Branch diff stat (origin/${base}...HEAD)`);
|
|
275
|
+
lines.push(committed ? `\`\`\`\n${clip(committed)}\n\`\`\`` : '_unavailable_');
|
|
276
|
+
|
|
277
|
+
return lines.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Run configured pre-PR validation command.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} cwd - Workspace path.
|
|
284
|
+
* @param {string} command - Validation shell command.
|
|
285
|
+
* @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string}>}
|
|
286
|
+
*/
|
|
287
|
+
async function validateBeforePr(cwd, command) {
|
|
288
|
+
try {
|
|
289
|
+
const { stdout, stderr } = await run('sh', ['-lc', command], {
|
|
290
|
+
cwd,
|
|
291
|
+
maxBuffer: 1024 * 1024 * 8,
|
|
292
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
code: 0,
|
|
297
|
+
stdout: stdout?.trim() ?? '',
|
|
298
|
+
stderr: stderr?.trim() ?? ''
|
|
299
|
+
};
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
code: Number(err?.code ?? 1),
|
|
304
|
+
stdout: err?.stdout?.trim?.() ?? '',
|
|
305
|
+
stderr: err?.stderr?.trim?.() ?? ''
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Agent-facing GitHub interaction tools.
|
|
312
|
+
*
|
|
313
|
+
* These tools are called by agents running in tmux panes via their
|
|
314
|
+
* `.mcp.json` connection to Loreli. Every tool reads repo, identity,
|
|
315
|
+
* role, and task context from the agent's session — zero identifier
|
|
316
|
+
* parameters. This eliminates hallucination vectors where an agent
|
|
317
|
+
* could affect the wrong repo or resource.
|
|
318
|
+
*/
|
|
319
|
+
export default {
|
|
320
|
+
plan: {
|
|
321
|
+
title: 'Plan',
|
|
322
|
+
description: [
|
|
323
|
+
'Manage plan discussions in the Loreli category. Use this tool for all discussion',
|
|
324
|
+
'operations — creating plans, revising after feedback, rendering review verdicts,',
|
|
325
|
+
'or escalating concerns. Your identity, repository, and target discussion are',
|
|
326
|
+
'resolved automatically from your session.',
|
|
327
|
+
'',
|
|
328
|
+
'Actions:',
|
|
329
|
+
'- "create": Create a new plan discussion. Provide title and body. Your agent stamp and labels are applied automatically. (Planner only)',
|
|
330
|
+
'- "revise": Submit a revised plan after reviewer feedback. Provide the updated body and a comment explaining your changes. The changes-requested label is removed automatically. (Planner only)',
|
|
331
|
+
'- "verdict": Render your review decision on a plan discussion. Provide decision ("approved" or "changes-requested") and a comment with your reasoning. The label is applied automatically. (Reviewer only)',
|
|
332
|
+
'- "escalate": Flag a concern or discovery for the planner. Provide title and body. Any agent role can escalate.',
|
|
333
|
+
'- "resolve": Close the objective with no work needed. Provide body explaining why. Creates a closed discussion as a visible record. (Planner only)'
|
|
334
|
+
].join('\n'),
|
|
335
|
+
schema: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
action: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
description: 'Operation to perform: "create", "revise", "verdict", "escalate", or "resolve".',
|
|
341
|
+
enum: ['create', 'revise', 'verdict', 'escalate', 'resolve']
|
|
342
|
+
},
|
|
343
|
+
title: { type: 'string', description: 'Discussion title (required for create, escalate).' },
|
|
344
|
+
body: { type: 'string', description: 'Discussion body or updated body (required for create, revise, escalate).' },
|
|
345
|
+
comment: { type: 'string', description: 'Comment explaining changes or verdict reasoning (required for revise, verdict).' },
|
|
346
|
+
decision: { type: 'string', description: 'Review decision: "approved" or "changes-requested" (required for verdict).' },
|
|
347
|
+
reasoning: { type: 'string', description: 'Summary of your approach, key decisions, and trade-offs. Included as a collapsible trace for human review.' }
|
|
348
|
+
},
|
|
349
|
+
required: ['action']
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {object} args - Tool arguments.
|
|
354
|
+
* @param {object} ctx - Execution context.
|
|
355
|
+
* @returns {Promise<object>} MCP tool result.
|
|
356
|
+
*/
|
|
357
|
+
async exec(args, ctx) {
|
|
358
|
+
const { action } = args;
|
|
359
|
+
|
|
360
|
+
if (!PLAN_ACTIONS.has(action)) {
|
|
361
|
+
return {
|
|
362
|
+
content: [{ type: 'text', text: `Unknown action: "${action}". Valid: ${[...PLAN_ACTIONS].join(', ')}` }],
|
|
363
|
+
isError: true
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!ctx.hub) {
|
|
368
|
+
return { content: [{ type: 'text', text: 'Hub not initialized. Run start first.' }], isError: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let agent;
|
|
372
|
+
try {
|
|
373
|
+
agent = await context(ctx);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (action === 'create') {
|
|
379
|
+
if (agent.role !== 'planner') {
|
|
380
|
+
return { content: [{ type: 'text', text: 'create is restricted to planner agents.' }], isError: true };
|
|
381
|
+
}
|
|
382
|
+
if (!args.title || !args.body) {
|
|
383
|
+
return { content: [{ type: 'text', text: 'create requires title and body.' }], isError: true };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const cat = await ctx.hub.category(agent.repo, 'Loreli');
|
|
387
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
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
|
+
}
|
|
394
|
+
|
|
395
|
+
let body = args.body;
|
|
396
|
+
const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'plan/create' });
|
|
397
|
+
if (traceBlock) body = `${body}\n\n${traceBlock}`;
|
|
398
|
+
|
|
399
|
+
const disc = await scoped.discuss(agent.repo, {
|
|
400
|
+
title: args.title,
|
|
401
|
+
body,
|
|
402
|
+
categoryId: cat.id,
|
|
403
|
+
repositoryId: cat.repositoryId,
|
|
404
|
+
labels
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
log.info(`plan/create: "${args.title}" → discussion #${disc.number}`);
|
|
408
|
+
return {
|
|
409
|
+
content: [{ type: 'text', text: `Created plan discussion #${disc.number}: ${args.title}\nURL: ${disc.url}` }]
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (action === 'revise') {
|
|
414
|
+
if (agent.role !== 'planner') {
|
|
415
|
+
return { content: [{ type: 'text', text: 'revise is restricted to planner agents.' }], isError: true };
|
|
416
|
+
}
|
|
417
|
+
if (!args.body || !args.comment) {
|
|
418
|
+
return { content: [{ type: 'text', text: 'revise requires body and comment.' }], isError: true };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const discussionId = agent.task?.discussionId;
|
|
422
|
+
if (!discussionId) {
|
|
423
|
+
return { content: [{ type: 'text', text: 'No discussion assigned. Task context missing discussionId.' }], isError: true };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
427
|
+
let body = args.body;
|
|
428
|
+
const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'plan/revise' });
|
|
429
|
+
if (traceBlock) body = `${body}\n\n${traceBlock}`;
|
|
430
|
+
await scoped.updateDiscussion(discussionId, { body });
|
|
431
|
+
await ctx.hub.removeDiscussionLabels(agent.repo, discussionId, ['loreli:changes-requested']);
|
|
432
|
+
await scoped.discussionComment(discussionId, args.comment);
|
|
433
|
+
|
|
434
|
+
log.info(`plan/revise: discussion ${discussionId} updated`);
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: 'text', text: `Revised plan discussion. Changes-requested label removed. Comment posted.` }]
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (action === 'verdict') {
|
|
441
|
+
if (agent.role !== 'reviewer') {
|
|
442
|
+
return { content: [{ type: 'text', text: 'verdict is restricted to reviewer agents.' }], isError: true };
|
|
443
|
+
}
|
|
444
|
+
if (!args.decision || !args.comment) {
|
|
445
|
+
return { content: [{ type: 'text', text: 'verdict requires decision and comment.' }], isError: true };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!DECISIONS.has(args.decision)) {
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: 'text', text: `Invalid decision: "${args.decision}". Valid: ${[...DECISIONS].join(', ')}` }],
|
|
451
|
+
isError: true
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const discussionId = agent.task?.discussionId;
|
|
456
|
+
if (!discussionId) {
|
|
457
|
+
return { content: [{ type: 'text', text: 'No discussion assigned. Task context missing discussionId.' }], isError: true };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Verify this reviewer is the one who claimed the discussion.
|
|
461
|
+
// Prevents agents dispatched through degraded fallback paths
|
|
462
|
+
// from overriding the original claimer's review.
|
|
463
|
+
const discNumber = agent.task?.discussion;
|
|
464
|
+
if (discNumber && ctx.hub.discussion) {
|
|
465
|
+
try {
|
|
466
|
+
const disc = await ctx.hub.discussion(agent.repo, discNumber);
|
|
467
|
+
const claimComment = disc.comments?.findLast(function isClaim(c) { return has(c.body, 'review-claim'); });
|
|
468
|
+
const claimedBy = parse(claimComment?.body, 'review-claim')?.agent;
|
|
469
|
+
if (claimedBy && claimedBy !== agent.identity.name) {
|
|
470
|
+
return {
|
|
471
|
+
content: [{ type: 'text', text: `Verdict rejected: discussion claimed by ${claimedBy}, not ${agent.identity.name}.` }],
|
|
472
|
+
isError: true
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
} catch (err) {
|
|
476
|
+
log.warn(`plan/verdict: claim check failed — ${err.message}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
481
|
+
let comment = args.comment;
|
|
482
|
+
let feedbackTag = '';
|
|
483
|
+
let feedbackLabel = '';
|
|
484
|
+
|
|
485
|
+
if (args.decision === 'changes-requested') {
|
|
486
|
+
try {
|
|
487
|
+
const { classify } = await import('loreli/knowledge');
|
|
488
|
+
const { category, confidence } = await classify(args.comment, {
|
|
489
|
+
backends: ctx.backendRegistry ?? ctx.orchestrator?.backendRegistry,
|
|
490
|
+
config: ctx.config ?? ctx.orchestrator?.cfg
|
|
491
|
+
});
|
|
492
|
+
if (confidence > 0) {
|
|
493
|
+
const provider = agent.identity?.provider ?? 'unknown';
|
|
494
|
+
feedbackTag = mark('feedback', {
|
|
495
|
+
category,
|
|
496
|
+
confidence: confidence.toFixed(2),
|
|
497
|
+
provider,
|
|
498
|
+
source: 'plan'
|
|
499
|
+
});
|
|
500
|
+
feedbackLabel = `${category} (${confidence.toFixed(2)})`;
|
|
501
|
+
}
|
|
502
|
+
} catch (err) {
|
|
503
|
+
log.debug(`plan/verdict: feedback classification failed: ${err.message}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (feedbackTag) comment = `${comment}\n\n${feedbackTag}`;
|
|
508
|
+
const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'plan/verdict' });
|
|
509
|
+
if (traceBlock) comment = `${comment}\n\n${traceBlock}`;
|
|
510
|
+
|
|
511
|
+
await scoped.discussionComment(discussionId, comment);
|
|
512
|
+
await ctx.hub.applyDiscussionLabels(agent.repo, discussionId, [`loreli:${args.decision}`]);
|
|
513
|
+
if (feedbackLabel) log.debug(`plan/verdict: classified feedback as ${feedbackLabel}`);
|
|
514
|
+
|
|
515
|
+
// Rejected discussions are terminal — close immediately so
|
|
516
|
+
// revise() and promote() skip them without further processing.
|
|
517
|
+
if (args.decision === 'rejected') {
|
|
518
|
+
try {
|
|
519
|
+
await ctx.hub.closeDiscussion(discussionId);
|
|
520
|
+
} catch (err) { log.warn(`plan/verdict: close failed for rejected discussion: ${err.message}`); }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
log.info(`plan/verdict: discussion ${discussionId} — ${args.decision}`);
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: 'text', text: `Verdict: ${args.decision}. Label applied and comment posted.` }]
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (action === 'escalate') {
|
|
530
|
+
if (!args.title || !args.body) {
|
|
531
|
+
return { content: [{ type: 'text', text: 'escalate requires title and body.' }], isError: true };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const cat = await ctx.hub.category(agent.repo, 'Loreli');
|
|
535
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
536
|
+
|
|
537
|
+
const disc = await scoped.discuss(agent.repo, {
|
|
538
|
+
title: args.title,
|
|
539
|
+
body: args.body,
|
|
540
|
+
categoryId: cat.id,
|
|
541
|
+
repositoryId: cat.repositoryId
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
log.info(`plan/escalate: "${args.title}" → discussion #${disc.number}`);
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: 'text', text: `Escalated: "${args.title}" created as discussion #${disc.number}.` }]
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (action === 'resolve') {
|
|
551
|
+
if (agent.role !== 'planner') {
|
|
552
|
+
return { content: [{ type: 'text', text: 'resolve is restricted to planner agents.' }], isError: true };
|
|
553
|
+
}
|
|
554
|
+
if (!args.body) {
|
|
555
|
+
return { content: [{ type: 'text', text: 'resolve requires body explaining why no work is needed.' }], isError: true };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const cat = await ctx.hub.category(agent.repo, 'Loreli');
|
|
559
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
560
|
+
const labels = agent.identity.labels?.(agent.role) ?? ['loreli'];
|
|
561
|
+
|
|
562
|
+
const disc = await scoped.discuss(agent.repo, {
|
|
563
|
+
title: args.title ?? 'Objective resolved — no work needed',
|
|
564
|
+
body: args.body,
|
|
565
|
+
categoryId: cat.id,
|
|
566
|
+
repositoryId: cat.repositoryId,
|
|
567
|
+
labels: [...labels, 'loreli:resolved']
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
await ctx.hub.ensure(agent.repo, [
|
|
572
|
+
{ name: 'loreli:resolved', color: '0e8a16', description: 'Objective resolved without work' }
|
|
573
|
+
]);
|
|
574
|
+
await ctx.hub.closeDiscussion(disc.id);
|
|
575
|
+
} catch (err) { log.warn(`plan/resolve: close failed for discussion #${disc.number}: ${err.message}`); }
|
|
576
|
+
|
|
577
|
+
log.info(`plan/resolve: discussion #${disc.number} — no work needed`);
|
|
578
|
+
return {
|
|
579
|
+
content: [{ type: 'text', text: `Resolved: discussion #${disc.number} created and closed. No work needed.` }]
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
pr: {
|
|
586
|
+
title: 'Pull Request',
|
|
587
|
+
description: [
|
|
588
|
+
'Manage pull requests. Use this tool to create PRs after implementing work or to',
|
|
589
|
+
'submit reviews on PRs assigned to you. Your identity, repository, and target PR',
|
|
590
|
+
'are resolved automatically from your session.',
|
|
591
|
+
'',
|
|
592
|
+
'Actions:',
|
|
593
|
+
'- "create": Open a pull request for your work. Provide title, body, head branch, and optionally base branch (defaults to merge.base from config). Include reasoning to document your approach and decisions. When pr.selfReview.enabled is true, call create once to review diff/stat output, then call again with confirm=true to proceed. When pr.validation.command is configured, Loreli runs it and blocks PR creation on failure. Your claimed issue is auto-referenced with "Closes #N". Your agent stamp and identity labels are applied automatically. (Action only)',
|
|
594
|
+
'- "review": Submit your review on the assigned PR. Provide event ("APPROVE" or "REQUEST_CHANGES") and body with your assessment. Include reasoning to document how you evaluated the code. Your agent stamp is applied automatically. (Reviewer only)'
|
|
595
|
+
].join('\n'),
|
|
596
|
+
schema: {
|
|
597
|
+
type: 'object',
|
|
598
|
+
properties: {
|
|
599
|
+
action: {
|
|
600
|
+
type: 'string',
|
|
601
|
+
description: 'Operation to perform: "create" or "review".',
|
|
602
|
+
enum: ['create', 'review']
|
|
603
|
+
},
|
|
604
|
+
title: { type: 'string', description: 'PR title (required for create).' },
|
|
605
|
+
body: { type: 'string', description: 'PR body or review body (required for both).' },
|
|
606
|
+
head: { type: 'string', description: 'Head branch name (required for create).' },
|
|
607
|
+
base: { type: 'string', description: 'Base branch name (optional for create, defaults to merge.base from config).' },
|
|
608
|
+
confirm: { type: 'boolean', description: 'Self-review confirmation flag for create when pr.selfReview.enabled is true.' },
|
|
609
|
+
event: { type: 'string', description: 'Review event: "APPROVE" or "REQUEST_CHANGES" (required for review).' },
|
|
610
|
+
reasoning: { type: 'string', description: 'Summary of your approach, key decisions, and trade-offs. Included as a collapsible trace in the PR body or review for human review.' }
|
|
611
|
+
},
|
|
612
|
+
required: ['action']
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* @param {object} args - Tool arguments.
|
|
617
|
+
* @param {object} ctx - Execution context.
|
|
618
|
+
* @returns {Promise<object>} MCP tool result.
|
|
619
|
+
*/
|
|
620
|
+
async exec(args, ctx) {
|
|
621
|
+
const { action } = args;
|
|
622
|
+
|
|
623
|
+
if (!PR_ACTIONS.has(action)) {
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: 'text', text: `Unknown action: "${action}". Valid: ${[...PR_ACTIONS].join(', ')}` }],
|
|
626
|
+
isError: true
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!ctx.hub) {
|
|
631
|
+
return { content: [{ type: 'text', text: 'Hub not initialized. Run start first.' }], isError: true };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let agent;
|
|
635
|
+
try {
|
|
636
|
+
agent = await context(ctx);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (action === 'create') {
|
|
642
|
+
if (agent.role !== 'action') {
|
|
643
|
+
return { content: [{ type: 'text', text: 'create is restricted to action agents.' }], isError: true };
|
|
644
|
+
}
|
|
645
|
+
if (!args.title || !args.body || !args.head) {
|
|
646
|
+
return { content: [{ type: 'text', text: 'create requires title, body, and head.' }], isError: true };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const base = args.base ?? ctx.config?.get?.('merge.base') ?? 'main';
|
|
650
|
+
const validationRaw = ctx.config?.get?.('pr.validation.command');
|
|
651
|
+
const validation = typeof validationRaw === 'string' ? validationRaw.trim() : '';
|
|
652
|
+
const selfReviewEnabled = ctx.config?.get?.('pr.selfReview.enabled') ?? false;
|
|
653
|
+
|
|
654
|
+
const home = ctx.home ?? process.env.LORELI_HOME;
|
|
655
|
+
const wsRoot = home ? `${home}/workspaces` : undefined;
|
|
656
|
+
const cwd = pathFor(ctx.agentName, wsRoot);
|
|
657
|
+
const key = pendingKey(ctx);
|
|
658
|
+
|
|
659
|
+
if (selfReviewEnabled) {
|
|
660
|
+
if (!args.confirm) {
|
|
661
|
+
if (key) {
|
|
662
|
+
SELF_REVIEW_PENDING.set(key, {
|
|
663
|
+
head: args.head,
|
|
664
|
+
base,
|
|
665
|
+
at: new Date().toISOString()
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
const summary = await preview(cwd, base);
|
|
669
|
+
return {
|
|
670
|
+
content: [{
|
|
671
|
+
type: 'text',
|
|
672
|
+
text: [
|
|
673
|
+
summary,
|
|
674
|
+
'',
|
|
675
|
+
'Self-review is required before PR creation.',
|
|
676
|
+
'Re-run pr/create with confirm=true after reviewing this summary.'
|
|
677
|
+
].join('\n')
|
|
678
|
+
}]
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const pending = key ? SELF_REVIEW_PENDING.get(key) : null;
|
|
683
|
+
if (!pending || pending.head !== args.head || pending.base !== base) {
|
|
684
|
+
return {
|
|
685
|
+
content: [{
|
|
686
|
+
type: 'text',
|
|
687
|
+
text: 'Self-review confirmation missing or stale. Call pr/create once without confirm, review the diff/stat output, then call again with confirm=true.'
|
|
688
|
+
}],
|
|
689
|
+
isError: true
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (validation) {
|
|
695
|
+
const check = await validateBeforePr(cwd, validation);
|
|
696
|
+
if (!check.ok) {
|
|
697
|
+
if (key) SELF_REVIEW_PENDING.delete(key);
|
|
698
|
+
const details = [check.stdout, check.stderr].filter(Boolean).join('\n');
|
|
699
|
+
return {
|
|
700
|
+
content: [{
|
|
701
|
+
type: 'text',
|
|
702
|
+
text: [
|
|
703
|
+
`PR creation blocked: pre-PR validation failed.`,
|
|
704
|
+
`Command: ${validation}`,
|
|
705
|
+
`Exit code: ${check.code}`,
|
|
706
|
+
details ? `Output:\n\`\`\`\n${clip(details)}\n\`\`\`` : 'Output: (none)'
|
|
707
|
+
].join('\n')
|
|
708
|
+
}],
|
|
709
|
+
isError: true
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Auto-commit and push workspace changes before creating the PR.
|
|
715
|
+
// Codex agents on macOS see `@` xattr flags in `ls -la` output
|
|
716
|
+
// and hallucinate a permission error — they never run `git add`,
|
|
717
|
+
// wasting their token budget investigating .git/ internals.
|
|
718
|
+
// By handling git operations server-side, the agent only needs
|
|
719
|
+
// to create files and call this tool.
|
|
720
|
+
try {
|
|
721
|
+
const result = await commitAndPush(cwd, args.title);
|
|
722
|
+
log.info(`pr/create: auto-committed ${result.sha?.slice(0, 7)} and pushed=${result.pushed}`);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
log.warn(`pr/create: auto-commit failed (agent may have committed manually): ${err.message}`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let body = args.body;
|
|
728
|
+
if (agent.issue) {
|
|
729
|
+
body = `Closes #${agent.issue}\n\n${body}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'pr/create' });
|
|
733
|
+
if (traceBlock) body = `${body}\n\n${traceBlock}`;
|
|
734
|
+
|
|
735
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
736
|
+
const labels = agent.identity.labels?.(agent.role) ?? ['loreli'];
|
|
737
|
+
|
|
738
|
+
let pr;
|
|
739
|
+
let updated = false;
|
|
740
|
+
let head = args.head;
|
|
741
|
+
let targetBase = base;
|
|
742
|
+
|
|
743
|
+
await ensureBase(ctx, agent.repo, targetBase);
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Build PR propose payload for the current target refs.
|
|
747
|
+
*
|
|
748
|
+
* @param {string} nextHead - Head branch.
|
|
749
|
+
* @param {string} nextBase - Base branch.
|
|
750
|
+
* @returns {{title: string, body: string, head: string, base: string, labels: string[]}}
|
|
751
|
+
*/
|
|
752
|
+
function payload(nextHead, nextBase) {
|
|
753
|
+
return {
|
|
754
|
+
title: args.title,
|
|
755
|
+
body,
|
|
756
|
+
head: nextHead,
|
|
757
|
+
base: nextBase,
|
|
758
|
+
labels
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
pr = await scoped.propose(agent.repo, payload(head, targetBase));
|
|
764
|
+
} catch (err) {
|
|
765
|
+
let failure = err;
|
|
766
|
+
|
|
767
|
+
if (isInvalidRefError(failure)) {
|
|
768
|
+
const errors = failure?.response?.data?.errors ?? [];
|
|
769
|
+
let retryHead = head;
|
|
770
|
+
let retry = false;
|
|
771
|
+
|
|
772
|
+
const headInvalid = Array.isArray(errors) &&
|
|
773
|
+
errors.some(function invalidHead(e) { return e?.field === 'head' && e?.code === 'invalid'; });
|
|
774
|
+
const baseInvalid = Array.isArray(errors) &&
|
|
775
|
+
errors.some(function invalidBase(e) { return e?.field === 'base' && e?.code === 'invalid'; });
|
|
776
|
+
|
|
777
|
+
if (headInvalid) {
|
|
778
|
+
const current = await gitOut(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
779
|
+
if (current && current !== head) {
|
|
780
|
+
retryHead = current;
|
|
781
|
+
retry = true;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (baseInvalid) {
|
|
786
|
+
await ensureBase(ctx, agent.repo, targetBase);
|
|
787
|
+
retry = true;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (retry) {
|
|
791
|
+
log.warn(
|
|
792
|
+
`pr/create: retrying with recovered refs (head: ${head} -> ${retryHead}, base: ${targetBase})`
|
|
793
|
+
);
|
|
794
|
+
head = retryHead;
|
|
795
|
+
try {
|
|
796
|
+
pr = await scoped.propose(agent.repo, payload(head, targetBase));
|
|
797
|
+
} catch (retryErr) {
|
|
798
|
+
failure = retryErr;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!pr) {
|
|
804
|
+
const duplicate = isDuplicatePrError(failure);
|
|
805
|
+
if (!duplicate) throw failure;
|
|
806
|
+
|
|
807
|
+
const heads = [...new Set([head, args.head])];
|
|
808
|
+
log.info(`pr/create: PR already exists for ${heads.join(' | ')}, updating`);
|
|
809
|
+
const open = await ctx.hub.pulls(agent.repo);
|
|
810
|
+
const existing = open.find(function match(p) { return heads.includes(p.head); });
|
|
811
|
+
if (!existing) throw failure;
|
|
812
|
+
|
|
813
|
+
await ctx.hub.update(agent.repo, existing.number, { title: args.title, body });
|
|
814
|
+
pr = existing;
|
|
815
|
+
updated = true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Persist the PR number in the agent's task so subsequent
|
|
820
|
+
// comment tool calls target the PR instead of the issue.
|
|
821
|
+
if (ctx.sessionId && ctx.storage && ctx.agentName) {
|
|
822
|
+
try {
|
|
823
|
+
const session = await ctx.storage.load(ctx.sessionId, ctx.agentName);
|
|
824
|
+
if (session?.task) {
|
|
825
|
+
session.task.pr = pr.number;
|
|
826
|
+
await ctx.storage.save(ctx.sessionId, ctx.agentName, session);
|
|
827
|
+
}
|
|
828
|
+
} catch (err) { log.debug(`pr/create: task update failed: ${err.message}`); }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (key) SELF_REVIEW_PENDING.delete(key);
|
|
832
|
+
|
|
833
|
+
const verb = updated ? 'Updated' : 'Created';
|
|
834
|
+
log.info(`pr/${verb.toLowerCase()}: "${args.title}" → PR #${pr.number}`);
|
|
835
|
+
return {
|
|
836
|
+
content: [{ type: 'text', text: `${verb} PR #${pr.number}: ${args.title}\nURL: ${pr.url}` }]
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (action === 'review') {
|
|
841
|
+
if (agent.role !== 'reviewer') {
|
|
842
|
+
return { content: [{ type: 'text', text: 'review is restricted to reviewer agents.' }], isError: true };
|
|
843
|
+
}
|
|
844
|
+
if (!args.event || !args.body) {
|
|
845
|
+
return { content: [{ type: 'text', text: 'review requires event and body.' }], isError: true };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!EVENTS.has(args.event)) {
|
|
849
|
+
return {
|
|
850
|
+
content: [{ type: 'text', text: `Invalid event: "${args.event}". Valid: ${[...EVENTS].join(', ')}` }],
|
|
851
|
+
isError: true
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const prNum = agent.task?.pr;
|
|
856
|
+
if (!prNum) {
|
|
857
|
+
return { content: [{ type: 'text', text: 'No PR assigned. Task context missing pr number.' }], isError: true };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
let reviewBody = args.body;
|
|
861
|
+
const traceBlock = await captureTrace(agent, { reasoning: args.reasoning, prefix: 'pr/review' });
|
|
862
|
+
if (traceBlock) reviewBody = `${reviewBody}\n\n${traceBlock}`;
|
|
863
|
+
|
|
864
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
865
|
+
await scoped.review(agent.repo, prNum, { event: args.event, body: reviewBody });
|
|
866
|
+
|
|
867
|
+
// Apply verdict label — mirrors plan verdict's labeling behavior.
|
|
868
|
+
// Remove the inverse label first so a PR never carries both
|
|
869
|
+
// loreli:approved and loreli:changes-requested simultaneously.
|
|
870
|
+
const addLabel = EVENT_LABELS[args.event];
|
|
871
|
+
const removeLabel = EVENT_LABEL_INVERSE[args.event];
|
|
872
|
+
if (addLabel) {
|
|
873
|
+
if (removeLabel) {
|
|
874
|
+
try { await ctx.hub.unlabel(agent.repo, prNum, removeLabel); }
|
|
875
|
+
catch (err) { log.debug(`pr/review: unlabel ${removeLabel} failed: ${err.message}`); }
|
|
876
|
+
}
|
|
877
|
+
try { await ctx.hub.label(agent.repo, prNum, [addLabel]); }
|
|
878
|
+
catch (err) { log.warn(`pr/review: label ${addLabel} failed: ${err.message}`); }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
log.info(`pr/review: PR #${prNum} — ${args.event}`);
|
|
882
|
+
return {
|
|
883
|
+
content: [{ type: 'text', text: `Review submitted on PR #${prNum}: ${args.event}. Label applied.` }]
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
comment: {
|
|
890
|
+
title: 'Comment',
|
|
891
|
+
description: [
|
|
892
|
+
'Post a comment on your current work item. When a PR exists in your session,',
|
|
893
|
+
'the comment targets the PR — once a PR is open, all conversation belongs there.',
|
|
894
|
+
'Before a PR is created, comments target the claimed issue. Use this to communicate',
|
|
895
|
+
'progress, notify reviewers that changes are addressed, or add context.',
|
|
896
|
+
'Provide only the comment body — the target is resolved from your session and your',
|
|
897
|
+
'agent stamp is applied automatically.',
|
|
898
|
+
'',
|
|
899
|
+
'Set claim=true to claim the current issue. When claim is true, the body is ignored —',
|
|
900
|
+
'a themed claim message with a machine-readable marker is posted on the issue.',
|
|
901
|
+
'',
|
|
902
|
+
'Set risk=true for a risk assessment verdict. Requires level: LOW, MEDIUM, or CRITICAL.',
|
|
903
|
+
'',
|
|
904
|
+
'Set abandon=true to abandon the current issue as impossible. Requires reason.',
|
|
905
|
+
'Posts a stamped abandon comment, applies loreli:abandoned + loreli:needs-attention,',
|
|
906
|
+
'and closes the issue. Use only when the issue is fundamentally impossible — not for',
|
|
907
|
+
'"this is hard" or "I need more time."'
|
|
908
|
+
].join('\n'),
|
|
909
|
+
schema: {
|
|
910
|
+
type: 'object',
|
|
911
|
+
properties: {
|
|
912
|
+
body: { type: 'string', description: 'Comment body text (ignored when claim=true or abandon=true).' },
|
|
913
|
+
claim: { type: 'boolean', description: 'Post a claim comment instead of a freeform comment.' },
|
|
914
|
+
risk: { type: 'boolean', description: 'If true, this is a risk assessment verdict. Requires level.' },
|
|
915
|
+
level: { type: 'string', enum: ['LOW', 'MEDIUM', 'CRITICAL'], description: 'Risk level (required when risk is true).' },
|
|
916
|
+
abandon: { type: 'boolean', description: 'Abandon the current issue as impossible. Requires reason.' },
|
|
917
|
+
reason: { type: 'string', description: 'Explanation of why the issue is impossible (required when abandon=true).' }
|
|
918
|
+
},
|
|
919
|
+
required: []
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* @param {object} args - Tool arguments.
|
|
924
|
+
* @param {object} ctx - Execution context.
|
|
925
|
+
* @returns {Promise<object>} MCP tool result.
|
|
926
|
+
*/
|
|
927
|
+
async exec(args, ctx) {
|
|
928
|
+
if (!args.claim && !args.abandon && !args.body) {
|
|
929
|
+
return { content: [{ type: 'text', text: 'body is required (or set claim=true / abandon=true).' }], isError: true };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (args.abandon && !args.reason) {
|
|
933
|
+
return { content: [{ type: 'text', text: 'abandon requires reason.' }], isError: true };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (args.risk) {
|
|
937
|
+
const level = args.level?.toUpperCase();
|
|
938
|
+
if (!['LOW', 'MEDIUM', 'CRITICAL'].includes(level)) {
|
|
939
|
+
return { content: [{ type: 'text', text: 'risk requires level: LOW, MEDIUM, or CRITICAL.' }], isError: true };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (!ctx.hub) {
|
|
944
|
+
return { content: [{ type: 'text', text: 'Hub not initialized. Run start first.' }], isError: true };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
let agent;
|
|
948
|
+
try {
|
|
949
|
+
agent = await context(ctx);
|
|
950
|
+
} catch (err) {
|
|
951
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const scoped = ctx.hub.as(agent.identity, agent.role);
|
|
955
|
+
|
|
956
|
+
if (args.abandon) {
|
|
957
|
+
const issueNum = agent.issue;
|
|
958
|
+
if (!issueNum) {
|
|
959
|
+
return { content: [{ type: 'text', text: 'No issue to abandon. Agent has no claimed issue in session.' }], isError: true };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const abandonMarker = mark('abandon', { agent: agent.identity.name, reason: args.reason });
|
|
963
|
+
const visible = `**Abandoned** by \`${agent.identity.name}\` — ${args.reason}`;
|
|
964
|
+
await scoped.comment(agent.repo, issueNum, `${abandonMarker}\n${visible}`);
|
|
965
|
+
|
|
966
|
+
const labels = ['loreli:abandoned', 'loreli:needs-attention'];
|
|
967
|
+
try {
|
|
968
|
+
await ctx.hub.ensure(agent.repo, [
|
|
969
|
+
{ name: 'loreli:abandoned', color: 'b60205', description: 'Issue abandoned by agent as impossible' },
|
|
970
|
+
{ name: 'loreli:needs-attention', color: 'e11d48', description: 'Requires human attention' }
|
|
971
|
+
]);
|
|
972
|
+
await ctx.hub.label(agent.repo, issueNum, labels);
|
|
973
|
+
} catch (err) { log.warn(`abandon: labeling failed for #${issueNum}: ${err.message}`); }
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
await ctx.hub.update(agent.repo, issueNum, { state: 'closed' });
|
|
977
|
+
} catch (err) { log.warn(`abandon: close failed for #${issueNum}: ${err.message}`); }
|
|
978
|
+
|
|
979
|
+
// Mention configured reviewers so humans are notified
|
|
980
|
+
const reviewers = ctx.config?.get?.('reviewers') ?? [];
|
|
981
|
+
if (reviewers.length) {
|
|
982
|
+
const mentions = reviewers.map(function mention(r) { return `@${r}`; }).join(' ');
|
|
983
|
+
try {
|
|
984
|
+
await scoped.comment(agent.repo, issueNum, `${mentions} This issue was abandoned by the action agent. Please review.`);
|
|
985
|
+
} catch (err) { log.warn(`abandon: reviewer mention failed for #${issueNum}: ${err.message}`); }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
log.info(`abandon: #${issueNum} by ${agent.identity.name} — ${args.reason}`);
|
|
989
|
+
return { content: [{ type: 'text', text: `Abandoned #${issueNum}. Issue closed with loreli:abandoned + loreli:needs-attention.` }] };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Claims always target the issue — that is the resource being claimed.
|
|
993
|
+
if (args.claim) {
|
|
994
|
+
const issueNum = agent.issue;
|
|
995
|
+
if (!issueNum) {
|
|
996
|
+
return { content: [{ type: 'text', text: 'No issue to claim. Agent has no claimed issue in session.' }], isError: true };
|
|
997
|
+
}
|
|
998
|
+
const marker = mark('claim', { agent: agent.identity.name });
|
|
999
|
+
const visible = agent.identity.claim?.() ?? `Claimed by **${agent.identity.name}**`;
|
|
1000
|
+
await scoped.comment(agent.repo, issueNum, `${marker}\n${visible}`);
|
|
1001
|
+
log.info(`claim: #${issueNum} by ${agent.identity.name} on ${agent.repo}`);
|
|
1002
|
+
return { content: [{ type: 'text', text: `Claimed #${issueNum}.` }] };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Regular comments prefer the PR when one exists — once a PR is
|
|
1006
|
+
// open, all conversation belongs there (review feedback, re-review
|
|
1007
|
+
// requests, progress updates). Fall back to the issue pre-PR.
|
|
1008
|
+
const number = agent.task?.pr ?? agent.issue;
|
|
1009
|
+
if (!number) {
|
|
1010
|
+
return {
|
|
1011
|
+
content: [{ type: 'text', text: 'No work item assigned. Neither claimed issue nor PR found in session.' }],
|
|
1012
|
+
isError: true
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
let body = args.body;
|
|
1017
|
+
if (args.risk) {
|
|
1018
|
+
const level = args.level?.toUpperCase();
|
|
1019
|
+
body = `${mark('risk', { level })}\n${body}`;
|
|
1020
|
+
}
|
|
1021
|
+
if (args.risk) {
|
|
1022
|
+
const traceBlock = await captureTrace(agent, { prefix: 'comment/risk' });
|
|
1023
|
+
if (traceBlock) body = `${body}\n\n${traceBlock}`;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
await scoped.comment(agent.repo, number, body);
|
|
1027
|
+
|
|
1028
|
+
// Apply a filterable risk label when posting a risk verdict
|
|
1029
|
+
if (args.risk) {
|
|
1030
|
+
const level = args.level?.toUpperCase();
|
|
1031
|
+
const label = `loreli:${level.toLowerCase()}-risk`;
|
|
1032
|
+
const color = { LOW: '0e8a16', MEDIUM: 'fbca04', CRITICAL: 'e11d48' }[level];
|
|
1033
|
+
try {
|
|
1034
|
+
await ctx.hub.ensure(agent.repo, [{ name: label, color, description: `Risk: ${level}` }]);
|
|
1035
|
+
await ctx.hub.label(agent.repo, number, [label]);
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
log.warn(`comment: failed to apply risk label ${label}: ${err.message}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
log.info(`comment: #${number} on ${agent.repo}`);
|
|
1042
|
+
return {
|
|
1043
|
+
content: [{ type: 'text', text: `Comment posted on #${number}.` }]
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
read: {
|
|
1049
|
+
title: 'Read',
|
|
1050
|
+
description: [
|
|
1051
|
+
'Read any issue, pull request, or discussion by number. Use this to look up',
|
|
1052
|
+
'acceptance criteria, review linked issues, understand PR context, or read',
|
|
1053
|
+
'discussion plans. Returns the title, body, state, labels, and optionally',
|
|
1054
|
+
'all comments for full context. Your repository is resolved from your session.',
|
|
1055
|
+
'',
|
|
1056
|
+
'Works with any GitHub item number — the tool automatically detects whether',
|
|
1057
|
+
'it is an issue, pull request, or discussion.'
|
|
1058
|
+
].join('\n'),
|
|
1059
|
+
schema: {
|
|
1060
|
+
type: 'object',
|
|
1061
|
+
properties: {
|
|
1062
|
+
number: {
|
|
1063
|
+
type: 'number',
|
|
1064
|
+
description: 'The issue, PR, or discussion number to read.'
|
|
1065
|
+
},
|
|
1066
|
+
comments: {
|
|
1067
|
+
type: 'boolean',
|
|
1068
|
+
description: 'Include comments for full context. Defaults to true.'
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
required: ['number']
|
|
1072
|
+
},
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* @param {object} args - Tool arguments.
|
|
1076
|
+
* @param {number} args.number - Item number to read.
|
|
1077
|
+
* @param {boolean} [args.comments=true] - Include comments.
|
|
1078
|
+
* @param {object} ctx - Execution context.
|
|
1079
|
+
* @returns {Promise<object>} MCP tool result.
|
|
1080
|
+
*/
|
|
1081
|
+
async exec(args, ctx) {
|
|
1082
|
+
if (!args.number) {
|
|
1083
|
+
return { content: [{ type: 'text', text: 'number is required.' }], isError: true };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (!ctx.hub) {
|
|
1087
|
+
return { content: [{ type: 'text', text: 'Hub not initialized. Run start first.' }], isError: true };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
let agent;
|
|
1091
|
+
try {
|
|
1092
|
+
agent = await context(ctx);
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const num = args.number;
|
|
1098
|
+
const includeComments = args.comments !== false;
|
|
1099
|
+
|
|
1100
|
+
// GitHub uses unified numbering for issues and PRs — the issues
|
|
1101
|
+
// API returns both. Discussions use a separate sequence and require
|
|
1102
|
+
// the GraphQL endpoint, so we fall back to that on 404.
|
|
1103
|
+
try {
|
|
1104
|
+
const item = await ctx.hub.issue(agent.repo, num);
|
|
1105
|
+
const itemBody = excise(item.body, 'trace') ?? item.body;
|
|
1106
|
+
const lines = [
|
|
1107
|
+
`# #${item.number}: ${item.title}`,
|
|
1108
|
+
`**State**: ${item.state} | **Author**: ${item.author}`,
|
|
1109
|
+
item.labels.length ? `**Labels**: ${item.labels.join(', ')}` : '',
|
|
1110
|
+
'',
|
|
1111
|
+
itemBody || '*(no body)*'
|
|
1112
|
+
];
|
|
1113
|
+
|
|
1114
|
+
if (includeComments) {
|
|
1115
|
+
const comments = await ctx.hub.comments(agent.repo, num);
|
|
1116
|
+
if (comments.length) {
|
|
1117
|
+
lines.push('', '---', '## Comments', '');
|
|
1118
|
+
for (const c of comments) {
|
|
1119
|
+
lines.push(`**@${c.author}** (${c.created}):`);
|
|
1120
|
+
lines.push(c.body);
|
|
1121
|
+
lines.push('');
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
log.info(`read: issue/PR #${num} on ${agent.repo}`);
|
|
1127
|
+
return { content: [{ type: 'text', text: lines.filter(Boolean).join('\n') }] };
|
|
1128
|
+
} catch {
|
|
1129
|
+
// Not an issue/PR — try discussion
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
const disc = await ctx.hub.discussion(agent.repo, num);
|
|
1134
|
+
const discBody = excise(disc.body, 'trace') ?? disc.body;
|
|
1135
|
+
const lines = [
|
|
1136
|
+
`# Discussion #${disc.number}: ${disc.title}`,
|
|
1137
|
+
`**Author**: ${disc.author} | **Closed**: ${disc.closed}`,
|
|
1138
|
+
disc.labels.length ? `**Labels**: ${disc.labels.join(', ')}` : '',
|
|
1139
|
+
'',
|
|
1140
|
+
discBody || '*(no body)*'
|
|
1141
|
+
];
|
|
1142
|
+
|
|
1143
|
+
// Discussions already include comments from the GraphQL query
|
|
1144
|
+
if (includeComments && disc.comments?.length) {
|
|
1145
|
+
lines.push('', '---', '## Comments', '');
|
|
1146
|
+
for (const c of disc.comments) {
|
|
1147
|
+
lines.push(`**@${c.author}** (${c.created}):`);
|
|
1148
|
+
lines.push(c.body);
|
|
1149
|
+
lines.push('');
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
log.info(`read: discussion #${num} on ${agent.repo}`);
|
|
1154
|
+
return { content: [{ type: 'text', text: lines.filter(Boolean).join('\n') }] };
|
|
1155
|
+
} catch {
|
|
1156
|
+
return {
|
|
1157
|
+
content: [{ type: 'text', text: `Item #${num} not found in ${agent.repo}. It may not exist or may be inaccessible.` }],
|
|
1158
|
+
isError: true
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
};
|