engineering-memory 1.11.29 → 1.11.30

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.29",
3
+ "version": "1.11.30",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "0bcad22013da985e82e5ac61c6fcf0f3bd598fd2"
2
+ "gitHead": "36588e4f0edd74e5ca4f7ebb8c8f77a41a2af925"
3
3
  }
@@ -80,6 +80,9 @@ export const endpoints = {
80
80
  reviewPass: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/pass`,
81
81
  reviewSkip: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/skip`,
82
82
  reviewExemption: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/exemption`,
83
+ reviewRoundComment: (projectId, roundId) => `/projects/${projectId}/review-rounds/${roundId}/comment`,
84
+ reviewComment: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/comment`,
85
+ reviewProviderFailure: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/provider-failure`,
83
86
  projectSetup: '/projects/setup',
84
87
  projectList: '/projects',
85
88
  projectResolve: '/projects/resolve',
@@ -270,6 +270,15 @@ export const bundledTexts = {
270
270
  'reviewBatch.untitled': 'untitled {kind}',
271
271
  'reviewBatch.unversioned': 'Written without a source inspection: {titles}. Tasks that read memory by source version will not see them until an inspection retains them.',
272
272
  'reviewBatch.why': 'Why',
273
+ 'reviewComment.exempted': 'Engineering Memory code review of {task}: exempted. Reason: {reason}',
274
+ 'reviewComment.findingsOf': 'Findings of round {round}:',
275
+ 'reviewComment.fixed': 'fixed in {commit}',
276
+ 'reviewComment.open': 'still open',
277
+ 'reviewComment.passed': 'Engineering Memory code review of {task}: passed after {rounds, plural, one {# round} other {# rounds}}',
278
+ 'reviewComment.rejected': 'rejected: {reason}',
279
+ 'reviewComment.round': 'Engineering Memory code review of {task}, round {round}: {count, plural, one {# finding} other {# findings}}',
280
+ 'reviewComment.rule': 'rule {rule}',
281
+ 'reviewComment.skipped': 'Engineering Memory code review of {task}: skipped',
273
282
  'reviewConclude.confirm.description': 'The review ends as described above.',
274
283
  'reviewConclude.confirm.label': 'Yes, end it',
275
284
  'reviewConclude.defer.description': 'The review stays open; nothing is recorded.',
@@ -736,6 +745,15 @@ export const bundledTexts = {
736
745
  'reviewBatch.untitled': 'başlıksız {kind}',
737
746
  'reviewBatch.unversioned': 'Kaynak incelemesi yapılmadan yazılanlar: {titles}. Hafızayı kaynak sürümüne göre okuyan görevler, bir inceleme bunları saklayana kadar görmez.',
738
747
  'reviewBatch.why': 'Nedeni',
748
+ 'reviewComment.exempted': '{task} için Engineering Memory kod incelemesi: muaf tutuldu. Gerekçe: {reason}',
749
+ 'reviewComment.findingsOf': 'Tur {round} bulguları:',
750
+ 'reviewComment.fixed': '{commit} ile düzeltildi',
751
+ 'reviewComment.open': 'hâlâ açık',
752
+ 'reviewComment.passed': '{task} için Engineering Memory kod incelemesi: {rounds, plural, one {# turda} other {# turda}} geçti',
753
+ 'reviewComment.rejected': 'reddedildi: {reason}',
754
+ 'reviewComment.round': '{task} için Engineering Memory kod incelemesi, tur {round}: {count, plural, one {# bulgu} other {# bulgu}}',
755
+ 'reviewComment.rule': 'kural {rule}',
756
+ 'reviewComment.skipped': '{task} için Engineering Memory kod incelemesi: atlandı',
739
757
  'reviewConclude.confirm.description': 'İnceleme yukarıda anlatıldığı gibi sonuçlanır.',
740
758
  'reviewConclude.confirm.label': 'Evet, bitir',
741
759
  'reviewConclude.defer.description': 'İnceleme açık kalır; hiçbir şey kaydedilmez.',
@@ -647,28 +647,47 @@ function registerTaskDelivery(server, service) {
647
647
  }
648
648
  function registerTaskReviewRequest(server, service) {
649
649
  server.registerTool('task.review_request', {
650
- description: "Record the pull request (GitHub) or merge request (GitLab) a closed task's changes went into, and its state: draft, ready, merged or closed. Call it with the link copied from the provider as soon as the pull request is opened, and again when the user says it became ready, was merged or was closed. Engineering Memory then moves the task's work item as the project's workflow rules say, for example to PR when every pull request of the work item is ready and to READY FOR QA when they are all merged. It works from any chat, also for a delivery session.entry lists. What is recorded is what was reported: Engineering Memory does not check it with the provider, and performs no Git or provider action.",
650
+ description: "Record the pull request (GitHub) or merge request (GitLab) a closed task's changes went into, and its state: draft, ready, merged or closed. Call it with the link copied from the provider as soon as the pull request is opened, and again when the user says it became ready, was merged or was closed. Pass headCommit and targetBranch when you can read them from the provider (on GitHub, gh pr view with --json headRefOid,baseRefName): a code review that had passed waits again when the pull request gets a new commit or a new target branch. Engineering Memory then moves the task's work item as the project's workflow rules say, for example to PR when every pull request of the work item is ready and to READY FOR QA when they are all merged. It works from any chat, also for a delivery session.entry lists. What is recorded is what was reported: Engineering Memory does not check it with the provider, and performs no Git or provider action.",
651
651
  inputSchema: z.strictObject({
652
652
  projectId: z.string().uuid(),
653
653
  taskId: z.string().uuid(),
654
654
  url: z.string().trim().min(1).max(500),
655
655
  state: z.enum(['draft', 'ready', 'merged', 'closed']),
656
+ headCommit: z
657
+ .string()
658
+ .regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/)
659
+ .optional(),
660
+ targetBranch: z.string().trim().min(1).max(255).optional(),
656
661
  }),
657
662
  }, async (input) => {
658
663
  const result = await service.taskReviewRequest(input);
659
664
  if (!result.ok)
660
665
  return output(result);
661
666
  const recorded = result.data;
667
+ const reopened = recorded.data?.reviewReopened;
668
+ const job = recorded.data?.providerJob;
662
669
  return output({
663
670
  ok: true,
664
671
  data: {
665
672
  ...result.data,
666
673
  nextAction: recorded.queued
667
674
  ? 'Engineering Memory is unreachable, so this report is queued. It is recorded, and the work item moves as the rules say, when Engineering Memory is reachable again.'
668
- : (recorded.data?.reviewEvent?.nextAction ??
669
- (recorded.data?.reviewEvent
670
- ? 'The pull request is recorded; the work item stays where it is.'
671
- : 'The pull request is recorded. The work item does not move yet: other work on it is still open or undelivered, or the task has no active work item.')),
675
+ : [
676
+ reopened
677
+ ? `The code review of this delivery had passed, but the pull request has ${reopened === 'new_target' ? 'a new target branch' : 'a new commit'} now, so the review waits again. Tell the user in one line; review.start runs the next round.`
678
+ : '',
679
+ job === 'make_draft'
680
+ ? 'This project requires code review, so turn the pull request back into a draft with your own tools (on GitHub, gh pr ready --undo) and record it with state draft.'
681
+ : job === 'make_ready'
682
+ ? 'Its code review has already ended: review.make_ready makes the pull request ready.'
683
+ : '',
684
+ recorded.data?.reviewEvent?.nextAction ??
685
+ (recorded.data?.reviewEvent
686
+ ? 'The pull request is recorded; the work item stays where it is.'
687
+ : 'The pull request is recorded. The work item does not move yet: other work on it is still open or undelivered, or the task has no active work item.'),
688
+ ]
689
+ .filter(Boolean)
690
+ .join(' '),
672
691
  },
673
692
  });
674
693
  });
@@ -39,6 +39,7 @@ const reviewRead = z.object({
39
39
  delivery: z.object({
40
40
  externalTaskId: z.string(),
41
41
  reviewState: z.string().nullish(),
42
+ reviewReopenReason: z.string().nullish(),
42
43
  reviewPolicy: z.string().optional(),
43
44
  }),
44
45
  rounds: z.array(z.object({ id: z.string(), state: z.string() })),
@@ -132,7 +133,7 @@ export function registerReviewTools(server, service) {
132
133
  inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
133
134
  }, async (input) => output(await service.reviewGet(input)));
134
135
  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
+ 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; from the second round on, also what changed since the previous round. The target branch is the one recorded for its PR/MR, else the one the delivery answer named; pass targetBranch only when neither is right.",
136
137
  inputSchema: z.strictObject({
137
138
  projectId: z.string().uuid(),
138
139
  taskId: z.string().uuid(),
@@ -150,7 +151,7 @@ export function registerReviewTools(server, service) {
150
151
  }),
151
152
  }, async (input) => output(await service.reviewRules(input)));
152
153
  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
+ 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. The findings, and later the conclusion, are written on the PR/MR as a comment: on GitLab with the token the user saved on this computer, elsewhere comment.body is returned for you to post.',
154
155
  inputSchema: z.strictObject({
155
156
  projectId: z.string().uuid(),
156
157
  roundId: z.string().uuid(),
@@ -285,6 +286,8 @@ export function registerReviewTools(server, service) {
285
286
  });
286
287
  if (!waitingReview.includes(state ?? ''))
287
288
  return refusal(`The review of ${task} is ${state ?? 'not tracked'}, so there is nothing to end. review.get shows where it stands.`, 'review.get');
289
+ if (input.outcome === 'passed' && review.delivery.reviewReopenReason)
290
+ return refusal(`The review of ${task} had passed, then its PR/MR got ${review.delivery.reviewReopenReason === 'new_target' ? 'a new target branch' : 'a new commit'}, so it passes again only after a round reviews it. Start one with review.start, or ask the user whether to skip or exempt it.`, 'review.start');
288
291
  if (input.outcome === 'passed' &&
289
292
  (state !== 'pending' ||
290
293
  !review.rounds.some((round) => round.state === 'submitted') ||
@@ -353,7 +356,7 @@ export function registerReviewTools(server, service) {
353
356
  });
354
357
  });
355
358
  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.",
359
+ 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 writes the review conclusion as a comment when it is not written yet, 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
360
  inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
358
361
  }, async (input) => output(await service.reviewMakeReady(input)));
359
362
  }
@@ -152,6 +152,7 @@ export const mergeRequestSchema = z.object({
152
152
  project_id: z.number().optional(),
153
153
  source_project_id: z.number().optional(),
154
154
  });
155
+ const noteSchema = z.object({ id: z.number(), body: z.string() });
155
156
  export function reviewState(request) {
156
157
  if (request.state === 'merged')
157
158
  return 'merged';
@@ -227,6 +228,13 @@ export class GitLabClient {
227
228
  ...(request.description ? { description: request.description } : {}),
228
229
  }));
229
230
  }
231
+ async notes(address, token, project, number) {
232
+ const query = new URLSearchParams({ sort: 'desc', order_by: 'created_at', per_page: '100' });
233
+ return this.shape(address, z.array(noteSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes?${query}`));
234
+ }
235
+ async createNote(address, token, project, number, body) {
236
+ this.shape(address, noteSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes`, { body }));
237
+ }
230
238
  async retitleMergeRequest(address, token, project, number, title) {
231
239
  return this.shape(address, mergeRequestSchema, await this.call(address, token, 'PUT', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`, { title }));
