engineering-memory 1.11.27 → 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.
@@ -0,0 +1,322 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import * as z from 'zod/v4';
3
+ import { endpoints } from '../config.js';
4
+ import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
5
+ import { BridgeRecoveryError } from './recovery-error.js';
6
+ export const reviewDeliverySchema = z.object({
7
+ taskId: z.string(),
8
+ externalTaskId: z.string().nullish(),
9
+ state: z.string(),
10
+ choice: z.string().nullish(),
11
+ baseBranch: z.string().nullish(),
12
+ deliveredCommit: z.string().nullish(),
13
+ pushed: z.boolean().nullish(),
14
+ branch: z.string().nullish(),
15
+ workItemKey: z.string().nullish(),
16
+ reviewState: z.string().nullish(),
17
+ reviewRequest: z
18
+ .object({
19
+ provider: z.string(),
20
+ host: z.string(),
21
+ repository: z.string(),
22
+ number: z.number().int().positive(),
23
+ url: z.string(),
24
+ state: z.string(),
25
+ source: z.string().optional(),
26
+ })
27
+ .nullish(),
28
+ });
29
+ const pullRequestChoices = ['commit_push_draft_pr', 'commit_push_pr'];
30
+ export const waitingReview = ['pending', 'in_review', 'findings_open'];
31
+ export const concludedReview = ['passed', 'skipped', 'exempted'];
32
+ const draftPrefix = /^\s*(?:(?:\[draft\]|\(draft\)|draft:|\[wip\]|wip:)\s*)+/i;
33
+ export function opensAsDraft(delivery) {
34
+ const review = delivery.reviewState ?? '';
35
+ return (waitingReview.includes(review) ||
36
+ (delivery.choice === 'commit_push_draft_pr' && !concludedReview.includes(review)));
37
+ }
38
+ export function awaitsReviewRequest(delivery) {
39
+ return (!delivery.reviewRequest &&
40
+ delivery.state === 'delivered' &&
41
+ delivery.pushed === true &&
42
+ pullRequestChoices.includes(delivery.choice ?? ''));
43
+ }
44
+ export function reviewDeliveries(data) {
45
+ const items = z.object({ items: z.array(z.unknown()) }).safeParse(data);
46
+ return (items.success ? items.data.items : []).flatMap((item) => {
47
+ const delivery = reviewDeliverySchema.safeParse(item);
48
+ return delivery.success ? [delivery.data] : [];
49
+ });
50
+ }
51
+ const manualWay = 'Opening it in GitLab yourself always works: record it afterwards with task.review_request and its link.';
52
+ export class MergeRequests {
53
+ client;
54
+ tokens;
55
+ gitlab;
56
+ git;
57
+ passes = new Map();
58
+ running = new Set();
59
+ constructor(client, tokens, gitlab, git) {
60
+ this.client = client;
61
+ this.tokens = tokens;
62
+ this.gitlab = gitlab;
63
+ this.git = git;
64
+ }
65
+ async open(input) {
66
+ const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
67
+ .data);
68
+ if (delivery.reviewRequest)
69
+ return { recorded: { url: delivery.reviewRequest.url, state: delivery.reviewRequest.state } };
70
+ if (delivery.state === 'cancelled')
71
+ throw new BridgeRecoveryError('This delivery was concluded without delivering, so it gets no merge request.', 'task.delivery');
72
+ const branch = delivery.branch;
73
+ if (!branch)
74
+ throw new BridgeRecoveryError('This task has no branch, so there is nothing to open a merge request from.', 'task.review_request');
75
+ const chosen = pullRequestChoices.includes(delivery.choice ?? '');
76
+ const target = input.targetBranch ?? (chosen ? delivery.baseBranch : null);
77
+ if (!target)
78
+ throw new BridgeRecoveryError('The delivery answer did not choose a pull request, so it names no target branch. If the user wants a merge request, call again with the targetBranch they name.', 'task.open_review_request');
79
+ const draft = input.draft ?? opensAsDraft(delivery);
80
+ const pushUrl = await this.git.branchPushUrl(input.repoRoot, branch);
81
+ if (!pushUrl)
82
+ throw new BridgeRecoveryError(`This clone has no remote that ${branch} is pushed to. ${manualWay}`, 'task.review_request');
83
+ const remote = gitLabRemote(pushUrl);
84
+ if ('refusal' in remote)
85
+ throw new BridgeRecoveryError(remoteRefusal(remote.refusal, pushUrl), 'task.review_request');
86
+ const address = (await this.tokens.address(remote.host)) ?? remote.address;
87
+ const token = await this.tokens.token(address);
88
+ if (!token)
89
+ throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${address}. Call gitlab.token and give the user the link it returns; they save their own token there, and then this call works. ${manualWay}`, 'gitlab.token', { address });
90
+ const path = projectPath(address, remote.path);
91
+ try {
92
+ const project = await this.gitlab.project(address, token, path);
93
+ if (!project)
94
+ throw new BridgeRecoveryError(`GitLab at ${address} shows no project ${path} to this token. If GitLab runs under a path (like https://host/gitlab), save that address with gitlab.token; otherwise the token's user needs access to the project. ${manualWay}`, 'gitlab.token', { address, project: path });
95
+ const remoteHead = await this.gitlab.branch(address, token, path, branch);
96
+ if (!remoteHead)
97
+ throw new BridgeRecoveryError(`${branch} is not on GitLab yet. Push it, then call again.`, 'task.open_review_request');
98
+ const local = await this.git.branchCommit(input.repoRoot, branch).catch(() => null);
99
+ const delivered = local?.commit ?? delivery.deliveredCommit ?? null;
100
+ if (delivered &&
101
+ delivered !== remoteHead.commit &&
102
+ !(await this.gitlab.contains(address, token, path, branch, delivered)))
103
+ throw new BridgeRecoveryError(`${branch} on GitLab does not have ${delivered.slice(0, 12)} yet, so a merge request would lack it. Push the branch, then call again.`, 'task.open_review_request');
104
+ const source = { id: project.id, path };
105
+ const existing = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
106
+ if (existing)
107
+ return opened(existing, true);
108
+ if (!(await this.gitlab.branch(address, token, path, target)))
109
+ throw new BridgeRecoveryError(`The target branch ${target} does not exist on GitLab. Ask the user which branch the merge request goes into and call again with targetBranch.`, 'task.open_review_request');
110
+ const title = ((draft ? 'Draft: ' : '') +
111
+ (input.title ??
112
+ defaultTitle(local?.subject || remoteHead.title || branch, delivery.workItemKey))).slice(0, 255);
113
+ try {
114
+ return opened(await this.gitlab.createMergeRequest(address, token, path, {
115
+ source: branch,
116
+ target,
117
+ title,
118
+ ...(input.description ? { description: input.description } : {}),
119
+ }), false);
120
+ }
121
+ catch (error) {
122
+ if (!(error instanceof GitLabError) ||
123
+ !['conflict', 'unknown_outcome', 'server_error'].includes(error.failure))
124
+ throw error;
125
+ const found = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
126
+ if (found)
127
+ return opened(found, true);
128
+ throw error;
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (error instanceof GitLabError)
133
+ throw gitLabRefusal(error);
134
+ throw error;
135
+ }
136
+ }
137
+ async markReady(request) {
138
+ const link = await this.tokens.forLink(request.host, request.repository);
139
+ const token = link ? await this.tokens.token(link.address) : null;
140
+ if (!link || !token)
141
+ throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${request.host}. Ask the user to mark ${request.url} ready in GitLab ("Mark as ready"); Engineering Memory follows it. Or call gitlab.token so they save their own token here, then call review.make_ready again.`, 'gitlab.token');
142
+ try {
143
+ const current = await this.gitlab.mergeRequest(link.address, token, link.project, request.number);
144
+ if (reviewState(current) !== 'draft')
145
+ return { url: current.web_url, state: reviewState(current) };
146
+ if (current.title === undefined)
147
+ throw new GitLabError('unexpected_answer', link.address, null, null);
148
+ const after = await this.gitlab.retitleMergeRequest(link.address, token, link.project, request.number, current.title.replace(draftPrefix, '') || defaultTitle(current.source_branch, null));
149
+ return { url: after.web_url, state: reviewState(after) };
150
+ }
151
+ catch (error) {
152
+ if (!(error instanceof GitLabError))
153
+ throw error;
154
+ const at = error.address;
155
+ const reason = error.failure === 'token_rejected'
156
+ ? `GitLab at ${at} no longer accepts the saved token (expired or revoked); gitlab.token saves a new one.`
157
+ : error.failure === 'not_allowed'
158
+ ? `GitLab at ${at} refused this with the saved token: changing a merge request needs the api scope and Developer or higher.`
159
+ : error.failure === 'unreachable'
160
+ ? `This computer cannot reach ${at}; if GitLab is reachable only from the company network, connect and call review.make_ready again.`
161
+ : error.failure === 'unknown_outcome'
162
+ ? `The answer of GitLab at ${at} was lost after the change was sent, so it may already be ready; calling review.make_ready again is safe and reads where it stands.`
163
+ : `GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}.`;
164
+ throw new BridgeRecoveryError(`${reason} ${request.url} may still be a draft: the user can mark it ready in GitLab ("Mark as ready"); Engineering Memory follows it.`, error.failure === 'token_rejected' || error.failure === 'not_allowed'
165
+ ? 'gitlab.token'
166
+ : 'review.make_ready', { address: at, failure: error.failure, status: error.status });
167
+ }
168
+ }
169
+ schedule(projectId, repoRoot, deliveries) {
170
+ const now = Date.now();
171
+ const last = this.passes.get(projectId);
172
+ if (last !== undefined && now - last < 5 * 60 * 1000)
173
+ return;
174
+ this.passes.set(projectId, now);
175
+ const pass = this.read(projectId, repoRoot, deliveries).then(() => undefined, () => undefined);
176
+ this.running.add(pass);
177
+ void pass.finally(() => this.running.delete(pass));
178
+ }
179
+ async settle() {
180
+ while (this.running.size)
181
+ await Promise.all([...this.running]);
182
+ }
183
+ async read(projectId, root, given) {
184
+ if (!(await this.tokens.any()))
185
+ return { reported: 0 };
186
+ const repoRoot = typeof root === 'function' ? await root().catch(() => null) : root;
187
+ const deliveries = given ??
188
+ reviewDeliveries((await this.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`)).data);
189
+ const pass = { failed: new Set(), projects: new Map(), calls: 100 };
190
+ const queue = [...deliveries];
191
+ let reported = 0;
192
+ const worker = async () => {
193
+ for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
194
+ const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
195
+ if (found && (await this.report(projectId, delivery.taskId, found)))
196
+ reported++;
197
+ }
198
+ };
199
+ await Promise.all([worker(), worker(), worker(), worker()]);
200
+ return { reported };
201
+ }
202
+ async follow(delivery, repoRoot, pass) {
203
+ const known = delivery.reviewRequest;
204
+ if (known) {
205
+ if (known.provider !== 'gitlab' || !['draft', 'ready'].includes(known.state))
206
+ return null;
207
+ const link = await this.tokens.forLink(known.host, known.repository);
208
+ const token = link && !pass.failed.has(link.address) ? await this.tokens.token(link.address) : null;
209
+ if (!link || !token)
210
+ return null;
211
+ const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
212
+ if (!request)
213
+ return null;
214
+ const state = reviewState(request);
215
+ return state !== known.state || known.source !== 'machine' ? { url: known.url, state } : null;
216
+ }
217
+ if (!repoRoot || !delivery.branch)
218
+ return null;
219
+ const pushUrl = await this.git.branchPushUrl(repoRoot, delivery.branch).catch(() => null);
220
+ const remote = pushUrl ? gitLabRemote(pushUrl) : null;
221
+ if (!remote || 'refusal' in remote)
222
+ return null;
223
+ const address = (await this.tokens.address(remote.host)) ?? remote.address;
224
+ const token = pass.failed.has(address) ? null : await this.tokens.token(address);
225
+ if (!token)
226
+ return null;
227
+ const path = projectPath(address, remote.path);
228
+ const key = `${address}\n${path}`;
229
+ if (!pass.projects.has(key))
230
+ pass.projects.set(key, await this.call(pass, address, () => this.gitlab.project(address, token, path)));
231
+ const project = pass.projects.get(key);
232
+ if (!project)
233
+ return null;
234
+ const requests = await this.call(pass, address, () => this.gitlab.mergeRequests(address, token, { id: project.id, path }, delivery.branch));
235
+ const rank = (request) => request.state === 'opened' || request.state === 'locked'
236
+ ? 0
237
+ : request.state === 'merged'
238
+ ? 1
239
+ : 2;
240
+ const chosen = (requests ?? [])
241
+ .filter((request) => rank(request) < 2)
242
+ .sort((left, right) => rank(left) - rank(right) ||
243
+ Number(right.sha === delivery.deliveredCommit) -
244
+ Number(left.sha === delivery.deliveredCommit))[0];
245
+ return chosen ? { url: chosen.web_url, state: reviewState(chosen) } : null;
246
+ }
247
+ async call(pass, address, request) {
248
+ if (pass.failed.has(address) || pass.calls <= 0)
249
+ return null;
250
+ pass.calls--;
251
+ try {
252
+ return await request();
253
+ }
254
+ catch (error) {
255
+ if (!(error instanceof GitLabError))
256
+ throw error;
257
+ pass.failed.add(address);
258
+ return null;
259
+ }
260
+ }
261
+ async report(projectId, taskId, found) {
262
+ try {
263
+ await this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
264
+ method: 'POST',
265
+ body: { url: found.url, state: found.state, source: 'machine' },
266
+ idempotencyKey: randomUUID(),
267
+ });
268
+ return true;
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
274
+ }
275
+ function opened(request, reused) {
276
+ return {
277
+ url: request.web_url,
278
+ state: reviewState(request),
279
+ sourceBranch: request.source_branch,
280
+ targetBranch: request.target_branch,
281
+ reused,
282
+ };
283
+ }
284
+ export function defaultTitle(subject, workItemKey) {
285
+ const title = subject.trim().slice(0, 240) || 'Merge request';
286
+ return workItemKey && !title.toLowerCase().includes(workItemKey.toLowerCase())
287
+ ? `${workItemKey}: ${title}`
288
+ : title;
289
+ }
290
+ function remoteRefusal(refusal, pushUrl) {
291
+ switch (refusal) {
292
+ case 'github':
293
+ return `The branch is pushed to GitHub (${pushUrl}). Engineering Memory opens merge requests only on GitLab so far: open the pull request with the tools you have and record it with task.review_request.`;
294
+ case 'ip':
295
+ return `The remote is reached by an IP address (${pushUrl}), and Engineering Memory records no link with an IP address in it. Open the merge request in GitLab and move the work item with work_item.update.`;
296
+ case 'local':
297
+ return `The remote is a folder on disk (${pushUrl}), not a GitLab server. ${manualWay}`;
298
+ default:
299
+ return `The remote address ${pushUrl} is not one Engineering Memory can read as a GitLab project. ${manualWay}`;
300
+ }
301
+ }
302
+ function gitLabRefusal(error) {
303
+ const at = error.address;
304
+ const details = { address: at, failure: error.failure, status: error.status };
305
+ switch (error.failure) {
306
+ case 'token_rejected':
307
+ return new BridgeRecoveryError(`GitLab at ${at} no longer accepts the saved token (expired or revoked). Call gitlab.token so the user can save a new one. ${manualWay}`, 'gitlab.token', details);
308
+ case 'not_allowed':
309
+ return new BridgeRecoveryError(`GitLab at ${at} refused this with the saved token: it needs the api scope and a role that may open merge requests in the project (Developer or higher). Nothing was opened. ${manualWay}`, 'gitlab.token', details);
310
+ case 'unreachable':
311
+ return new BridgeRecoveryError(`This computer cannot reach ${at}. If GitLab is only reachable from the company network, the user connects (for example through the VPN) and you call again. Nothing was opened.`, 'task.open_review_request', details);
312
+ case 'untrusted_certificate':
313
+ return new BridgeRecoveryError(`This computer does not trust the certificate of ${at}. If the company uses its own certificate, the user sets NODE_EXTRA_CA_CERTS to its file and restarts the coding agent. Nothing was opened.`, 'task.open_review_request', details);
314
+ case 'unknown_outcome':
315
+ return new BridgeRecoveryError(`The answer from ${at} was lost after the merge request was sent, and it is not visible yet. Call again: an open merge request of the branch is reused, never opened twice.`, 'task.open_review_request', details);
316
+ case 'redirected':
317
+ return new BridgeRecoveryError(`${at} answered with a redirect, so the saved address is probably not GitLab's own. Call gitlab.token so the user can correct the address.`, 'gitlab.token', details);
318
+ default:
319
+ return new BridgeRecoveryError(`GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}${error.detail ? `: ${error.detail}` : ''}. Nothing more was done. ${manualWay}`, 'task.open_review_request', details);
320
+ }
321
+ }
322
+ //# sourceMappingURL=merge-request-sync.js.map
@@ -9,15 +9,17 @@ export class PrincipalStateGuard {
9
9
  outbox;
10
10
  activeContexts;
11
11
  gate;
12
+ gitlabTokens;
12
13
  ownerPath;
13
14
  queue = Promise.resolve();
14
- constructor(stateRoot, credentials, cache, outbox, activeContexts, gate) {
15
+ constructor(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens) {
15
16
  this.stateRoot = stateRoot;
16
17
  this.credentials = credentials;
17
18
  this.cache = cache;
18
19
  this.outbox = outbox;
19
20
  this.activeContexts = activeContexts;
20
21
  this.gate = gate;
22
+ this.gitlabTokens = gitlabTokens;
21
23
  this.ownerPath = join(stateRoot, 'principal-owner.json');
22
24
  }
23
25
  async ensure() {
@@ -55,6 +57,7 @@ export class PrincipalStateGuard {
55
57
  await this.cache.clear();
56
58
  await this.activeContexts.clear();
57
59
  await this.gate.clear();
60
+ await this.gitlabTokens?.clear();
58
61
  await removeFile(this.ownerPath, this.stateRoot);
59
62
  }
60
63
  async exclusive(action) {
@@ -0,0 +1,31 @@
1
+ import { restrictedValueKind } from './privacy-detector.js';
2
+ const masks = [
3
+ [/[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}.-]+\.\p{L}{2,63}/gu, 'e-mail address', () => '[e-mail]'],
4
+ [
5
+ /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g,
6
+ 'IP address',
7
+ (match) => (match.split('.').every((part) => Number(part) <= 255) ? '[IP]' : match),
8
+ ],
9
+ [
10
+ /[A-Z]:[\\/](?:Users|Documents and Settings)[\\/][^\\/\s"'<>:|?*]+|\\\\[^\\\s]+\\Users\\[^\\/\s"'<>*]+|(?:\/mnt\/[a-z]|\/cygdrive\/[a-z]|\/System\/Volumes\/Data|\/Volumes\/[^/\s]+|\/export)\/(?:Users|home)\/[^\\/\s"'<>*]+/gi,
11
+ 'user folder',
12
+ () => '~',
13
+ ],
14
+ [
15
+ /(^|[\s"'(<=])\/(?:[a-z]\/)?(?:Users|home)\/[^\\/\s"'<>*]+/gi,
16
+ 'user folder',
17
+ (_, before) => before + '~',
18
+ ],
19
+ ];
20
+ export function maskFinding(description) {
21
+ const masked = new Set();
22
+ let text = description;
23
+ for (const [pattern, kind, replace] of masks) {
24
+ const next = text.replace(pattern, replace);
25
+ if (next !== text)
26
+ masked.add(kind);
27
+ text = next;
28
+ }
29
+ return { text, masked: [...masked], left: restrictedValueKind(text, 'description') };
30
+ }
31
+ //# sourceMappingURL=review-masking.js.map
@@ -51,6 +51,7 @@ const startWording = z.strictObject({
51
51
  newBranch: text,
52
52
  newBranchOrContinue: text,
53
53
  continuePlan: text,
54
+ continueReview: text,
54
55
  example: text,
55
56
  stay: text,
56
57
  base: z.strictObject({
@@ -199,11 +200,16 @@ export function taskStartDefinition(facts) {
199
200
  message: copy.message,
200
201
  context: keepOnly
201
202
  ? copy.keepContext
202
- : binding.base?.kind === 'existing'
203
- ? format(copy.continuePlan, language, { branch: binding.base.branch })
204
- : format(Object.values(binding.bases ?? {}).some((base) => base.kind === 'existing')
205
- ? copy.newBranchOrContinue
206
- : copy.newBranch, language, { name: binding.name }) + (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
203
+ : binding.base?.kind === 'existing' && facts.reviewedTask
204
+ ? format(copy.continueReview, language, {
205
+ branch: binding.base.branch,
206
+ task: facts.reviewedTask,
207
+ })
208
+ : binding.base?.kind === 'existing'
209
+ ? format(copy.continuePlan, language, { branch: binding.base.branch })
210
+ : format(Object.values(binding.bases ?? {}).some((base) => base.kind === 'existing')
211
+ ? copy.newBranchOrContinue
212
+ : copy.newBranch, language, { name: binding.name }) + (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
207
213
  example: keepOnly ? format(copy.stay, language, { source: current }) : copy.example,
208
214
  questions,
209
215
  binding: JSON.parse(JSON.stringify(binding)),
@@ -216,7 +222,7 @@ function legacyTaskStartDefinition(facts) {
216
222
  const folder = facts.folder;
217
223
  const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
218
224
  const here = !keepOnly && !folder.managed && !folder.heldBy && folder.clean;
219
- const keep = keepOnly || (!folder.managed && !folder.heldBy && !folder.holder);
225
+ const keep = !facts.reviewedTask && (keepOnly || (!folder.managed && !folder.heldBy && !folder.holder));
220
226
  const holder = here && folder.holder ? holderIdentity(folder.holder) : null;
221
227
  const currentLabel = sourceLabel({ kind: 'current', ...(facts.currentCommit ? { commit: facts.currentCommit } : {}) }, facts.currentBranch);
222
228
  const binding = {
@@ -296,9 +296,19 @@ Call `task.close` only after verification and only when the current diff still m
296
296
 
297
297
  After memory approval, finish any required reconciliation and verification, then call `task.close` in the same turn; do not stop at publishing memory or at a successful verify result. Closing is not the end of the turn either. The MCP `task.close` call closes the verified task and opens its durable, mode-governed delivery selector itself: commit, commit and push, commit and push with a draft PR/MR, commit and push with a PR/MR, or keep it for now. Do not open a duplicate delivery questionnaire. Follow its pending/delegated/native result and retry the same call until a real decision is recorded. Cancellation and feedback preserve the closed worktree and pending delivery. PR/MR target selection is a second question when the target is not already explicitly supplied; merging is separate and never implied. An explicit Git delivery instruction already present in the user's own message can be passed as `deliveryInstruction` with its exact relevant `userRequestExcerpt`, choice and any explicit baseBranch. It is reported as an agent-reported user instruction, not a native answer; never use memory approval or an agent preference as that instruction. Read-only tasks have no Git delivery form. Perform only the selected and authorized Git action; the tool itself commits, pushes and merges nothing. When the host has to approve a push, follow the Delivery section of `questionnaires.md`; an Engineering Memory form is never the way past a host's denial.
298
298
 
299
- When a pull request has been opened, keep going: record it with `task.review_request` — its link as the provider shows it, and `draft` or `ready` as it was opened — then check whether it merges cleanly, report the result with the link, and if it conflicts, name the files and ask whether to resolve them. Never end the turn on a pull request whose mergeability was never checked, and never resolve a conflict without being told to.
299
+ When the delivery chose a pull request and the branch is pushed to a GitLab, open it with `task.open_review_request` in the task's clone: it opens the merge request from this computer — a GitLab reachable only from the company network included — reuses one the branch already has, and records it in the same call. When it says no GitLab token is saved, call `gitlab.token` and give the user the link it returns: they type their own token on that page, on their own computer. Never open or fill in that page, never ask for the token, and never use a token pasted into the chat — ask the user to revoke it and use the page. For any other provider, or when the tool refuses, open the pull request with the tools you have and record it with `task.review_request` — its link as the provider shows it, and `draft` or `ready` as it was opened. Either way, keep going: check whether it merges cleanly, report the result with the link, and if it conflicts, name the files and ask whether to resolve them. Never end the turn on a pull request whose mergeability was never checked, and never resolve a conflict without being told to. `session.entry` lists as `awaitingReviewRequests` the delivered tasks still waiting for their pull request; mention them in one line.
300
300
 
301
- When the user later says a pull request became ready, was merged or was closed, record that state with `task.review_request` too; it works from any chat, also for a delivery `session.entry` lists. Engineering Memory then moves the task's work item as the project's workflow rules say: to the `pr_opened` target once every pull request of the work item is ready, to the `all_prs_merged` target (READY FOR QA by default) once they are all merged, and to the draft target when one is still a draft and the project names one. It waits while another task of the work item is still open, or closed with a delivery that has no pull request yet. Relay the returned `nextAction` in one line. The record is what was reported: Engineering Memory does not check it with the provider, so never report a merge the user has not confirmed. A merge is final, and a delivery keeps the first pull request recorded for it. GitHub pull request and GitLab merge request links are recorded; for another provider, move the work item with `work_item.update`.
301
+ When the user later says a pull request became ready, was merged or was closed, record that state with `task.review_request` too; it works from any chat, also for a delivery `session.entry` lists. Engineering Memory then moves the task's work item as the project's workflow rules say: to the `pr_opened` target once every pull request of the work item is ready, to the `all_prs_merged` target (READY FOR QA by default) once they are all merged, and to the draft target when one is still a draft and the project names one. It waits while another task of the work item is still open, or closed with a delivery that has no pull request yet. Relay the returned `nextAction` in one line. A record is what was reported, unless a member's computer read it from GitLab: where someone has saved a GitLab token, the bridge reads the project's merge requests in the background when a chat opens or work items are listed, and records a merge, a close or a draft turning ready by itself, also for a merge request someone opened by hand. Never report a merge the user has not confirmed. A merge is final, and a delivery keeps the first pull request recorded for it. GitHub pull request and GitLab merge request links are recorded; for another provider, move the work item with `work_item.update`.
302
+
303
+ ## Code review
304
+
305
+ A project can review code before a pull request is ready: `project.review_policy` reads its setting — none, optional or required — and, only when the user asks to change it, `change: true` asks them in one native form; only an owner or maintainer can change it. Under optional the delivery question offers a pull request reviewed first, which opens as a draft, and one without review; under required only the reviewed one. A user's own instruction for a ready pull request under required becomes the reviewed one, and the tool says so; relay it. When the tool says the setting changed while the question was open, tell the user and call it again.
306
+
307
+ After a reviewed pull request is pushed and the worktree released, open it as a draft (`task.open_review_request`, or your own tools and `task.review_request` with `draft`), then call `review.start` in a clone of the project. It fetches the pushed branch without checking anything out and returns the review packet. Give the packet to a fresh subagent that did not write the change; only when your host cannot start one, review it yourself under the same rules. The reviewer reads the change only through the read-only Git commands the packet lists: it never checks out, switches branches, edits, commits, pushes or changes a file. It judges the change against the packet's records (more with `review.rules`) and says whether each earlier finding still holds. Each finding names the file by its path in the repository, the line in the reviewed commit when there is one, the key of the record it breaks when one applies, and what is wrong and why in plain words. Record the result with `review.submit`, an empty list when there are no findings. Describe a problem without the value itself: e-mail addresses, IP addresses and user folders are masked, and a description that still looks like it holds a secret or personal data is refused.
308
+
309
+ Show the findings to the user and ask whether to fix them. A fix is a write task: `task.branch` with `reviewFixOf` set to the reviewed task's id, then `session.bootstrap` with the same `reviewFixOf`; it continues the pull request's branch, and its delivery offers commit or commit and push onto that branch — never a second pull request. After its push the open findings are marked fixed; ask whether to review again (`review.start`) or end the review (`review.conclude` with passed). A finding the user disagrees with is rejected with `review.reject_finding`, where the user writes the reason in the form; never write it for them. `review.conclude` also skips a review where the setting is not required, and exempts one under required, which only an owner or maintainer can do and only with the reason the user writes. Every one of these asks the user in a native form first.
310
+
311
+ Once a review has ended, call `review.make_ready`: on GitLab, with the user's own token saved, it removes Draft and records the pull request ready; anywhere else follow its `nextAction` (for example `gh pr ready`) and record the state with `task.review_request`. It also makes ready, when the user asks, a pull request that has no code review. Never mark a draft ready while its review has not ended. `session.entry` lists as `awaitingReviews` the deliveries that wait for code review; mention them in one line and start nothing on your own. When the user asks, `review.queue` and `review.get` read them, and every member except readers can continue any review where it stopped.
302
312
 
303
313
  ## Code source and memory applicability
304
314