engineering-memory 1.11.28 → 1.11.29
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/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +9 -0
- package/runtime/dist/src/git/git-inspector.js +13 -0
- package/runtime/dist/src/localization/catalogue.generated.js +80 -0
- package/runtime/dist/src/mcp/delivery-tools.js +185 -50
- package/runtime/dist/src/mcp/review-tools.js +369 -0
- package/runtime/dist/src/mcp/tool-annotations.js +9 -0
- package/runtime/dist/src/mcp/tool-definitions.js +16 -0
- package/runtime/dist/src/mcp/worktree-tools.js +12 -1
- package/runtime/dist/src/providers/gitlab.js +4 -0
- package/runtime/dist/src/runtime/api-client.js +7 -0
- package/runtime/dist/src/runtime/bridge-service.js +273 -5
- package/runtime/dist/src/runtime/merge-request-sync.js +42 -1
- package/runtime/dist/src/runtime/review-masking.js +31 -0
- package/runtime/dist/src/runtime/task-start.js +12 -6
- package/skill/references/lifecycle.md +10 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { waitingReview } from '../runtime/merge-request-sync.js';
|
|
3
|
+
import { languageTag } from '../runtime/questionnaire-store.js';
|
|
4
|
+
import { copies, format } from '../runtime/texts.js';
|
|
5
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
|
+
import { answerChoice, answerData, askQuestionnaire } from './questionnaire-tools.js';
|
|
7
|
+
const text = z.string().min(1);
|
|
8
|
+
const option = z.strictObject({ label: text, description: text });
|
|
9
|
+
const policyWording = z.strictObject({
|
|
10
|
+
message: text,
|
|
11
|
+
context: text,
|
|
12
|
+
example: text,
|
|
13
|
+
current: text,
|
|
14
|
+
none: option,
|
|
15
|
+
optional: option,
|
|
16
|
+
required: option,
|
|
17
|
+
keep: option,
|
|
18
|
+
});
|
|
19
|
+
const rejectWording = z.strictObject({
|
|
20
|
+
message: text,
|
|
21
|
+
context: text,
|
|
22
|
+
example: text,
|
|
23
|
+
reasonTitle: text,
|
|
24
|
+
reject: option,
|
|
25
|
+
keep: option,
|
|
26
|
+
});
|
|
27
|
+
const managers = ['owner', 'maintainer'];
|
|
28
|
+
const conclusion = z.strictObject({ message: text, context: text });
|
|
29
|
+
const concludeWording = z.strictObject({
|
|
30
|
+
passed: conclusion,
|
|
31
|
+
skipped: conclusion,
|
|
32
|
+
exempted: conclusion,
|
|
33
|
+
example: text,
|
|
34
|
+
reasonTitle: text,
|
|
35
|
+
confirm: option,
|
|
36
|
+
defer: option,
|
|
37
|
+
});
|
|
38
|
+
const reviewRead = z.object({
|
|
39
|
+
delivery: z.object({
|
|
40
|
+
externalTaskId: z.string(),
|
|
41
|
+
reviewState: z.string().nullish(),
|
|
42
|
+
reviewPolicy: z.string().optional(),
|
|
43
|
+
}),
|
|
44
|
+
rounds: z.array(z.object({ id: z.string(), state: z.string() })),
|
|
45
|
+
findings: z.array(z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
position: z.number().int(),
|
|
48
|
+
path: z.string(),
|
|
49
|
+
line: z.number().int().nullish(),
|
|
50
|
+
description: z.string(),
|
|
51
|
+
resolution: z.string(),
|
|
52
|
+
})),
|
|
53
|
+
});
|
|
54
|
+
const form = {
|
|
55
|
+
repoRoot: z.string().min(1).optional(),
|
|
56
|
+
language: languageTag.optional(),
|
|
57
|
+
decisionAttempt: z.number().int().min(1).max(10000).optional(),
|
|
58
|
+
presentation: z.literal('host_native').optional(),
|
|
59
|
+
};
|
|
60
|
+
export function registerReviewTools(server, service) {
|
|
61
|
+
server.registerTool('project.review_policy', {
|
|
62
|
+
description: 'Read whether this project reviews code before a PR/MR is ready: none, optional or required. With change true it asks the user in one native form and records their choice; only an owner or maintainer can change it. The setting applies from then on: work already delivered keeps its review state. On GitHub the setting is recorded, but GitHub does not enforce it.',
|
|
63
|
+
inputSchema: z.strictObject({
|
|
64
|
+
projectId: z.string().uuid(),
|
|
65
|
+
change: z.boolean().optional(),
|
|
66
|
+
...form,
|
|
67
|
+
}),
|
|
68
|
+
}, async (input, context) => {
|
|
69
|
+
const found = await service.projectReviewPolicy({ projectId: input.projectId });
|
|
70
|
+
if (!found.ok || !input.change)
|
|
71
|
+
return output(found);
|
|
72
|
+
const project = found.data;
|
|
73
|
+
if (project.role && !managers.includes(project.role))
|
|
74
|
+
return refusal(`Only a project owner or maintainer can change the code review setting of ${project.name}; it is ${project.reviewPolicy}. project.member_list shows who can.`, 'project.member_list');
|
|
75
|
+
const questionnaireId = 'review-policy-' +
|
|
76
|
+
sha256(stableStringify({
|
|
77
|
+
projectId: input.projectId,
|
|
78
|
+
lockVersion: project.lockVersion,
|
|
79
|
+
reviewPolicy: project.reviewPolicy,
|
|
80
|
+
decisionAttempt: input.decisionAttempt ?? 0,
|
|
81
|
+
}));
|
|
82
|
+
const definitions = copies(policyWording, 'reviewPolicy', await service.language(input.language)).map(({ language, copy }) => ({
|
|
83
|
+
questionnaireId,
|
|
84
|
+
language,
|
|
85
|
+
impact: 'critical',
|
|
86
|
+
message: copy.message,
|
|
87
|
+
context: copy.context + '\n' + copy.current + ': ' + copy[project.reviewPolicy].label,
|
|
88
|
+
example: copy.example,
|
|
89
|
+
options: ['none', 'optional', 'required', 'keep'].map((id) => ({
|
|
90
|
+
id,
|
|
91
|
+
...copy[id],
|
|
92
|
+
})),
|
|
93
|
+
}));
|
|
94
|
+
const asked = await askQuestionnaire(server, service, { ...definitions[0], repoRoot: input.repoRoot, presentation: input.presentation }, context, definitions);
|
|
95
|
+
const choice = answerChoice(asked);
|
|
96
|
+
if (!choice)
|
|
97
|
+
return asked;
|
|
98
|
+
if (choice === 'keep' || choice === project.reviewPolicy)
|
|
99
|
+
return output({
|
|
100
|
+
ok: true,
|
|
101
|
+
data: {
|
|
102
|
+
reviewPolicy: project.reviewPolicy,
|
|
103
|
+
changed: false,
|
|
104
|
+
nextAction: 'Nothing was changed.',
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const updated = await service.projectUpdate({
|
|
108
|
+
projectId: input.projectId,
|
|
109
|
+
data: { expectedVersion: project.lockVersion, reviewPolicy: choice },
|
|
110
|
+
});
|
|
111
|
+
if (!updated.ok)
|
|
112
|
+
return output(updated);
|
|
113
|
+
return output({
|
|
114
|
+
ok: true,
|
|
115
|
+
data: {
|
|
116
|
+
project: updated.data ?? null,
|
|
117
|
+
changed: true,
|
|
118
|
+
nextAction: `The code review setting of ${project.name} is now ${choice}. Tell the user; work already delivered keeps its review state.`,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
server.registerTool('review.queue', {
|
|
123
|
+
description: 'List the deliveries of this project that wait for code review, oldest first: reviews not started or not finished, and reviews that ended while their PR/MR is still a draft. Each shows its open round and how many findings are open. Page with offset and limit (defaults 0 and 20, at most 100).',
|
|
124
|
+
inputSchema: z.strictObject({
|
|
125
|
+
projectId: z.string().uuid(),
|
|
126
|
+
offset: z.number().int().min(0).optional(),
|
|
127
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
128
|
+
}),
|
|
129
|
+
}, async (input) => output(await service.reviewQueue(input)));
|
|
130
|
+
server.registerTool('review.get', {
|
|
131
|
+
description: "Read the code review of a delivered task: its state, the project's setting, its rounds (newest first) and the findings of the latest submitted round with how each was resolved.",
|
|
132
|
+
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
133
|
+
}, async (input) => output(await service.reviewGet(input)));
|
|
134
|
+
server.registerTool('review.start', {
|
|
135
|
+
description: "Start the next code review round of a delivered task, or return the round already open at the same commit, from a clone of the task's project on this computer. It fetches the pushed branch and its target from origin without checking anything out and returns the review packet: the commits to compare, the changed files, the records that governed the change, earlier findings and the read-only Git commands the reviewer may use. Pass targetBranch only when the delivery names no target branch.",
|
|
136
|
+
inputSchema: z.strictObject({
|
|
137
|
+
projectId: z.string().uuid(),
|
|
138
|
+
taskId: z.string().uuid(),
|
|
139
|
+
repoRoot: z.string().min(1).optional(),
|
|
140
|
+
targetBranch: z.string().trim().min(1).max(240).optional(),
|
|
141
|
+
}),
|
|
142
|
+
}, async (input) => output(await service.reviewStart(input)));
|
|
143
|
+
server.registerTool('review.rules', {
|
|
144
|
+
description: 'Read the records a review round judges the change against, with their content, in pages (defaults offset 0 and limit 5, at most 20). Records the reader cannot open are counted, not shown.',
|
|
145
|
+
inputSchema: z.strictObject({
|
|
146
|
+
projectId: z.string().uuid(),
|
|
147
|
+
roundId: z.string().uuid(),
|
|
148
|
+
offset: z.number().int().min(0).optional(),
|
|
149
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
150
|
+
}),
|
|
151
|
+
}, async (input) => output(await service.reviewRules(input)));
|
|
152
|
+
server.registerTool('review.submit', {
|
|
153
|
+
description: 'Record the result of a review round: its findings, or an empty list when there are none. E-mail addresses, IP addresses and user folders in descriptions are masked first; a description that still holds a secret or personal data is refused so it can be rephrased. Sending the same result again changes nothing.',
|
|
154
|
+
inputSchema: z.strictObject({
|
|
155
|
+
projectId: z.string().uuid(),
|
|
156
|
+
roundId: z.string().uuid(),
|
|
157
|
+
findings: z
|
|
158
|
+
.array(z.strictObject({
|
|
159
|
+
path: z.string().trim().min(1).max(500),
|
|
160
|
+
line: z.number().int().min(1).optional(),
|
|
161
|
+
ruleKey: z.string().trim().min(1).max(160).optional(),
|
|
162
|
+
description: z.string().trim().min(1).max(4000),
|
|
163
|
+
}))
|
|
164
|
+
.max(100),
|
|
165
|
+
}),
|
|
166
|
+
}, async (input) => {
|
|
167
|
+
const result = await service.reviewSubmit(input);
|
|
168
|
+
if (!result.ok)
|
|
169
|
+
return output(result);
|
|
170
|
+
const data = result.data;
|
|
171
|
+
const task = data.review.delivery.externalTaskId;
|
|
172
|
+
const decided = data.review.rounds.some((round) => round.id === input.roundId && round.state === 'submitted');
|
|
173
|
+
return output({
|
|
174
|
+
ok: true,
|
|
175
|
+
data: {
|
|
176
|
+
...data,
|
|
177
|
+
nextAction: [
|
|
178
|
+
data.masked.length
|
|
179
|
+
? `These were masked in the descriptions before they were stored: ${data.masked.join(', ')}.`
|
|
180
|
+
: '',
|
|
181
|
+
!decided
|
|
182
|
+
? `The result is stored with this round, but the round no longer decides the review of ${task}: a newer round or a conclusion replaced it. Read the review with review.get and show the user where it stands.`
|
|
183
|
+
: input.findings.length
|
|
184
|
+
? `Show the user the findings of ${task} and ask whether to fix them. A fix is a write task started with task.branch with reviewFixOf set to the reviewed taskId; a finding the user disagrees with is rejected with a reason through review.reject_finding.`
|
|
185
|
+
: `No findings: the review of ${task} passed. Tell the user and call review.make_ready to make its PR/MR ready.`,
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(' '),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
server.registerTool('review.reject_finding', {
|
|
193
|
+
description: 'Reject an open finding of a code review with a written reason, after asking the user in a native form where they write the reason. When it was the last open finding, the review passes.',
|
|
194
|
+
inputSchema: z.strictObject({
|
|
195
|
+
projectId: z.string().uuid(),
|
|
196
|
+
taskId: z.string().uuid(),
|
|
197
|
+
findingId: z.string().uuid(),
|
|
198
|
+
...form,
|
|
199
|
+
}),
|
|
200
|
+
}, async (input, context) => {
|
|
201
|
+
const read = await service.reviewGet(input);
|
|
202
|
+
if (!read.ok)
|
|
203
|
+
return output(read);
|
|
204
|
+
const review = reviewRead.parse(read.data);
|
|
205
|
+
const finding = review.findings.find((candidate) => candidate.id === input.findingId);
|
|
206
|
+
if (!finding || finding.resolution !== 'open')
|
|
207
|
+
return refusal(finding
|
|
208
|
+
? `Finding ${finding.position} is already ${finding.resolution}.`
|
|
209
|
+
: 'This finding is not among the findings of the latest round of this review. Read it again with review.get.', 'review.get');
|
|
210
|
+
const questionnaireId = 'review-reject-' +
|
|
211
|
+
sha256(stableStringify({
|
|
212
|
+
findingId: input.findingId,
|
|
213
|
+
decisionAttempt: input.decisionAttempt ?? 0,
|
|
214
|
+
}));
|
|
215
|
+
const where = finding.path + (finding.line ? ':' + finding.line : '');
|
|
216
|
+
const definitions = copies(rejectWording, 'reviewReject', await service.language(input.language)).map(({ language, copy }) => ({
|
|
217
|
+
questionnaireId,
|
|
218
|
+
language,
|
|
219
|
+
impact: 'critical',
|
|
220
|
+
message: copy.message,
|
|
221
|
+
context: copy.context +
|
|
222
|
+
'\n' +
|
|
223
|
+
`${review.delivery.externalTaskId} #${finding.position}, ${where}: ` +
|
|
224
|
+
(finding.description.length > 500
|
|
225
|
+
? finding.description.slice(0, 500) + '…'
|
|
226
|
+
: finding.description),
|
|
227
|
+
example: copy.example,
|
|
228
|
+
options: [
|
|
229
|
+
{ id: 'reject', ...copy.reject },
|
|
230
|
+
{ id: 'keep', ...copy.keep },
|
|
231
|
+
],
|
|
232
|
+
textField: { title: copy.reasonTitle, maxLength: 2000, requiredForChoice: 'reject' },
|
|
233
|
+
}));
|
|
234
|
+
const asked = await askQuestionnaire(server, service, { ...definitions[0], repoRoot: input.repoRoot, presentation: input.presentation }, context, definitions);
|
|
235
|
+
const answer = answerData(asked);
|
|
236
|
+
if (!answer?.choice)
|
|
237
|
+
return asked;
|
|
238
|
+
if (answer.choice !== 'reject' || !answer.text)
|
|
239
|
+
return output({
|
|
240
|
+
ok: true,
|
|
241
|
+
data: { rejected: false, nextAction: 'Nothing was recorded; the finding stays open.' },
|
|
242
|
+
});
|
|
243
|
+
const rejected = await service.reviewRejectFinding({
|
|
244
|
+
projectId: input.projectId,
|
|
245
|
+
findingId: input.findingId,
|
|
246
|
+
reason: answer.text,
|
|
247
|
+
});
|
|
248
|
+
if (!rejected.ok)
|
|
249
|
+
return output(rejected);
|
|
250
|
+
const after = reviewRead.parse(rejected.data);
|
|
251
|
+
const open = after.findings.filter((candidate) => candidate.resolution === 'open').length;
|
|
252
|
+
return output({
|
|
253
|
+
ok: true,
|
|
254
|
+
data: {
|
|
255
|
+
review: rejected.data ?? null,
|
|
256
|
+
rejected: true,
|
|
257
|
+
nextAction: after.delivery.reviewState === 'passed'
|
|
258
|
+
? `No finding is open any more, so the review of ${after.delivery.externalTaskId} passed. Tell the user and call review.make_ready to make its PR/MR ready.`
|
|
259
|
+
: `The finding is rejected with the user's reason; ${open} finding${open === 1 ? ' is' : 's are'} still open.`,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
server.registerTool('review.conclude', {
|
|
264
|
+
description: 'End a code review after asking the user in a native form. passed ends a review whose fix was pushed without another round (no finding may be open); skipped ends it where review is not required; exempted ends a required review and needs an owner or maintainer and a written reason. Every member except readers may pass or skip. Nothing changes in Git.',
|
|
265
|
+
inputSchema: z.strictObject({
|
|
266
|
+
projectId: z.string().uuid(),
|
|
267
|
+
taskId: z.string().uuid(),
|
|
268
|
+
outcome: z.enum(['passed', 'skipped', 'exempted']),
|
|
269
|
+
...form,
|
|
270
|
+
}),
|
|
271
|
+
}, async (input, context) => {
|
|
272
|
+
const read = await service.reviewGet(input);
|
|
273
|
+
if (!read.ok)
|
|
274
|
+
return output(read);
|
|
275
|
+
const review = reviewRead.parse(read.data);
|
|
276
|
+
const task = review.delivery.externalTaskId;
|
|
277
|
+
const state = review.delivery.reviewState ?? null;
|
|
278
|
+
if (state === input.outcome)
|
|
279
|
+
return output({
|
|
280
|
+
ok: true,
|
|
281
|
+
data: {
|
|
282
|
+
concluded: true,
|
|
283
|
+
nextAction: `The review of ${task} already ended as ${state}. Call review.make_ready to make its PR/MR ready.`,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
if (!waitingReview.includes(state ?? ''))
|
|
287
|
+
return refusal(`The review of ${task} is ${state ?? 'not tracked'}, so there is nothing to end. review.get shows where it stands.`, 'review.get');
|
|
288
|
+
if (input.outcome === 'passed' &&
|
|
289
|
+
(state !== 'pending' ||
|
|
290
|
+
!review.rounds.some((round) => round.state === 'submitted') ||
|
|
291
|
+
review.findings.some((finding) => finding.resolution === 'open')))
|
|
292
|
+
return refusal(`The review of ${task} is ${state}: it passes without another round only after a round was submitted, a fix was pushed and no finding is open. Start a round with review.start, or ask the user whether to skip or exempt it.`, 'review.start');
|
|
293
|
+
if (input.outcome === 'skipped' && review.delivery.reviewPolicy === 'required')
|
|
294
|
+
return refusal(`This project requires code review, so the review of ${task} cannot be skipped; a project owner or maintainer can exempt it with a written reason (outcome exempted).`, 'review.conclude');
|
|
295
|
+
if (input.outcome === 'exempted') {
|
|
296
|
+
const project = await service.projectReviewPolicy({ projectId: input.projectId });
|
|
297
|
+
const role = project.ok ? project.data.role : undefined;
|
|
298
|
+
if (role && !managers.includes(role))
|
|
299
|
+
return refusal(`Only a project owner or maintainer can exempt the review of ${task}. project.member_list shows who can.`, 'project.member_list');
|
|
300
|
+
}
|
|
301
|
+
const questionnaireId = 'review-conclude-' +
|
|
302
|
+
sha256(stableStringify({
|
|
303
|
+
taskId: input.taskId,
|
|
304
|
+
outcome: input.outcome,
|
|
305
|
+
reviewState: review.delivery.reviewState ?? null,
|
|
306
|
+
decisionAttempt: input.decisionAttempt ?? 0,
|
|
307
|
+
}));
|
|
308
|
+
const definitions = copies(concludeWording, 'reviewConclude', await service.language(input.language)).map(({ language, copy }) => ({
|
|
309
|
+
questionnaireId,
|
|
310
|
+
language,
|
|
311
|
+
impact: 'critical',
|
|
312
|
+
message: format(copy[input.outcome].message, language, { task }),
|
|
313
|
+
context: copy[input.outcome].context,
|
|
314
|
+
example: copy.example,
|
|
315
|
+
options: [
|
|
316
|
+
{ id: 'confirm', ...copy.confirm },
|
|
317
|
+
{ id: 'defer', ...copy.defer },
|
|
318
|
+
],
|
|
319
|
+
...(input.outcome === 'exempted'
|
|
320
|
+
? {
|
|
321
|
+
textField: {
|
|
322
|
+
title: copy.reasonTitle,
|
|
323
|
+
maxLength: 2000,
|
|
324
|
+
requiredForChoice: 'confirm',
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
: {}),
|
|
328
|
+
}));
|
|
329
|
+
const asked = await askQuestionnaire(server, service, { ...definitions[0], repoRoot: input.repoRoot, presentation: input.presentation }, context, definitions);
|
|
330
|
+
const answer = answerData(asked);
|
|
331
|
+
if (!answer?.choice)
|
|
332
|
+
return asked;
|
|
333
|
+
if (answer.choice !== 'confirm')
|
|
334
|
+
return output({
|
|
335
|
+
ok: true,
|
|
336
|
+
data: { concluded: false, nextAction: 'Nothing was recorded; the review stays open.' },
|
|
337
|
+
});
|
|
338
|
+
const concluded = await service.reviewConclude({
|
|
339
|
+
projectId: input.projectId,
|
|
340
|
+
taskId: input.taskId,
|
|
341
|
+
outcome: input.outcome,
|
|
342
|
+
...(answer.text ? { reason: answer.text } : {}),
|
|
343
|
+
});
|
|
344
|
+
if (!concluded.ok)
|
|
345
|
+
return output(concluded);
|
|
346
|
+
return output({
|
|
347
|
+
ok: true,
|
|
348
|
+
data: {
|
|
349
|
+
review: concluded.data ?? null,
|
|
350
|
+
concluded: true,
|
|
351
|
+
nextAction: `The review of ${task} ended as ${input.outcome}. Call review.make_ready to make its PR/MR ready.`,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
server.registerTool('review.make_ready', {
|
|
356
|
+
description: "Make the PR/MR of a delivered task ready for the team once its code review ended, or, when the user asks, one that has no code review; while a review runs it refuses. On GitLab with the user's own token saved on this computer, it removes Draft from the merge request and records it; anywhere else it says exactly what to do, since the draft is marked ready with the provider's own tools or by the user.",
|
|
357
|
+
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
358
|
+
}, async (input) => output(await service.reviewMakeReady(input)));
|
|
359
|
+
}
|
|
360
|
+
function output(result) {
|
|
361
|
+
return {
|
|
362
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
363
|
+
isError: !result.ok,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function refusal(message, recovery) {
|
|
367
|
+
return output({ ok: false, error: { kind: 'review', message, recovery, retryable: false } });
|
|
368
|
+
}
|
|
369
|
+
//# sourceMappingURL=review-tools.js.map
|
|
@@ -81,6 +81,15 @@ export const toolAnnotations = {
|
|
|
81
81
|
'task.review_request': repeatableWrite,
|
|
82
82
|
'task.open_review_request': { ...outward, idempotentHint: true },
|
|
83
83
|
'gitlab.token': outward,
|
|
84
|
+
'project.review_policy': write,
|
|
85
|
+
'review.queue': read,
|
|
86
|
+
'review.get': read,
|
|
87
|
+
'review.start': { ...outward, idempotentHint: true },
|
|
88
|
+
'review.rules': read,
|
|
89
|
+
'review.submit': repeatableWrite,
|
|
90
|
+
'review.reject_finding': write,
|
|
91
|
+
'review.conclude': write,
|
|
92
|
+
'review.make_ready': { ...outward, idempotentHint: true },
|
|
84
93
|
'task.abandon': destructive,
|
|
85
94
|
'task.branch': outward, // fetches the chosen base from the Git remote
|
|
86
95
|
'architecture.plan': read,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerDeliveryTools } from './delivery-tools.js';
|
|
2
|
+
import { registerReviewTools } from './review-tools.js';
|
|
2
3
|
import { registerLiveStatusTools } from './live-status-tools.js';
|
|
3
4
|
import { registerStatusMeaningTools } from './status-meaning-tools.js';
|
|
4
5
|
import { registerStatusRemapTools } from './status-remap-tools.js';
|
|
@@ -120,6 +121,15 @@ export const engineeringMemoryToolNames = [
|
|
|
120
121
|
'task.review_request',
|
|
121
122
|
'task.open_review_request',
|
|
122
123
|
'gitlab.token',
|
|
124
|
+
'project.review_policy',
|
|
125
|
+
'review.queue',
|
|
126
|
+
'review.get',
|
|
127
|
+
'review.start',
|
|
128
|
+
'review.rules',
|
|
129
|
+
'review.submit',
|
|
130
|
+
'review.reject_finding',
|
|
131
|
+
'review.conclude',
|
|
132
|
+
'review.make_ready',
|
|
123
133
|
'task.abandon',
|
|
124
134
|
'task.branch',
|
|
125
135
|
'architecture.plan',
|
|
@@ -376,6 +386,11 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
376
386
|
.regex(/^[^\r\n]+$/, 'workItemKey must be a single line of at most 120 characters.')
|
|
377
387
|
.optional(),
|
|
378
388
|
workItemId: z.string().uuid().optional(),
|
|
389
|
+
reviewFixOf: z
|
|
390
|
+
.string()
|
|
391
|
+
.uuid()
|
|
392
|
+
.optional()
|
|
393
|
+
.describe("The reviewed task's taskId when this write task fixes the findings of its code review, as given to task.branch. The task belongs to that task's work item and delivers onto its pull request branch."),
|
|
379
394
|
mode: z.enum(['write', 'read_only', 'scaffold']).optional(),
|
|
380
395
|
linkedSources: z
|
|
381
396
|
.array(z.object({
|
|
@@ -759,6 +774,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
759
774
|
return toolResult(await service.taskVerify(request));
|
|
760
775
|
});
|
|
761
776
|
registerDeliveryTools(server, service);
|
|
777
|
+
registerReviewTools(server, service);
|
|
762
778
|
server.registerTool('task.abandon', {
|
|
763
779
|
description: "Abandon a task the user has said they are giving up on, so it stops holding its proposals and its local pointer. It has no effect on any other task: nobody else's work waits on this, and nothing already verified or closed is undone. Ask the user first; never abandon a task on your own judgement.",
|
|
764
780
|
inputSchema: z.object({
|
|
@@ -287,7 +287,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
287
287
|
inputSchema: z.strictObject(policy),
|
|
288
288
|
}, async (input) => output(await service.worktreePolicy(json(input))));
|
|
289
289
|
server.registerTool('task.branch', {
|
|
290
|
-
description:
|
|
290
|
+
description: "Start a write task by selecting its decision mode in one short native form, then resolve two short questions for the starting branch (unless base is given) and the folder. A unique branch name is generated automatically unless name is supplied. Pass workItemId (and workItemProjectId when the item lives in a linked project) for work on a work item: the branch question then also offers continuing the branch of its plan or of its earlier rounds, fetched from origin when this clone lacks it, and a planned item that has no branch yet gets its plan branch as the new name. To fix the findings of a code review, pass reviewFixOf with the reviewed task's taskId: the task continues on the branch of its pull request, and session.bootstrap then takes the same reviewFixOf. Folder options: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until a valid native or mode-delegated start answer is recorded; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.",
|
|
291
291
|
inputSchema: z.strictObject({
|
|
292
292
|
repoRoot: repo,
|
|
293
293
|
externalTaskId: z.string().min(2).max(160),
|
|
@@ -297,6 +297,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
297
297
|
keepCurrent: z.boolean().optional(),
|
|
298
298
|
workItemId: z.string().uuid().optional(),
|
|
299
299
|
workItemProjectId: z.string().uuid().optional(),
|
|
300
|
+
reviewFixOf: z.string().uuid().optional(),
|
|
300
301
|
language: languageTag.optional(),
|
|
301
302
|
presentation,
|
|
302
303
|
}),
|
|
@@ -324,6 +325,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
324
325
|
},
|
|
325
326
|
});
|
|
326
327
|
const plan = flight.planBranch;
|
|
328
|
+
const fix = flight.reviewFix;
|
|
327
329
|
const { previous, ...definition } = taskStartDefinition({
|
|
328
330
|
...input,
|
|
329
331
|
...flight,
|
|
@@ -331,6 +333,14 @@ export function registerWorktreeTools(server, service) {
|
|
|
331
333
|
...(plan && flight.continueBranches.includes(plan)
|
|
332
334
|
? { base: { kind: 'existing', branch: plan } }
|
|
333
335
|
: {}),
|
|
336
|
+
...(fix
|
|
337
|
+
? {
|
|
338
|
+
name: fix.branch,
|
|
339
|
+
base: { kind: 'existing', branch: fix.branch },
|
|
340
|
+
keepCurrent: false,
|
|
341
|
+
reviewedTask: fix.externalTaskId,
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
334
344
|
language: told ?? 'en',
|
|
335
345
|
});
|
|
336
346
|
const owner = {
|
|
@@ -344,6 +354,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
344
354
|
keepCurrent: input.keepCurrent,
|
|
345
355
|
workItemId: input.workItemId,
|
|
346
356
|
workItemProjectId: input.workItemProjectId,
|
|
357
|
+
reviewFixOf: input.reviewFixOf,
|
|
347
358
|
}),
|
|
348
359
|
};
|
|
349
360
|
const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, previous, owner);
|
|
@@ -146,6 +146,7 @@ export const mergeRequestSchema = z.object({
|
|
|
146
146
|
draft: z.boolean().optional(),
|
|
147
147
|
work_in_progress: z.boolean().optional(),
|
|
148
148
|
sha: z.string().nullish(),
|
|
149
|
+
title: z.string().optional(),
|
|
149
150
|
source_branch: z.string(),
|
|
150
151
|
target_branch: z.string(),
|
|
151
152
|
project_id: z.number().optional(),
|
|
@@ -226,6 +227,9 @@ export class GitLabClient {
|
|
|
226
227
|
...(request.description ? { description: request.description } : {}),
|
|
227
228
|
}));
|
|
228
229
|
}
|
|
230
|
+
async retitleMergeRequest(address, token, project, number, title) {
|
|
231
|
+
return this.shape(address, mergeRequestSchema, await this.call(address, token, 'PUT', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`, { title }));
|
|
232
|
+
}
|
|
229
233
|
async found(read) {
|
|
230
234
|
try {
|
|
231
235
|
return await read();
|
|
@@ -52,6 +52,13 @@ export const backendRecoveryOperationNames = [
|
|
|
52
52
|
'task.resolve_pending_delivery',
|
|
53
53
|
'task.delivery',
|
|
54
54
|
'task.review_request',
|
|
55
|
+
'project.review_policy',
|
|
56
|
+
'review.queue',
|
|
57
|
+
'review.get',
|
|
58
|
+
'review.start',
|
|
59
|
+
'review.submit',
|
|
60
|
+
'review.reject_finding',
|
|
61
|
+
'review.conclude',
|
|
55
62
|
];
|
|
56
63
|
const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
|
|
57
64
|
const browserSigninRecovery = 'auth.signin_browser';
|