232
240
  }
@@ -95,6 +95,11 @@ export class ApiResponseError extends Error {
95
95
  this.recovery = normalizedRecovery;
96
96
  }
97
97
  }
98
+ export function backendRejectsField(error, fields) {
99
+ return (error instanceof ApiResponseError &&
100
+ error.httpStatus === 400 &&
101
+ fields.some((field) => error.message.includes(`property ${field} should not exist`)));
102
+ }
98
103
  export class ApiClient {
99
104
  options;
100
105
  fetchImplementation;
@@ -15,7 +15,7 @@ import { minimatch } from 'minimatch';
15
15
  import { endpoints } from '../config.js';
16
16
  import { startPhaseTimer } from './phase-timer.js';
17
17
  import { sha256, stableStringify } from '../utilities/hash.js';
18
- import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
18
+ import { ApiResponseError, BackendUnavailableError, backendRejectsField, } from './api-client.js';
19
19
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
20
20
  import { restrictedValueKind } from './privacy-detector.js';
21
21
  import { principalFingerprint } from './principal-state.js';
@@ -26,6 +26,7 @@ import { BridgeRecoveryError } from './recovery-error.js';
26
26
  import { gitLabRemote } from '../providers/gitlab.js';
27
27
  import { awaitsReviewRequest, concludedReview, opensAsDraft, reviewDeliveries, reviewDeliverySchema, waitingReview, } from './merge-request-sync.js';
28
28
  import { maskFinding } from './review-masking.js';
29
+ import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
29
30
  export const validationIds = [
30
31
  'format',
31
32
  'static_analysis',
@@ -4410,6 +4411,9 @@ export class BridgeService {
4410
4411
  .object({
4411
4412
  total: z.number().int(),
4412
4413
  items: z.array(reviewDeliverySchema.extend({
4414
+ reviewReopenReason: z.string().nullish(),
4415
+ providerJobSince: z.string().nullish(),
4416
+ providerFailure: z.string().nullish(),
4413
4417
  openRound: z.object({ number: z.number().int(), createdAt: z.string() }).nullish(),
4414
4418
  openFindings: z.number().int(),
4415
4419
  })),
@@ -4424,15 +4428,25 @@ export class BridgeService {
4424
4428
  externalTaskId: item.externalTaskId ?? null,
4425
4429
  workItemKey: item.workItemKey ?? null,
4426
4430
  reviewState: item.reviewState ?? null,
4431
+ reopened: item.reviewReopenReason ?? null,
4427
4432
  pullRequest: item.reviewRequest
4428
4433
  ? { url: item.reviewRequest.url, state: item.reviewRequest.state }
4429
4434
  : null,
4435
+ readyBeforeReview: waitingReview.includes(item.reviewState ?? '') && item.reviewRequest?.state === 'ready',
4436
+ pullRequestChange: item.providerJob
4437
+ ? {
4438
+ job: item.providerJob,
4439
+ since: item.providerJobSince ?? null,
4440
+ failure: item.providerFailure ?? null,
4441
+ failedAt: item.providerFailedAt ?? null,
4442
+ }
4443
+ : null,
4430
4444
  openRound: item.openRound
4431
4445
  ? { number: item.openRound.number, since: item.openRound.createdAt }
4432
4446
  : null,
4433
4447
  openFindings: item.openFindings,
4434
4448
  })),
4435
- nextAction: 'These deliveries wait for code review. Mention them to the user in one line and start nothing on your own. When the user asks, review.get reads one and review.start runs its next round from a clone of this project; a review that ended while its PR/MR is still a draft waits for review.make_ready.',
4449
+ nextAction: 'These deliveries wait for code review. Mention them to the user in one line and start nothing on your own. When the user asks, review.get reads one and review.start runs its next round from a clone of this project; a review that ended while its PR/MR is still a draft waits for review.make_ready. reopened (new_commit or new_target) means the review had passed and then a new commit or a new target branch arrived, so it waits again; readyBeforeReview means its PR/MR was made ready while the review had not ended; pullRequestChange is a make-ready or make-draft that a member computer with a GitLab token does in the background, with the last failure when one was recorded.',
4436
4450
  });
4437
4451
  }
4438
4452
  catch {
@@ -4757,7 +4771,18 @@ export class BridgeService {
4757
4771
  return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
4758
4772
  }
4759
4773
  async taskReviewRequest(input) {
4760
- return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
4774
+ return this.execute(() => {
4775
+ const report = (reading) => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), cleanJson({
4776
+ url: input.url,
4777
+ state: input.state,
4778
+ ...(reading ? { headCommit: input.headCommit, targetBranch: input.targetBranch } : {}),
4779
+ }));
4780
+ return report(true).catch((error) => {
4781
+ if (!backendRejectsField(error, ['headCommit', 'targetBranch']))
4782
+ throw error;
4783
+ return report(false);
4784
+ });
4785
+ });
4761
4786
  }
4762
4787
  async taskOpenReviewRequest(input) {
4763
4788
  return this.execute(async () => {
@@ -4882,7 +4907,7 @@ export class BridgeService {
4882
4907
  : `No code review was asked for ${task}. A review starts when its delivery chooses a reviewed PR/MR, or, where review is required, when its PR/MR is recorded.`, 'review.get');
4883
4908
  if (delivery.state !== 'delivered' || !delivery.pushed || !delivery.branch)
4884
4909
  throw refuse(`${task} is not pushed yet, and a review reads the pushed branch. Push it and record the delivery (worktree.release with deliveryOutcome delivered), then call review.start again.`, 'review.get');
4885
- const target = input.targetBranch ?? delivery.baseBranch;
4910
+ const target = input.targetBranch ?? delivery.reviewRequest?.targetBranch ?? delivery.baseBranch;
4886
4911
  if (!target)
4887
4912
  throw refuse(`The delivery of ${task} names no target branch. Call review.start again with targetBranch: the branch its PR/MR goes into.`, 'review.start');
4888
4913
  const git = this.dependencies.repositories.git;
@@ -4894,9 +4919,18 @@ export class BridgeService {
4894
4919
  if (!tip)
4895
4920
  throw refuse(`The target branch ${target} could not be fetched from origin. If the PR/MR goes into another branch, call review.start again with targetBranch.`, 'review.start');
4896
4921
  const base = await git.mergeBase(repository.repoRoot, head.sourceCommit, tip.sourceCommit);
4897
- const started = reviewSchema.parse((await this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
4922
+ const startRound = (withTarget) => this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
4898
4923
  method: 'POST',
4899
- body: cleanJson({ commit: head.sourceCommit, baseCommit: base ?? undefined }),
4924
+ body: cleanJson({
4925
+ commit: head.sourceCommit,
4926
+ baseCommit: base ?? undefined,
4927
+ targetBranch: withTarget ? target : undefined,
4928
+ }),
4929
+ });
4930
+ const started = reviewSchema.parse((await startRound(true).catch((error) => {
4931
+ if (!backendRejectsField(error, ['targetBranch']))
4932
+ throw error;
4933
+ return startRound(false);
4900
4934
  })).data);
4901
4935
  const round = started.rounds.find((candidate) => candidate.state === 'open');
4902
4936
  if (!round)
@@ -4904,6 +4938,12 @@ export class BridgeService {
4904
4938
  const files = base
4905
4939
  ? await git.changedFiles(repository.repoRoot, base, head.sourceCommit)
4906
4940
  : [];
4941
+ const previous = started.rounds.find((candidate) => candidate.state === 'submitted' && candidate.number < round.number);
4942
+ const since = previous &&
4943
+ previous.headCommit !== head.sourceCommit &&
4944
+ (await git.isAncestor(repository.repoRoot, previous.headCommit, head.sourceCommit))
4945
+ ? await git.changedFiles(repository.repoRoot, previous.headCommit, head.sourceCommit)
4946
+ : null;
4907
4947
  const records = await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, round.id)}?offset=0&limit=5`);
4908
4948
  const range = base ? `${base} ${head.sourceCommit}` : head.sourceCommit;
4909
4949
  return asJsonValue({
@@ -4925,22 +4965,36 @@ export class BridgeService {
4925
4965
  },
4926
4966
  changedFiles: files.slice(0, 300),
4927
4967
  changedFileCount: files.length,
4968
+ ...(previous && since
4969
+ ? {
4970
+ sinceLastRound: {
4971
+ commit: previous.headCommit,
4972
+ changedFiles: since.slice(0, 300),
4973
+ changedFileCount: since.length,
4974
+ },
4975
+ }
4976
+ : {}),
4928
4977
  earlierFindings: round.number > 1 ? started.findings : [],
4929
4978
  records: records.data,
4930
- commands: base
4931
- ? [
4932
- `git diff --stat ${range}`,
4933
- `git diff ${range} -- <path>`,
4934
- `git show ${head.sourceCommit}:<path>`,
4935
- `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
4936
- `git log --oneline ${base}..${head.sourceCommit}`,
4937
- ]
4938
- : [
4939
- `git show --stat ${head.sourceCommit}`,
4940
- `git show ${head.sourceCommit}:<path>`,
4941
- `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
4942
- ],
4943
- nextAction: `Give this packet to a fresh subagent that did not write the change; if your host cannot start one, review it yourself under the same rules. The reviewer reads the change from baseCommit to headCommit only through the listed read-only Git commands: it never checks out, switches branches, edits, commits or pushes, and changes no file. It judges the change against the records (more with review.rules for this round from offset 5 while more are listed) and says whether each earlier finding still holds. Each finding names the file path, the line in headCommit when there is one, the ruleKey of the record it breaks when one applies, and a plain description of what is wrong and why. Then call review.submit with this projectId, roundId and the findings, an empty list when there are none.`,
4979
+ commands: [
4980
+ ...(base
4981
+ ? [
4982
+ `git diff --stat ${range}`,
4983
+ `git diff ${range} -- <path>`,
4984
+ `git show ${head.sourceCommit}:<path>`,
4985
+ `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
4986
+ `git log --oneline ${base}..${head.sourceCommit}`,
4987
+ ]
4988
+ : [
4989
+ `git show --stat ${head.sourceCommit}`,
4990
+ `git show ${head.sourceCommit}:<path>`,
4991
+ `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
4992
+ ]),
4993
+ ...(previous && since
4994
+ ? [`git diff ${previous.headCommit} ${head.sourceCommit} -- <path>`]
4995
+ : []),
4996
+ ],
4997
+ nextAction: `Give this packet to a fresh subagent that did not write the change; if your host cannot start one, review it yourself under the same rules. The reviewer reads the change from baseCommit to headCommit only through the listed read-only Git commands: it never checks out, switches branches, edits, commits or pushes, and changes no file. It judges the change against the records (more with review.rules for this round from offset 5 while more are listed) and says whether each earlier finding still holds${previous && since ? ', starting from sinceLastRound: what changed since the previous round' : ''}. Each finding names the file path, the line in headCommit when there is one, the ruleKey of the record it breaks when one applies, and a plain description of what is wrong and why. Then call review.submit with this projectId, roundId and the findings, an empty list when there are none.`,
4944
4998
  });
4945
4999
  });
4946
5000
  }
@@ -4971,14 +5025,20 @@ export class BridgeService {
4971
5025
  }));
4972
5026
  const idempotencyKey = deterministicUuid('review.submit', input.roundId, stableStringify(findings));
4973
5027
  const response = await this.dependencies.client.request(endpoints.reviewRoundResult(input.projectId, input.roundId), { method: 'POST', body: cleanJson({ idempotencyKey, findings }) });
5028
+ const comment = await this.reviewComment(input.projectId, response.data);
4974
5029
  return asJsonValue({
4975
5030
  review: response.data,
4976
5031
  masked: [...new Set(checked.flatMap(({ mask }) => mask.masked))],
5032
+ ...(comment ? { comment } : {}),
4977
5033
  });
4978
5034
  });
4979
5035
  }
4980
5036
  async reviewRejectFinding(input) {
4981
- return this.execute(async () => asJsonValue((await this.dependencies.client.request(endpoints.reviewFindingRejection(input.projectId, input.findingId), { method: 'POST', body: { reason: input.reason } })).data));
5037
+ return this.execute(async () => {
5038
+ const response = await this.dependencies.client.request(endpoints.reviewFindingRejection(input.projectId, input.findingId), { method: 'POST', body: { reason: input.reason } });
5039
+ const comment = await this.reviewComment(input.projectId, response.data);
5040
+ return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
5041
+ });
4982
5042
  }
4983
5043
  async reviewConclude(input) {
4984
5044
  return this.execute(async () => {
@@ -4987,15 +5047,18 @@ export class BridgeService {
4987
5047
  : input.outcome === 'skipped'
4988
5048
  ? endpoints.reviewSkip(input.projectId, input.taskId)
4989
5049
  : endpoints.reviewExemption(input.projectId, input.taskId);
4990
- return asJsonValue((await this.dependencies.client.request(path, {
5050
+ const response = await this.dependencies.client.request(path, {
4991
5051
  method: 'POST',
4992
5052
  ...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
4993
- })).data);
5053
+ });
5054
+ const comment = await this.reviewComment(input.projectId, response.data);
5055
+ return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
4994
5056
  });
4995
5057
  }
4996
5058
  async reviewMakeReady(input) {
4997
5059
  return this.execute(async () => {
4998
- const { delivery } = await this.review(input.projectId, input.taskId);
5060
+ const data = (await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data;
5061
+ const { delivery } = reviewSchema.parse(data);
4999
5062
  const task = delivery.externalTaskId;
5000
5063
  const request = delivery.reviewRequest;
5001
5064
  if (waitingReview.includes(delivery.reviewState ?? ''))
@@ -5005,32 +5068,75 @@ export class BridgeService {
5005
5068
  ready: false,
5006
5069
  nextAction: `No PR/MR is recorded for ${task}. Open it now as ready: on GitLab task.open_review_request with draft false; elsewhere with your own tools, then record it with task.review_request.`,
5007
5070
  });
5071
+ const comment = await this.reviewComment(input.projectId, data);
5072
+ const commented = comment ? { comment } : {};
5008
5073
  if (request.state !== 'draft')
5009
5074
  return asJsonValue({
5010
5075
  ready: request.state === 'ready',
5011
5076
  pullRequest: { url: request.url, state: request.state },
5077
+ ...commented,
5012
5078
  nextAction: `${request.url} is ${request.state} already; nothing was changed.`,
5013
5079
  });
5014
5080
  if (request.provider !== 'gitlab' || !this.dependencies.gitlab)
5015
5081
  return asJsonValue({
5016
5082
  ready: false,
5017
5083
  pullRequest: { url: request.url, state: request.state },
5084
+ ...commented,
5018
5085
  nextAction: `Mark ${request.url} ready for review with your own tools (on GitHub, gh pr ready or the "Ready for review" button), or ask the user to, then record it with task.review_request state ready.`,
5019
5086
  });
5020
- const readied = await this.dependencies.gitlab.requests.markReady(request);
5087
+ const readied = await this.dependencies.gitlab.requests
5088
+ .markReady(request)
5089
+ .catch(async (error) => {
5090
+ const failure = error instanceof BridgeRecoveryError ? objectValue(error.details)?.failure : undefined;
5091
+ if (delivery.providerJob === 'make_ready' && typeof failure === 'string')
5092
+ await this.dependencies.client
5093
+ .request(endpoints.reviewProviderFailure(input.projectId, input.taskId), {
5094
+ method: 'POST',
5095
+ body: { job: 'make_ready', failure },
5096
+ })
5097
+ .catch(() => undefined);
5098
+ throw error;
5099
+ });
5021
5100
  if (readied.state !== 'ready')
5022
5101
  return asJsonValue({
5023
5102
  ready: false,
5024
5103
  pullRequest: readied,
5104
+ ...commented,
5025
5105
  nextAction: `GitLab still shows ${readied.url} as ${readied.state}. Ask the user to mark it ready in GitLab ("Mark as ready"); Engineering Memory follows it.`,
5026
5106
  });
5027
5107
  const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: readied.url, state: 'ready', source: 'machine' });
5028
- return asJsonValue({ ready: true, pullRequest: readied, report });
5108
+ return asJsonValue({ ready: true, pullRequest: readied, ...commented, report });
5029
5109
  });
5030
5110
  }
5031
5111
  async review(projectId, taskId) {
5032
5112
  return reviewSchema.parse((await this.dependencies.client.request(endpoints.review(projectId, taskId))).data);
5033
5113
  }
5114
+ async reviewComment(projectId, data) {
5115
+ const review = commentReviewSchema.safeParse(data);
5116
+ const request = review.success ? review.data.delivery.reviewRequest : null;
5117
+ const comment = review.success && request ? dueComment(review.data, (await this.language()) ?? 'en') : null;
5118
+ if (!review.success || !request || !comment)
5119
+ return null;
5120
+ const taskId = review.data.delivery.taskId;
5121
+ if (request.provider === 'gitlab' && this.dependencies.gitlab) {
5122
+ const written = await this.dependencies.gitlab.requests.comment(projectId, taskId, request, comment);
5123
+ if (written.posted)
5124
+ return { posted: true };
5125
+ return {
5126
+ posted: false,
5127
+ ...(written.failure ? { failure: written.failure } : {}),
5128
+ nextAction: written.failure
5129
+ ? `GitLab refused the review comment on ${request.url} (${written.failure.replace(/_/g, ' ')}); a member computer with a GitLab token writes it later, and nothing waits for it.`
5130
+ : `No GitLab token is saved on this computer, so the review comment was not written on ${request.url}; a member computer with a GitLab token writes it later, and gitlab.token lets this computer write it too.`,
5131
+ };
5132
+ }
5133
+ await markComment(this.dependencies.client, projectId, taskId, comment);
5134
+ return {
5135
+ posted: false,
5136
+ body: comment.body,
5137
+ nextAction: `Post body unchanged as a comment on ${request.url} with your own tools (on GitHub, gh pr comment with the body in a file), so the team sees the review there. It is recorded as handed over and is not handed over again.`,
5138
+ };
5139
+ }
5034
5140
  async reportDeliveryOutcome(report) {
5035
5141
  const path = report.outcome === 'delivered'
5036
5142
  ? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
@@ -6241,11 +6347,6 @@ function classifyError(error) {
6241
6347
  }
6242
6348
  return 'bridge_delivery_error';
6243
6349
  }
6244
- function backendRejectsField(error, fields) {
6245
- return (error instanceof ApiResponseError &&
6246
- error.httpStatus === 400 &&
6247
- fields.some((field) => error.message.includes(`property ${field} should not exist`)));
6248
- }
6249
6350
  function isDefinitiveAuthenticationFailure(error) {
6250
6351
  return (error instanceof ApiResponseError &&
6251
6352
  (error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
@@ -113,7 +113,7 @@ export function createBridgeService(options = {}) {
113
113
  gitlab: {
114
114
  tokens: gitlabTokens,
115
115
  page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
116
- requests: new MergeRequests(client, gitlabTokens, gitlab, git),
116
+ requests: new MergeRequests(client, gitlabTokens, gitlab, git, signedInLanguage),
117
117
  },
118
118
  }));
119
119
  }
@@ -29,6 +29,9 @@ const kindByPrefix = [
29
29
  ['sync-start-', 'project_onboarding'],
30
30
  ['workflow-apply-', 'project_onboarding'],
31
31
  ['status-description-', 'project_onboarding'],
32
+ ['review-policy-', 'review_decision'],
33
+ ['review-reject-', 'review_decision'],
34
+ ['review-conclude-', 'review_decision'],
32
35
  ];
33
36
  export function liveQuestionKind(record) {
34
37
  if (record.owner?.tool === 'decision.mode')
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto';
2
2
  import * as z from 'zod/v4';
3
3
  import { endpoints } from '../config.js';
4
4
  import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
5
+ import { backendRejectsField } from './api-client.js';
5
6
  import { BridgeRecoveryError } from './recovery-error.js';
7
+ import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
6
8
  export const reviewDeliverySchema = z.object({
7
9
  taskId: z.string(),
8
10
  externalTaskId: z.string().nullish(),
@@ -14,6 +16,10 @@ export const reviewDeliverySchema = z.object({
14
16
  branch: z.string().nullish(),
15
17
  workItemKey: z.string().nullish(),
16
18
  reviewState: z.string().nullish(),
19
+ reviewCommit: z.string().nullish(),
20
+ reviewCommentPostedAt: z.string().nullish(),
21
+ providerJob: z.string().nullish(),
22
+ providerFailedAt: z.string().nullish(),
17
23
  reviewRequest: z
18
24
  .object({
19
25
  provider: z.string(),
@@ -23,6 +29,7 @@ export const reviewDeliverySchema = z.object({
23
29
  url: z.string(),
24
30
  state: z.string(),
25
31
  source: z.string().optional(),
32
+ targetBranch: z.string().nullish(),
26
33
  })
27
34
  .nullish(),
28
35
  });
@@ -54,13 +61,16 @@ export class MergeRequests {
54
61
  tokens;
55
62
  gitlab;
56
63
  git;
64
+ language;
57
65
  passes = new Map();
58
66
  running = new Set();
59
- constructor(client, tokens, gitlab, git) {
67
+ readingRefused = false;
68
+ constructor(client, tokens, gitlab, git, language) {
60
69
  this.client = client;
61
70
  this.tokens = tokens;
62
71
  this.gitlab = gitlab;
63
72
  this.git = git;
73
+ this.language = language;
64
74
  }
65
75
  async open(input) {
66
76
  const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
@@ -166,6 +176,21 @@ export class MergeRequests {
166
176
  : 'review.make_ready', { address: at, failure: error.failure, status: error.status });
167
177
  }
168
178
  }
179
+ async comment(projectId, taskId, request, comment) {
180
+ const link = await this.tokens.forLink(request.host, request.repository);
181
+ const token = link ? await this.tokens.token(link.address) : null;
182
+ if (!link || !token)
183
+ return { posted: false };
184
+ try {
185
+ await this.post(projectId, taskId, { address: link.address, token, project: link.project, number: request.number }, comment);
186
+ return { posted: true };
187
+ }
188
+ catch (error) {
189
+ if (!(error instanceof GitLabError))
190
+ throw error;
191
+ return { posted: false, failure: error.failure };
192
+ }
193
+ }
169
194
  schedule(projectId, repoRoot, deliveries) {
170
195
  const now = Date.now();
171
196
  const last = this.passes.get(projectId);
@@ -192,8 +217,17 @@ export class MergeRequests {
192
217
  const worker = async () => {
193
218
  for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
194
219
  const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
195
- if (found && (await this.report(projectId, delivery.taskId, found)))
220
+ if (!found)
221
+ continue;
222
+ let job = delivery.providerJob ?? null;
223
+ if (this.changed(delivery, found)) {
224
+ const answer = await this.report(projectId, delivery.taskId, found);
225
+ if (!answer)
226
+ continue;
196
227
  reported++;
228
+ job = answer.providerJob;
229
+ }
230
+ await this.provide(projectId, delivery, found, job, pass).catch(() => undefined);
197
231
  }
198
232
  };
199
233
  await Promise.all([worker(), worker(), worker(), worker()]);
@@ -209,10 +243,9 @@ export class MergeRequests {
209
243
  if (!link || !token)
210
244
  return null;
211
245
  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;
246
+ return request
247
+ ? { url: known.url, request, address: link.address, token, project: link.project }
248
+ : null;
216
249
  }
217
250
  if (!repoRoot || !delivery.branch)
218
251
  return null;
@@ -242,7 +275,78 @@ export class MergeRequests {
242
275
  .sort((left, right) => rank(left) - rank(right) ||
243
276
  Number(right.sha === delivery.deliveredCommit) -
244
277
  Number(left.sha === delivery.deliveredCommit))[0];
245
- return chosen ? { url: chosen.web_url, state: reviewState(chosen) } : null;
278
+ return chosen ? { url: chosen.web_url, request: chosen, address, token, project: path } : null;
279
+ }
280
+ changed(delivery, found) {
281
+ const known = delivery.reviewRequest;
282
+ const head = found.request.sha;
283
+ return (!known ||
284
+ reviewState(found.request) !== known.state ||
285
+ known.source !== 'machine' ||
286
+ (known.targetBranch !== undefined && found.request.target_branch !== known.targetBranch) ||
287
+ (!this.readingRefused &&
288
+ delivery.reviewState === 'passed' &&
289
+ !!head &&
290
+ !!delivery.reviewCommit &&
291
+ head !== delivery.reviewCommit));
292
+ }
293
+ async provide(projectId, delivery, found, job, pass) {
294
+ const state = reviewState(found.request);
295
+ const failedAt = job === (delivery.providerJob ?? null) ? delivery.providerFailedAt : null;
296
+ if ((state !== 'draft' && state !== 'ready') ||
297
+ (failedAt && Date.now() - Date.parse(failedAt) < 30 * 60 * 1000) ||
298
+ pass.failed.has(found.address) ||
299
+ pass.calls < 3)
300
+ return;
301
+ if (delivery.reviewState === 'findings_open' ||
302
+ (concludedReview.includes(delivery.reviewState ?? '') &&
303
+ delivery.reviewCommentPostedAt === null)) {
304
+ const review = commentReviewSchema.safeParse((await this.client
305
+ .request(endpoints.review(projectId, delivery.taskId))
306
+ .catch(() => null))?.data);
307
+ const comment = review.success
308
+ ? dueComment(review.data, (await this.language()) ?? 'en')
309
+ : null;
310
+ if (comment)
311
+ await this.post(projectId, delivery.taskId, { ...found, number: found.request.iid }, comment, pass).catch(() => undefined);
312
+ }
313
+ const due = (job === 'make_ready' && state === 'draft') || (job === 'make_draft' && state === 'ready')
314
+ ? job
315
+ : null;
316
+ if (!due)
317
+ return;
318
+ try {
319
+ pass.calls--;
320
+ const title = found.request.title;
321
+ if (title === undefined)
322
+ throw new GitLabError('unexpected_answer', found.address, null, null);
323
+ const after = await this.gitlab.retitleMergeRequest(found.address, found.token, found.project, found.request.iid, due === 'make_ready'
324
+ ? title.replace(draftPrefix, '') || defaultTitle(found.request.source_branch, null)
325
+ : `Draft: ${title}`.slice(0, 255));
326
+ await this.report(projectId, delivery.taskId, { ...found, request: after });
327
+ }
328
+ catch (error) {
329
+ if (!(error instanceof GitLabError))
330
+ return;
331
+ pass.failed.add(found.address);
332
+ await this.client
333
+ .request(endpoints.reviewProviderFailure(projectId, delivery.taskId), {
334
+ method: 'POST',
335
+ body: { job: due, failure: error.failure },
336
+ })
337
+ .catch(() => undefined);
338
+ }
339
+ }
340
+ async post(projectId, taskId, target, comment, pass) {
341
+ if (pass)
342
+ pass.calls--;
343
+ const notes = await this.gitlab.notes(target.address, target.token, target.project, target.number);
344
+ if (!notes.some((note) => note.body.includes(comment.marker))) {
345
+ if (pass)
346
+ pass.calls--;
347
+ await this.gitlab.createNote(target.address, target.token, target.project, target.number, comment.body);
348
+ }
349
+ await markComment(this.client, projectId, taskId, comment);
246
350
  }
247
351
  async call(pass, address, request) {
248
352
  if (pass.failed.has(address) || pass.calls <= 0)
@@ -259,16 +363,28 @@ export class MergeRequests {
259
363
  }
260
364
  }
261
365
  async report(projectId, taskId, found) {
366
+ const body = { url: found.url, state: reviewState(found.request), source: 'machine' };
367
+ const reading = {
368
+ ...(found.request.sha ? { headCommit: found.request.sha } : {}),
369
+ targetBranch: found.request.target_branch,
370
+ };
371
+ const send = () => this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
372
+ method: 'POST',
373
+ body: this.readingRefused ? body : { ...body, ...reading },
374
+ idempotencyKey: randomUUID(),
375
+ });
262
376
  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(),
377
+ const response = await send().catch(async (error) => {
378
+ if (this.readingRefused || !backendRejectsField(error, ['headCommit', 'targetBranch']))
379
+ throw error;
380
+ this.readingRefused = true;
381
+ return send();
267
382
  });
268
- return true;
383
+ const answer = z.object({ providerJob: z.string().nullish() }).safeParse(response.data);
384
+ return { providerJob: answer.success ? (answer.data.providerJob ?? null) : null };
269
385
  }
270
386
  catch {
271
- return false;
387
+ return null;
272
388
  }
273
389
  }
274
390
  }
@@ -0,0 +1,135 @@
1
+ import * as z from 'zod/v4';
2
+ import { endpoints } from '../config.js';
3
+ import { concludedReview } from './merge-request-sync.js';
4
+ import { copies, format } from './texts.js';
5
+ const text = z.string().min(1);
6
+ const commentWording = z.strictObject({
7
+ round: text,
8
+ passed: text,
9
+ skipped: text,
10
+ exempted: text,
11
+ findingsOf: text,
12
+ rule: text,
13
+ open: text,
14
+ fixed: text,
15
+ rejected: text,
16
+ });
17
+ export const commentReviewSchema = z.object({
18
+ delivery: z.object({
19
+ id: z.string(),
20
+ taskId: z.string(),
21
+ externalTaskId: z.string(),
22
+ workItemKey: z.string().nullish(),
23
+ reviewState: z.string().nullish(),
24
+ reviewChangedAt: z.string().nullish(),
25
+ reviewExemptionReason: z.string().nullish(),
26
+ reviewCommentPostedAt: z.string().nullish(),
27
+ reviewRequest: z
28
+ .object({
29
+ provider: z.string(),
30
+ host: z.string(),
31
+ repository: z.string(),
32
+ number: z.number().int().positive(),
33
+ url: z.string(),
34
+ state: z.string(),
35
+ })
36
+ .nullish(),
37
+ }),
38
+ rounds: z.array(z.object({
39
+ id: z.string(),
40
+ number: z.number().int(),
41
+ state: z.string(),
42
+ commentPostedAt: z.string().nullish(),
43
+ })),
44
+ totalRounds: z.number().int().optional(),
45
+ findings: z.array(z.object({
46
+ position: z.number().int(),
47
+ path: z.string(),
48
+ line: z.number().int().nullish(),
49
+ ruleKey: z.string().nullish(),
50
+ description: z.string(),
51
+ resolution: z.string(),
52
+ rejectionReason: z.string().nullish(),
53
+ fixedCommit: z.string().nullish(),
54
+ })),
55
+ });
56
+ export function dueComment(review, language) {
57
+ const { delivery, findings } = review;
58
+ const { copy, language: shown } = copies(commentWording, 'reviewComment', language)[0];
59
+ const task = delivery.workItemKey ?? delivery.externalTaskId;
60
+ const where = (finding) => '`' + (finding.path + (finding.line ? `:${finding.line}` : '')).replace(/`/g, '') + '`';
61
+ const latest = review.rounds.find((round) => round.state === 'submitted');
62
+ if (delivery.reviewState === 'findings_open' &&
63
+ latest?.commentPostedAt === null &&
64
+ findings.length) {
65
+ const marker = `<!-- engineering-memory review-round ${latest.id} -->`;
66
+ const lines = findings.map((finding) => `${finding.position}. ${where(finding)}` +
67
+ (finding.ruleKey
68
+ ? ` (${format(copy.rule, shown, { rule: oneLine(finding.ruleKey) })})`
69
+ : '') +
70
+ `: ${oneLine(finding.description)}`);
71
+ return {
72
+ kind: 'round',
73
+ roundId: latest.id,
74
+ marker,
75
+ body: [
76
+ marker,
77
+ `**${format(copy.round, shown, { task, round: latest.number, count: findings.length })}**`,
78
+ '',
79
+ ...lines,
80
+ ].join('\n'),
81
+ };
82
+ }
83
+ const outcome = delivery.reviewState;
84
+ if (!outcome ||
85
+ !concludedReview.includes(outcome) ||
86
+ !delivery.reviewChangedAt ||
87
+ delivery.reviewCommentPostedAt !== null ||
88
+ !['draft', 'ready'].includes(delivery.reviewRequest?.state ?? ''))
89
+ return null;
90
+ const marker = `<!-- engineering-memory review-conclusion ${delivery.id} ${delivery.reviewChangedAt} -->`;
91
+ const title = outcome === 'passed'
92
+ ? format(copy.passed, shown, { task, rounds: review.totalRounds ?? review.rounds.length })
93
+ : outcome === 'skipped'
94
+ ? format(copy.skipped, shown, { task })
95
+ : format(copy.exempted, shown, {
96
+ task,
97
+ reason: oneLine(delivery.reviewExemptionReason ?? ''),
98
+ });
99
+ const status = (finding) => finding.resolution === 'fixed'
100
+ ? format(copy.fixed, shown, { commit: (finding.fixedCommit ?? '').slice(0, 12) })
101
+ : finding.resolution === 'rejected'
102
+ ? format(copy.rejected, shown, { reason: oneLine(finding.rejectionReason ?? '') })
103
+ : copy.open;
104
+ return {
105
+ kind: 'conclusion',
106
+ concludedAt: delivery.reviewChangedAt,
107
+ marker,
108
+ body: [
109
+ marker,
110
+ `**${title}**`,
111
+ ...(findings.length && latest
112
+ ? [
113
+ '',
114
+ format(copy.findingsOf, shown, { round: latest.number }),
115
+ ...findings.map((finding) => `${finding.position}. ${where(finding)}: ${status(finding)}`),
116
+ ]
117
+ : []),
118
+ ].join('\n'),
119
+ };
120
+ }
121
+ export async function markComment(client, projectId, taskId, comment) {
122
+ await client
123
+ .request(comment.kind === 'round'
124
+ ? endpoints.reviewRoundComment(projectId, comment.roundId)
125
+ : endpoints.reviewComment(projectId, taskId), {
126
+ method: 'POST',
127
+ ...(comment.kind === 'conclusion' ? { body: { concludedAt: comment.concludedAt } } : {}),
128
+ })
129
+ .catch(() => undefined);
130
+ }
131
+ function oneLine(value) {
132
+ const line = value.replace(/\s+/g, ' ').trim();
133
+ return (line.length > 1500 ? `${line.slice(0, 1499)}…` : line).replace(/@/g, '@​');
134
+ }
135
+ //# sourceMappingURL=review-comment.js.map
@@ -304,12 +304,14 @@ When the user later says a pull request became ready, was merged or was closed,
304
304
 
305
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
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.
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; from the second round on it starts from `sinceLastRound`, what changed since the previous round. 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
308
 
309
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
310
 
311
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.
312
312
 
313
+ The review is written on the pull request as comments: the findings of each round and the conclusion. On GitLab the bridge writes them with the user's saved token; when a result of `review.submit`, `review.reject_finding`, `review.conclude` or `review.make_ready` carries `comment.body`, post that body unchanged as a comment with your own tools (on GitHub, `gh pr comment` with the body in a file) — it is recorded as handed over and not handed over again. When you record a pull request with `task.review_request`, pass `headCommit` and `targetBranch` when you can read them (on GitHub, `gh pr view --json headRefOid,baseRefName`): a review that had passed waits again when its pull request gets a new commit or a new target branch, and the answer says so; tell the user in one line, and do not pass it again without a round — `review.start` runs the next one, and skipping or exempting still asks the user. An `awaitingReviews` entry shows `reopened`, `readyBeforeReview` and `pullRequestChange`: a make-ready or make-draft that a member computer with a GitLab token does in the background, with its last failure. Mention a failure in one line; on GitHub, when the answer asks for a draft again, make it a draft with your own tools (`gh pr ready --undo`) and record it with `task.review_request`.
314
+
313
315
  ## Code source and memory applicability
314
316
 
315
317
  The bridge sends the checkout's committed Git commit and tree at bootstrap. Task and session