@imenam/mcp-feature-analyzer 1.0.17 → 1.0.19

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.
Files changed (107) hide show
  1. package/README.md +357 -351
  2. package/dist/shared/labels.d.ts +2 -0
  3. package/dist/shared/labels.js +2 -0
  4. package/dist/shared/review-state.d.ts +38 -2
  5. package/dist/shared/review-state.js +83 -3
  6. package/dist/shared/schemas/analysis.schema.d.ts +102 -0
  7. package/dist/shared/schemas/analysis.schema.js +24 -0
  8. package/dist/shared/schemas/ipc.schema.d.ts +17 -2
  9. package/dist/shared/schemas/ipc.schema.js +2 -2
  10. package/dist/src/core/analysis-store.d.ts +0 -2
  11. package/dist/src/core/analysis-store.js +1 -5
  12. package/dist/src/core/invariants.js +3 -0
  13. package/dist/src/core/migrate.d.ts +5 -0
  14. package/dist/src/core/migrate.js +14 -1
  15. package/dist/src/core/review-actions.d.ts +9 -10
  16. package/dist/src/core/review-actions.js +22 -29
  17. package/dist/src/gui-worker.js +48 -46
  18. package/dist/src/index.js +37 -13
  19. package/dist/src/tools/format.d.ts +5 -1
  20. package/dist/src/tools/format.js +11 -2
  21. package/dist/src/tools/get-review-feedback.d.ts +5 -1
  22. package/dist/src/tools/get-review-feedback.js +59 -19
  23. package/dist/src/tools/list-analyses.d.ts +8 -1
  24. package/dist/src/tools/list-analyses.js +40 -14
  25. package/dist/src/tools/mark-feedback-processed.d.ts +8 -0
  26. package/dist/src/tools/mark-feedback-processed.js +34 -0
  27. package/gui/dist/assets/{angular-html-mqETY8Ta.js → angular-html-BG32ZSEg.js} +1 -1
  28. package/gui/dist/assets/{angular-ts-Dyua8-1t.js → angular-ts-W997xU6j.js} +1 -1
  29. package/gui/dist/assets/{apl-3jbxnvrH.js → apl-6ogEEQiK.js} +1 -1
  30. package/gui/dist/assets/{astro-Co4C3aDz.js → astro-DBxTz6gT.js} +1 -1
  31. package/gui/dist/assets/{blade-B4EPDbIQ.js → blade-PbZvkn0I.js} +1 -1
  32. package/gui/dist/assets/{c-B6Zq6Std.js → c-C5-3wfrB.js} +1 -1
  33. package/gui/dist/assets/{chapel-BQd1_20m.js → chapel-KHYeKJD5.js} +1 -1
  34. package/gui/dist/assets/{cobol-m9aPmaKL.js → cobol-D51AxaJB.js} +1 -1
  35. package/gui/dist/assets/{coffee-9RfP0zJM.js → coffee-4h6_4dhl.js} +1 -1
  36. package/gui/dist/assets/{cpp-BVBdUNUD.js → cpp-B63oWnHV.js} +1 -1
  37. package/gui/dist/assets/{crystal-BPUdd6PR.js → crystal-OG_TkIE7.js} +1 -1
  38. package/gui/dist/assets/{css-LrCWcNdJ.js → css-Csea3soR.js} +1 -1
  39. package/gui/dist/assets/{edge-D-Q1P4iH.js → edge-HBaaYuuJ.js} +1 -1
  40. package/gui/dist/assets/{elixir-BvGDaF1W.js → elixir-DZlxxd9b.js} +1 -1
  41. package/gui/dist/assets/{elm-DcLIpqA8.js → elm-DZAjSs9M.js} +1 -1
  42. package/gui/dist/assets/{erb-BNIJdl1V.js → erb-BurFBvpV.js} +1 -1
  43. package/gui/dist/assets/{git-rebase-9T7mb2tK.js → git-rebase-B7wPwCu-.js} +1 -1
  44. package/gui/dist/assets/{glimmer-js-7oEVZM3L.js → glimmer-js-Bgk8Z51T.js} +1 -1
  45. package/gui/dist/assets/{glimmer-ts-B9lGBZyd.js → glimmer-ts-ldZtmANh.js} +1 -1
  46. package/gui/dist/assets/{glsl-C59JLFCt.js → glsl-7SImpJuS.js} +1 -1
  47. package/gui/dist/assets/{graphql-DhT3P-M_.js → graphql-CJ0BLjCq.js} +1 -1
  48. package/gui/dist/assets/{hack-B22EZN7R.js → hack-D50FsJQB.js} +1 -1
  49. package/gui/dist/assets/{haml-DOHFovSe.js → haml-xkw_a2ii.js} +1 -1
  50. package/gui/dist/assets/{handlebars-ChdRsCo8.js → handlebars-C0upK5dG.js} +1 -1
  51. package/gui/dist/assets/{html-Cvii8U8Z.js → html-CerDkvKq.js} +1 -1
  52. package/gui/dist/assets/{html-derivative-DvPQ-VHx.js → html-derivative-BfB3QMj5.js} +1 -1
  53. package/gui/dist/assets/{http-DrT8XJXI.js → http-pXaTY6-R.js} +1 -1
  54. package/gui/dist/assets/{hurl-B-_5p73n.js → hurl-BNtMT88T.js} +1 -1
  55. package/gui/dist/assets/index-CMjVOwnL.js +87 -0
  56. package/gui/dist/assets/index-CN9ojYe6.css +1 -0
  57. package/gui/dist/assets/{java-DX7zTQhc.js → java-B3e3IyVz.js} +1 -1
  58. package/gui/dist/assets/{javascript-Db2TOoX-.js → javascript-DgggYE1f.js} +1 -1
  59. package/gui/dist/assets/{jinja-BlzjXyBG.js → jinja-BHtm4gvT.js} +1 -1
  60. package/gui/dist/assets/{jison-CYH9v_vD.js → jison-B2YK1Y49.js} +1 -1
  61. package/gui/dist/assets/{json-DvgESUmx.js → json-7jbWqmUr.js} +1 -1
  62. package/gui/dist/assets/{jsx-BxUu3NOl.js → jsx-BFPQ2oqh.js} +1 -1
  63. package/gui/dist/assets/{julia-BD6shmUv.js → julia-DX1Bsm0x.js} +1 -1
  64. package/gui/dist/assets/{just-B58vJyzZ.js → just-D524YAex.js} +1 -1
  65. package/gui/dist/assets/{latex-DpXG4qlp.js → latex-Dc0dzAGl.js} +1 -1
  66. package/gui/dist/assets/{liquid-CKXKEzQm.js → liquid-DXohsHev.js} +1 -1
  67. package/gui/dist/assets/{lua-CrR2yS38.js → lua-G_rux6Ay.js} +1 -1
  68. package/gui/dist/assets/{marko-DIY1RQi3.js → marko-BdidjG96.js} +1 -1
  69. package/gui/dist/assets/{mdc-CSxBBNi0.js → mdc-DxAZb3tM.js} +1 -1
  70. package/gui/dist/assets/{nginx-BR5UiLsj.js → nginx-CuvNT-q8.js} +1 -1
  71. package/gui/dist/assets/{nim-81Kxf6HC.js → nim-DuDgLkQa.js} +1 -1
  72. package/gui/dist/assets/{org-YkzSiTpH.js → org-BJKUhgoL.js} +1 -1
  73. package/gui/dist/assets/{perl-BKAf7ctD.js → perl-BdJ-pZH6.js} +1 -1
  74. package/gui/dist/assets/{php-D60BkLMS.js → php-DUOUy2DX.js} +1 -1
  75. package/gui/dist/assets/{pug-CeojeKqv.js → pug-WUnoGP-C.js} +1 -1
  76. package/gui/dist/assets/{qml-vJFVB1sq.js → qml-Dl8tY0zd.js} +1 -1
  77. package/gui/dist/assets/{r-buvgYjbV.js → r-Ci9Hbbcn.js} +1 -1
  78. package/gui/dist/assets/{razor-Dz_7Cjz-.js → razor-0bx4DTys.js} +1 -1
  79. package/gui/dist/assets/{regexp-B68ulszt.js → regexp-C0ZlJfpq.js} +1 -1
  80. package/gui/dist/assets/{rst-BJ9SfN4C.js → rst-BC_vqA0U.js} +1 -1
  81. package/gui/dist/assets/{ruby-C_OOgGF-.js → ruby-DPPk42YS.js} +1 -1
  82. package/gui/dist/assets/{sas-DyKx0IvO.js → sas-B8jfw3hR.js} +1 -1
  83. package/gui/dist/assets/{scss-D2FME1KT.js → scss-7mTWL496.js} +1 -1
  84. package/gui/dist/assets/{shellscript-DKhUkHWJ.js → shellscript-CYNcJqc-.js} +1 -1
  85. package/gui/dist/assets/{shellsession-B9VkeV-t.js → shellsession-Dh2E3t_B.js} +1 -1
  86. package/gui/dist/assets/{soy-DT_8USFA.js → soy-BVR-TmA2.js} +1 -1
  87. package/gui/dist/assets/{sql-CTY7Kq3S.js → sql-D3S51lwn.js} +1 -1
  88. package/gui/dist/assets/{stata-BXc7Lm53.js → stata-BjuRTlAG.js} +1 -1
  89. package/gui/dist/assets/{surrealql-Dc_YL61u.js → surrealql-BKRHdvyc.js} +1 -1
  90. package/gui/dist/assets/{svelte-fq0JF0B3.js → svelte-DIKo_e2T.js} +1 -1
  91. package/gui/dist/assets/{templ-B20S0R9K.js → templ-DC42iqks.js} +1 -1
  92. package/gui/dist/assets/{tex-BhmGjqVa.js → tex-BZ2lfoiV.js} +1 -1
  93. package/gui/dist/assets/{ts-tags-D_pLLOmi.js → ts-tags-o8CUeNFk.js} +1 -1
  94. package/gui/dist/assets/{tsx-DULdqnBJ.js → tsx-DeMh4Cat.js} +1 -1
  95. package/gui/dist/assets/{twig-DIYYhsx1.js → twig-BUr_lvSK.js} +1 -1
  96. package/gui/dist/assets/{typescript-0qGmyQ6p.js → typescript-DeKHrHpS.js} +1 -1
  97. package/gui/dist/assets/{typst-qJ1kgBh8.js → typst-CcbUABTI.js} +1 -1
  98. package/gui/dist/assets/{vue-BZCq4N1_.js → vue-JHDlnmNz.js} +1 -1
  99. package/gui/dist/assets/{vue-html-DtRFW8u7.js → vue-html-soMMIiuc.js} +1 -1
  100. package/gui/dist/assets/{vue-vine-CoFObNiz.js → vue-vine-D8TZS6Lm.js} +1 -1
  101. package/gui/dist/assets/{xml-DjvoX99Q.js → xml-9isD2blC.js} +1 -1
  102. package/gui/dist/assets/{xsl-B0Obrqq8.js → xsl-chWKvWOz.js} +1 -1
  103. package/gui/dist/assets/{yaml-Dbi9c2aj.js → yaml-DEY2M1js.js} +1 -1
  104. package/gui/dist/index.html +2 -2
  105. package/package.json +1 -1
  106. package/gui/dist/assets/index-B_Us1tSN.css +0 -1
  107. package/gui/dist/assets/index-D7dShIx3.js +0 -87
@@ -1,11 +1,11 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
- import { streamSSE } from "hono/streaming";
5
4
  import { GUI_LISTEN_HOST, ProxyClient, createLogger, honoGuiGuard, setupLogging } from "@imenam/mcp-gui-interface";
6
5
  import path from "path";
7
6
  import fs from "fs";
8
7
  import { fileURLToPath } from "url";
8
+ import { randomUUID } from "crypto";
9
9
  import { IPCMessageSchema, PUSHED_EVENT_TYPES } from "../shared/schemas/ipc.schema.js";
10
10
  import { AddNoteBodySchema, DeleteNoteBodySchema, SetFileReviewedBodySchema, SetFindingStatusBodySchema, SubmitReviewBodySchema, UpdateNoteBodySchema, } from "../shared/schemas/api.schema.js";
11
11
  import { resolveLogDir } from "./core/log-dir.js";
@@ -79,7 +79,6 @@ const app = new Hono();
79
79
  // Contrôle de l'hôte appelé et des requêtes de modification, avant toute route.
80
80
  app.use("*", honoGuiGuard({ proxyUrl: guiMode.kind === "proxy" ? guiMode.proxyUrl : undefined }));
81
81
  let proxyClient = null;
82
- const sseStreams = new Set();
83
82
  const pendingRequests = new Map();
84
83
  function safeSend(message) {
85
84
  if (process.send && process.connected) {
@@ -116,6 +115,38 @@ function ipcRequest(type, data, timeout = 5000) {
116
115
  });
117
116
  }
118
117
  const PUSHED_EVENTS = new Set(PUSHED_EVENT_TYPES);
118
+ /** Nombre d'évènements conservés dans le journal servi par `/api/changes`. */
119
+ const CHANGES_JOURNAL_SIZE = 200;
120
+ /** Identifiant de cette instance du worker : un client qui en voit changer recharge tout. */
121
+ const epoch = randomUUID();
122
+ /** Journal des évènements poussés par le master, numérotés par `seq` croissant. */
123
+ const changesJournal = [];
124
+ let lastSeq = 0;
125
+ function recordChange(event) {
126
+ lastSeq += 1;
127
+ changesJournal.push({ seq: lastSeq, event });
128
+ if (changesJournal.length > CHANGES_JOURNAL_SIZE)
129
+ changesJournal.shift();
130
+ }
131
+ /**
132
+ * Évènements postérieurs à `since`, ou `reset` quand le client ne peut pas
133
+ * reprendre : autre instance du worker, évènements sortis du journal, ou
134
+ * `since` au-delà du dernier évènement.
135
+ */
136
+ function changesSince(since, clientEpoch) {
137
+ if (since === undefined || clientEpoch !== epoch || since > lastSeq) {
138
+ return { epoch, seq: lastSeq, reset: true };
139
+ }
140
+ const oldestKept = changesJournal.length > 0 ? changesJournal[0].seq : lastSeq + 1;
141
+ if (since < oldestKept - 1)
142
+ return { epoch, seq: lastSeq, reset: true };
143
+ return {
144
+ epoch,
145
+ seq: lastSeq,
146
+ reset: false,
147
+ events: changesJournal.filter((entry) => entry.seq > since).map((entry) => entry.event),
148
+ };
149
+ }
119
150
  function setupIpcHandlers() {
120
151
  process.on("error", (err) => {
121
152
  if (err.code === "ERR_IPC_CHANNEL_CLOSED")
@@ -123,7 +154,7 @@ function setupIpcHandlers() {
123
154
  logger.error(`Process error: ${err.message}`);
124
155
  });
125
156
  process.on("disconnect", () => {
126
- logger.warn("IPC disconnected from MCP master. GUI worker exits; EventSource clients show Disconnected.");
157
+ logger.warn("IPC disconnected from MCP master. GUI worker exits.");
127
158
  process.exit(0);
128
159
  });
129
160
  process.on("message", (raw) => {
@@ -140,12 +171,7 @@ function setupIpcHandlers() {
140
171
  return;
141
172
  }
142
173
  if (PUSHED_EVENTS.has(msg.type)) {
143
- for (const stream of sseStreams) {
144
- stream.writeSSE({ data: JSON.stringify(msg), event: "message" }).catch((err) => {
145
- logger.warn(`SSE push ${msg.type} failed, stream dropped: ${err?.message ?? err}`);
146
- sseStreams.delete(stream);
147
- });
148
- }
174
+ recordChange(msg);
149
175
  return;
150
176
  }
151
177
  logger.debug(`Unhandled IPC message type: ${msg.type}`);
@@ -163,45 +189,21 @@ app.get("/proxy/health", (c) => {
163
189
  timestamp: new Date().toISOString(),
164
190
  });
165
191
  });
166
- app.get("/api/events", (c) => streamSSE(c, async (stream) => {
167
- const clientId = Math.random().toString(36).slice(2, 11);
168
- sseStreams.add(stream);
169
- logger.info(`SSE /api/events: client connected (id=${clientId})`);
170
- stream.onAbort(() => {
171
- sseStreams.delete(stream);
172
- logger.info(`SSE /api/events: stream closed (id=${clientId}).`);
173
- });
174
- if (!safeSend({ type: "GET_INITIAL_STATE", timestamp: new Date().toISOString() })) {
175
- logger.warn(`SSE /api/events: GET_INITIAL_STATE not sent (id=${clientId}) — IPC unavailable.`);
176
- }
177
- try {
178
- await stream.writeSSE({ data: "connected", event: "ping" });
179
- }
180
- catch (err) {
181
- logger.warn(`SSE /api/events: initial writeSSE failed (id=${clientId}): ${err?.message ?? err}`);
182
- sseStreams.delete(stream);
183
- return;
184
- }
185
- while (!stream.aborted) {
186
- await stream.sleep(30000);
187
- try {
188
- await stream.writeSSE({ data: "ping", event: "ping" });
189
- }
190
- catch (err) {
191
- logger.warn(`SSE /api/events: keepalive ping failed (id=${clientId}): ${err?.message ?? err}`);
192
- sseStreams.delete(stream);
193
- break;
192
+ /**
193
+ * Changements depuis `since` pour l'instance `epoch`. Répond immédiatement ;
194
+ * sans `since` ni `epoch`, la réponse est un `reset`.
195
+ */
196
+ app.get("/api/changes", (c) => {
197
+ const rawSince = c.req.query("since");
198
+ const rawEpoch = c.req.query("epoch");
199
+ let since;
200
+ if (rawSince !== undefined && rawSince !== "") {
201
+ if (!/^\d+$/.test(rawSince) || !Number.isSafeInteger(Number(rawSince))) {
202
+ return c.json({ error: `Invalid "since" query parameter: ${JSON.stringify(rawSince)} — expected a non-negative integer.` }, 400);
194
203
  }
204
+ since = Number(rawSince);
195
205
  }
196
- }));
197
- app.post("/api/client-diagnostics", async (c) => {
198
- try {
199
- logger.warn(`Client diagnostics (SSE/EventSource): ${JSON.stringify(await c.req.json())}`);
200
- }
201
- catch (e) {
202
- logger.warn(`Client diagnostics: invalid JSON body (${e?.message ?? e})`);
203
- }
204
- return c.json({ ok: true });
206
+ return c.json(changesSince(since, rawEpoch || undefined));
205
207
  });
206
208
  /** Code HTTP d'une erreur renvoyée par le master ; une erreur locale (timeout, IPC absent) est une erreur serveur. */
207
209
  const HTTP_STATUS_BY_ERROR_KIND = {
package/dist/src/index.js CHANGED
@@ -22,7 +22,7 @@ import { AddNoteRequestSchema, AnalysisRequestSchema, DeleteNoteRequestSchema, S
22
22
  import { ANALYSIS_MODES, DIAGRAM_KINDS, EXPLANATION_SIDES, FINDING_KINDS, NODE_SHAPES, NODE_STATUSES, SEVERITIES, } from "../shared/schemas/analysis.schema.js";
23
23
  import { errorResult, textResult } from "./tools/types.js";
24
24
  import { createAnalysis } from "./tools/create-analysis.js";
25
- import { listAnalyses } from "./tools/list-analyses.js";
25
+ import { listAnalyses, REVIEW_STATE_FILTERS } from "./tools/list-analyses.js";
26
26
  import { getAnalysis } from "./tools/get-analysis.js";
27
27
  import { getDiff } from "./tools/get-diff.js";
28
28
  import { refreshAnalysis } from "./tools/refresh-analysis.js";
@@ -36,6 +36,7 @@ import { deleteExplanations } from "./tools/delete-explanations.js";
36
36
  import { DEFAULT_NODE_SHAPE, DEFAULT_NODE_STATUS, setDiagram } from "./tools/set-diagram.js";
37
37
  import { deleteDiagram } from "./tools/delete-diagram.js";
38
38
  import { getReviewFeedback } from "./tools/get-review-feedback.js";
39
+ import { markFeedbackProcessed } from "./tools/mark-feedback-processed.js";
39
40
  import { deleteAnalysis } from "./tools/delete-analysis.js";
40
41
  const __filename = fileURLToPath(import.meta.url);
41
42
  const __dirname = path.dirname(__filename);
@@ -103,8 +104,8 @@ Workflow:
103
104
  5. add_findings for every problem, including requirement_gap findings for requested behaviour that is missing.
104
105
  6. set_diagram when a picture helps (impacted layers, process flow, concept tree).
105
106
  7. Tell the user to review the analysis in the GUI (the link is in the tool results).
106
- 8. get_review_feedback to read the decision, the selected points and the prompt.
107
- 9. Fix the code, then refresh_analysis to recompute the diff for a new review round; re-anchor or rewrite the outdated findings and explanations.
107
+ 8. get_review_feedback to read the decision, the selected points and the prompt. list_analyses with review_state "to_process" lists the submitted reviews you have not handled yet; an approved review has nothing to process.
108
+ 9. Fix the code, then refresh_analysis to recompute the diff for a new review round; re-anchor or rewrite the outdated findings and explanations. When the diff must stay as it is (an analysis of one commit, fixes in other commits, answers given in the conversation), call mark_feedback_processed instead, with a note saying how the feedback was handled.
108
109
 
109
110
  Explanation rules:
110
111
  - Write one for every added or removed block that is long (a function or a block of about 30 lines or more) or complex (non-trivial algorithm, state machine, concurrency, recursion, tricky regular expression or data transformation). A 100-line function always gets one.
@@ -245,13 +246,6 @@ function handleIPCMessage(raw) {
245
246
  case "ALREADY_RUNNING":
246
247
  logger.info(`GUI already served by another instance at ${msg.data?.url}`);
247
248
  return;
248
- case "GET_INITIAL_STATE":
249
- broadcastToGUI({
250
- type: "INITIAL_STATE",
251
- data: { version: APP_VERSION, analyses: store.list().analyses },
252
- timestamp: new Date().toISOString(),
253
- });
254
- return;
255
249
  default:
256
250
  if (GUI_REQUESTS.has(msg.type)) {
257
251
  try {
@@ -358,13 +352,21 @@ const TOOLS = [
358
352
  {
359
353
  name: "list_analyses",
360
354
  description: "List the analyses grouped by project, with their refs, open findings by severity, reviewed files, diagram count and review state " +
361
- "(not started, in progress, all files reviewed, submitted). Start here: when the project already has an analysis of the feature, " +
355
+ "(not started, in progress, all files reviewed, submitted; a submitted review is pending processing until mark_feedback_processed or refresh_analysis, " +
356
+ "except an approved one, which has nothing to process). " +
357
+ "Start here: when the project already has an analysis of the feature, " +
362
358
  "continue it (update_analysis to rewrite the overview and summary, refresh_analysis after changing the code) instead of creating a duplicate; " +
363
359
  "create a new one with create_analysis for another feature or another diff.",
364
360
  inputSchema: {
365
361
  type: "object",
366
362
  properties: {
367
363
  project: { type: "string", description: "Only list the analyses of this project (exact name). An unknown name lists the existing projects." },
364
+ review_state: {
365
+ type: "string",
366
+ enum: [...REVIEW_STATE_FILTERS],
367
+ description: "Only list the analyses in this review state. to_process = submitted reviews, not approved, not marked processed yet: the feedback you still have to handle; " +
368
+ "processed = submitted reviews marked processed or approved.",
369
+ },
368
370
  },
369
371
  },
370
372
  },
@@ -659,14 +661,34 @@ const TOOLS = [
659
661
  },
660
662
  {
661
663
  name: "get_review_feedback",
662
- description: "Read the reviewer's feedback once the review is submitted: decision, selected findings and notes, and the prompt to act on. " +
663
- "Before submission it reports the review progress. Read-only. Always read the feedback before calling refresh_analysis, which discards it.",
664
+ description: "Read the reviewer's feedback once the review is submitted: decision, whether you already processed it, selected findings and notes, and the prompt to act on. " +
665
+ "After a resubmission it also lists what changed since the previous submitted version and recalls how that one was processed. " +
666
+ "Before submission, or while the reviewer is changing a submitted review, it reports the review progress only. " +
667
+ "Read-only. Always read the feedback before calling refresh_analysis, which discards it.",
664
668
  inputSchema: {
665
669
  type: "object",
666
670
  properties: { analysis_id: { type: "string", description: ANALYSIS_ID_DOC } },
667
671
  required: ["analysis_id"],
668
672
  },
669
673
  },
674
+ {
675
+ name: "mark_feedback_processed",
676
+ description: "Mark the submitted feedback of an analysis as processed once you handled it, when refresh_analysis does not fit: the analysis freezes one commit " +
677
+ "and the fixes are in other commits, or the notes were answered in the conversation. Records the date and an optional note; the feedback stays readable " +
678
+ "with get_review_feedback and the GUI shows the review as processed. Refused while the review is not submitted. " +
679
+ "If the reviewer changes the review afterwards, it becomes pending again until it is resubmitted.",
680
+ inputSchema: {
681
+ type: "object",
682
+ properties: {
683
+ analysis_id: { type: "string", description: ANALYSIS_ID_DOC },
684
+ note: {
685
+ type: "string",
686
+ description: 'How the feedback was handled, shown to the reviewer, e.g. "fixed by 12785d6e3 and 1ff5baaad" or "answers given in the conversation".',
687
+ },
688
+ },
689
+ required: ["analysis_id"],
690
+ },
691
+ },
670
692
  {
671
693
  name: "refresh_analysis",
672
694
  description: "Recompute the frozen diff with the same repository, mode and refs, after fixing the code. " +
@@ -743,6 +765,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
743
765
  return afterMutation(setDiagram(store, params));
744
766
  case "delete_diagram":
745
767
  return afterMutation(deleteDiagram(store, params));
768
+ case "mark_feedback_processed":
769
+ return afterMutation(markFeedbackProcessed(store, params));
746
770
  case "refresh_analysis":
747
771
  return await withRepoAccess(exec, async (repoFor) => afterMutation(await refreshAnalysis(store, params, repoFor)));
748
772
  case "delete_analysis": {
@@ -23,7 +23,11 @@ export declare function severityCountsLabel(counts: Record<Severity, number>): s
23
23
  export declare function indentBlock(text: string, indent: string): string;
24
24
  /** Nom anglais d'un état de revue : `not started`, `in progress`… */
25
25
  export declare function progressStateLabel(state: ReviewProgressState): string;
26
- /** `in progress (2/7 files reviewed)` ou `submitted, decision approve (7/7 files reviewed)`. */
26
+ /**
27
+ * `in progress (2/7 files reviewed)`, `submitted, decision request_changes, pending processing (7/7 files reviewed)`,
28
+ * `submitted, decision request_changes, processed at <date> (7/7 files reviewed)`
29
+ * ou `submitted, decision approve, nothing to process (7/7 files reviewed)`.
30
+ */
27
31
  export declare function progressLabel(progress: ReviewProgress): string;
28
32
  /** Bloc « Overview » : objectif, approche et points d'attention ; une ligne d'invitation quand il n'y en a pas. */
29
33
  export declare function overviewLines(overview: Overview | null): string[];
@@ -73,9 +73,18 @@ const REVIEW_PROGRESS_TEXT = {
73
73
  export function progressStateLabel(state) {
74
74
  return REVIEW_PROGRESS_TEXT[state];
75
75
  }
76
- /** `in progress (2/7 files reviewed)` ou `submitted, decision approve (7/7 files reviewed)`. */
76
+ /**
77
+ * `in progress (2/7 files reviewed)`, `submitted, decision request_changes, pending processing (7/7 files reviewed)`,
78
+ * `submitted, decision request_changes, processed at <date> (7/7 files reviewed)`
79
+ * ou `submitted, decision approve, nothing to process (7/7 files reviewed)`.
80
+ */
77
81
  export function progressLabel(progress) {
78
- const decision = progress.state === "submitted" ? `, decision ${progress.decision}` : "";
82
+ const processing = progress.toProcess
83
+ ? "pending processing"
84
+ : progress.processed === null
85
+ ? "nothing to process"
86
+ : `processed at ${progress.processed.at}`;
87
+ const decision = progress.state === "submitted" ? `, decision ${progress.decision}, ${processing}` : "";
79
88
  return `${REVIEW_PROGRESS_TEXT[progress.state]}${decision} (${progress.reviewedFiles}/${progress.totalFiles} files reviewed)`;
80
89
  }
81
90
  /** Bloc « Overview » : objectif, approche et points d'attention ; une ligne d'invitation quand il n'y en a pas. */
@@ -1,4 +1,8 @@
1
1
  import type { AnalysisStore } from "../core/analysis-store.js";
2
2
  import { type Args, type ToolResult } from "./types.js";
3
- /** Retour du relecteur : décision, points retenus et prompt ; sinon l'avancement de la revue. Lecture seule. */
3
+ /**
4
+ * Retour du relecteur : décision, état du traitement, écarts avec la version
5
+ * soumise précédente, points retenus et prompt ; sinon l'avancement de la revue.
6
+ * Lecture seule.
7
+ */
4
8
  export declare function getReviewFeedback(store: AnalysisStore, args: Args): ToolResult;
@@ -1,7 +1,41 @@
1
- import { reviewProgress } from "../../shared/review-state.js";
1
+ import { hasNoChanges, needsProcessing, recordSubmission, reviewProgress, submissionChanges } from "../../shared/review-state.js";
2
2
  import { sortBySeverity } from "../../shared/severity.js";
3
3
  import { findingLine, indentBlock, noteLocationLabel, overviewLines, progressLabel } from "./format.js";
4
4
  import { rejectUnknownFields, requireString, textResult } from "./types.js";
5
+ /** `processed at <date> (note: …)`, `nothing to process (approved)` ou `not processed`. */
6
+ function processingLabel(decision, processed) {
7
+ if (processed !== null)
8
+ return `processed at ${processed.at}${processed.note === null ? "" : ` (note: ${processed.note})`}`;
9
+ return decision === "approve" ? "nothing to process (approved)" : "not processed";
10
+ }
11
+ /** `submitted at <date>, decision <d>, processed at <date>…` : rappel d'une version soumise. */
12
+ function submissionLabel(record) {
13
+ return `submitted at ${record.submittedAt}, decision ${record.decision}, ${processingLabel(record.decision, record.processed)}`;
14
+ }
15
+ function recordedNoteLine(note) {
16
+ return `${note.id} — ${noteLocationLabel(note)}\n${indentBlock(note.text, " ")}`;
17
+ }
18
+ /** Écarts entre la version soumise précédente et la soumission en cours. */
19
+ function changeLines(previous, current) {
20
+ const changes = submissionChanges(previous, current);
21
+ const lines = [`Changes since the previous submission (${submissionLabel(previous)}):`];
22
+ if (hasNoChanges(changes))
23
+ return [...lines, " none: same decision and same points."];
24
+ if (changes.decision)
25
+ lines.push(` Decision changed: ${changes.decision.from} → ${changes.decision.to}`);
26
+ for (const finding of changes.addedFindings)
27
+ lines.push(` + finding ${finding.id} — ${finding.title}`);
28
+ for (const finding of changes.removedFindings)
29
+ lines.push(` - finding ${finding.id} — ${finding.title}`);
30
+ for (const note of changes.addedNotes)
31
+ lines.push(` + note ${recordedNoteLine(note)}`);
32
+ for (const { before, after } of changes.modifiedNotes) {
33
+ lines.push(` ~ note ${recordedNoteLine(after)}`, ` was: ${noteLocationLabel(before)}`, indentBlock(before.text, " "));
34
+ }
35
+ for (const note of changes.removedNotes)
36
+ lines.push(` - note ${recordedNoteLine(note)}`);
37
+ return lines;
38
+ }
5
39
  function findingDetails(finding) {
6
40
  const lines = [`- ${findingLine(finding)}`];
7
41
  if (finding.body.trim() !== "")
@@ -13,22 +47,27 @@ function findingDetails(finding) {
13
47
  function noteDetails(note) {
14
48
  return `- ${note.id} — ${noteLocationLabel(note)}\n${indentBlock(note.text, " ")}`;
15
49
  }
16
- /** Avancement d'une revue non soumise. */
50
+ /**
51
+ * Avancement d'une revue non soumise. Une revue rouverte par le relecteur rappelle
52
+ * la version soumise qu'elle remplacera, sans en donner le contenu.
53
+ */
17
54
  function progressLines(analysis) {
18
55
  const progress = reviewProgress(analysis);
19
56
  const ignored = analysis.findings.filter((finding) => finding.status === "ignored").length;
20
- return [
21
- `The review of "${analysis.title}" (${analysis.id}, project "${analysis.project}") is not submitted yet.`,
22
- `Review state: ${progressLabel(progress)}.`,
23
- `Progress: ${progress.reviewedFiles}/${progress.totalFiles} file(s) reviewed, ${ignored}/${analysis.findings.length} finding(s) ignored, ` +
24
- `${analysis.notes.length} reviewer note(s).`,
25
- "",
26
- ...overviewLines(analysis.overview),
27
- "",
28
- "Ask the user to finish the review in the GUI and save the feedback, then call get_review_feedback again.",
29
- ];
57
+ const previous = analysis.review.previousSubmission;
58
+ const lines = [`The review of "${analysis.title}" (${analysis.id}, project "${analysis.project}") is not submitted yet.`];
59
+ if (previous) {
60
+ lines.push(`The reviewer reopened the feedback ${submissionLabel(previous)}, and is changing it: do not act on it until it is submitted again.`);
61
+ }
62
+ lines.push(`Review state: ${progressLabel(progress)}.`, `Progress: ${progress.reviewedFiles}/${progress.totalFiles} file(s) reviewed, ${ignored}/${analysis.findings.length} finding(s) ignored, ` +
63
+ `${analysis.notes.length} reviewer note(s).`, "", ...overviewLines(analysis.overview), "", "Ask the user to finish the review in the GUI and save the feedback, then call get_review_feedback again.");
64
+ return lines;
30
65
  }
31
- /** Retour du relecteur : décision, points retenus et prompt ; sinon l'avancement de la revue. Lecture seule. */
66
+ /**
67
+ * Retour du relecteur : décision, état du traitement, écarts avec la version
68
+ * soumise précédente, points retenus et prompt ; sinon l'avancement de la revue.
69
+ * Lecture seule.
70
+ */
32
71
  export function getReviewFeedback(store, args) {
33
72
  rejectUnknownFields(args, ["analysis_id"]);
34
73
  const analysis = store.get(requireString(args, "analysis_id"));
@@ -42,11 +81,11 @@ export function getReviewFeedback(store, args) {
42
81
  const lines = [
43
82
  `Review of "${analysis.title}" (${analysis.id}, project "${analysis.project}") submitted at ${review.submittedAt}.`,
44
83
  `Decision: ${review.decision}`,
45
- "",
46
- ...overviewLines(analysis.overview),
47
- "",
48
- `Selected findings (${findings.length}):`,
84
+ `Processing: ${needsProcessing(review) ? "to process (call mark_feedback_processed once handled)" : processingLabel(review.decision, review.processed)}`,
49
85
  ];
86
+ if (review.previousSubmission)
87
+ lines.push("", ...changeLines(review.previousSubmission, recordSubmission(analysis)));
88
+ lines.push("", ...overviewLines(analysis.overview), "", `Selected findings (${findings.length}):`);
50
89
  lines.push(...(findings.length === 0 ? [" none"] : findings.flatMap(findingDetails)), "");
51
90
  lines.push(`Selected reviewer notes (${notes.length}):`);
52
91
  lines.push(...(notes.length === 0 ? [" none"] : notes.map(noteDetails)), "");
@@ -64,8 +103,9 @@ export function getReviewFeedback(store, args) {
64
103
  lines.push("Next steps: the reviewer approved the feature with no points to address.");
65
104
  }
66
105
  else {
67
- lines.push("Next steps: address the points above in the repository, then call refresh_analysis to recompute the diff " +
68
- "(it discards this feedback and starts a new review round).");
106
+ lines.push("Next steps: address the points above in the repository. Then either call refresh_analysis to recompute the diff " +
107
+ "(it discards this feedback and starts a new review round), or, when the diff stays as it is (an analysis of one commit, " +
108
+ "fixes made in other commits, answers given in the conversation), call mark_feedback_processed with a note saying how it was handled.");
69
109
  }
70
110
  return textResult(lines.join("\n"));
71
111
  }
@@ -1,8 +1,15 @@
1
1
  import type { AnalysisStore } from "../core/analysis-store.js";
2
2
  import { type Args, type ToolResult } from "./types.js";
3
+ /**
4
+ * Filtre `review_state` : un état d'avancement, ou parmi les revues soumises
5
+ * celles qui restent à traiter (`to_process`) ou n'attendent plus l'agent
6
+ * (`processed` : marquées traitées, ou approuvées).
7
+ */
8
+ export declare const REVIEW_STATE_FILTERS: readonly ["not_started", "in_progress", "files_reviewed", "submitted", "to_process", "processed"];
9
+ export type ReviewStateFilter = (typeof REVIEW_STATE_FILTERS)[number];
3
10
  /**
4
11
  * Liste les analyses regroupées par projet (projet le plus récemment modifié en
5
12
  * premier, puis analyses de la plus récente à la plus ancienne), éventuellement
6
- * limitées à un projet, et les fichiers illisibles.
13
+ * limitées à un projet et à un état de revue, et les fichiers illisibles.
7
14
  */
8
15
  export declare function listAnalyses(store: AnalysisStore, args: Args): ToolResult;
@@ -1,6 +1,20 @@
1
1
  import { REVIEW_PROGRESS_STATES } from "../../shared/schemas/analysis.schema.js";
2
2
  import { progressLabel, progressStateLabel, refsLabel, severityCountsLabel } from "./format.js";
3
- import { optionalString, rejectUnknownFields, textResult } from "./types.js";
3
+ import { optionalEnum, optionalString, rejectUnknownFields, textResult } from "./types.js";
4
+ /**
5
+ * Filtre `review_state` : un état d'avancement, ou parmi les revues soumises
6
+ * celles qui restent à traiter (`to_process`) ou n'attendent plus l'agent
7
+ * (`processed` : marquées traitées, ou approuvées).
8
+ */
9
+ export const REVIEW_STATE_FILTERS = [...REVIEW_PROGRESS_STATES, "to_process", "processed"];
10
+ function matchesReviewState(summary, filter) {
11
+ const { state, toProcess } = summary.progress;
12
+ if (filter === "to_process")
13
+ return toProcess;
14
+ if (filter === "processed")
15
+ return state === "submitted" && !toProcess;
16
+ return state === filter;
17
+ }
4
18
  /** Analyses regroupées par projet, les projets triés par leur analyse la plus récente. */
5
19
  function groupByProject(analyses) {
6
20
  const groups = new Map();
@@ -21,41 +35,53 @@ function projectCounts(analyses) {
21
35
  return `${analyses.length} analysis(es): ${counts.join(", ")}`;
22
36
  }
23
37
  function analysisLines(summary) {
24
- const feedback = summary.progress.state === "submitted" ? " — read it with get_review_feedback" : "";
25
- return [
38
+ const { processed, toProcess } = summary.progress;
39
+ const feedback = toProcess ? " — read it with get_review_feedback" : "";
40
+ const lines = [
26
41
  `- ${summary.id} — "${summary.title}"`,
27
42
  ` ${refsLabel(summary)} | files reviewed ${summary.reviewedCount}/${summary.fileCount} | ` +
28
43
  `open findings: ${severityCountsLabel(summary.openFindings)} | diagrams: ${summary.diagramCount}`,
29
44
  ` review: ${progressLabel(summary.progress)}${feedback} | updated ${summary.updatedAt}`,
30
45
  ];
46
+ if (processed?.note)
47
+ lines.push(` processing note: ${processed.note}`);
48
+ return lines;
31
49
  }
32
50
  /**
33
51
  * Liste les analyses regroupées par projet (projet le plus récemment modifié en
34
52
  * premier, puis analyses de la plus récente à la plus ancienne), éventuellement
35
- * limitées à un projet, et les fichiers illisibles.
53
+ * limitées à un projet et à un état de revue, et les fichiers illisibles.
36
54
  */
37
55
  export function listAnalyses(store, args) {
38
- rejectUnknownFields(args, ["project"]);
56
+ rejectUnknownFields(args, ["project", "review_state"]);
39
57
  const project = optionalString(args, "project");
40
- const { analyses, unreadable } = store.list();
58
+ const reviewState = optionalEnum(args, "review_state", REVIEW_STATE_FILTERS);
59
+ const { analyses: all, unreadable } = store.list();
60
+ const known = new Set(all.map((summary) => summary.project));
61
+ const analyses = reviewState === undefined ? all : all.filter((summary) => matchesReviewState(summary, reviewState));
41
62
  const groups = groupByProject(analyses);
42
63
  const lines = [];
43
- if (project !== undefined && !groups.has(project)) {
44
- const known = [...groups.keys()];
45
- throw new Error(known.length === 0
64
+ if (project !== undefined && !known.has(project)) {
65
+ const names = [...groupByProject(all).keys()];
66
+ throw new Error(names.length === 0
46
67
  ? `Unknown project "${project}": there are no analyses yet. Create one with create_analysis.`
47
- : `Unknown project "${project}". Existing projects: ${known.map((name) => `"${name}"`).join(", ")}. ` +
68
+ : `Unknown project "${project}". Existing projects: ${names.map((name) => `"${name}"`).join(", ")}. ` +
48
69
  `Call list_analyses without project to see them all, or create_analysis to start a new project.`);
49
70
  }
50
- if (analyses.length === 0) {
71
+ const shown = project === undefined ? [...groups] : groups.has(project) ? [[project, groups.get(project)]] : [];
72
+ const scope = project === undefined ? "" : ` in project "${project}"`;
73
+ const filtered = reviewState === undefined ? "" : ` with review state ${reviewState}`;
74
+ if (all.length === 0) {
51
75
  lines.push("No analyses yet. Create one with create_analysis once the feature is developed: it freezes the diff of a branch or of the working tree for human review.");
52
76
  }
77
+ else if (shown.length === 0) {
78
+ lines.push(`No analysis${scope}${filtered}. Call list_analyses without review_state to see them all.`);
79
+ }
53
80
  else {
54
- const shown = project === undefined ? [...groups] : [[project, groups.get(project)]];
55
81
  const total = shown.reduce((sum, [, list]) => sum + list.length, 0);
56
82
  lines.push(project === undefined
57
- ? `${total} analysis(es) in ${groups.size} project(s), most recently updated first:`
58
- : `${total} analysis(es) in project "${project}", most recently updated first:`);
83
+ ? `${total} analysis(es)${filtered} in ${groups.size} project(s), most recently updated first:`
84
+ : `${total} analysis(es)${filtered}${scope}, most recently updated first:`);
59
85
  for (const [name, list] of shown) {
60
86
  lines.push("", `## Project "${name}" (${projectCounts(list)})`);
61
87
  for (const summary of list)
@@ -0,0 +1,8 @@
1
+ import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type Args, type MutationResult } from "./types.js";
3
+ /**
4
+ * Marque traité le retour soumis d'une analyse, avec la date et une note
5
+ * facultative. Le retour reste lisible avec get_review_feedback. Refusé tant
6
+ * que la revue n'est pas soumise ; un nouveau marquage remplace le précédent.
7
+ */
8
+ export declare function markFeedbackProcessed(store: AnalysisStore, args: Args): MutationResult;
@@ -0,0 +1,34 @@
1
+ import { AnalysisError } from "../core/errors.js";
2
+ import { optionalString, rejectUnknownFields, requireString, textResult } from "./types.js";
3
+ /**
4
+ * Marque traité le retour soumis d'une analyse, avec la date et une note
5
+ * facultative. Le retour reste lisible avec get_review_feedback. Refusé tant
6
+ * que la revue n'est pas soumise ; un nouveau marquage remplace le précédent.
7
+ */
8
+ export function markFeedbackProcessed(store, args) {
9
+ rejectUnknownFields(args, ["analysis_id", "note"]);
10
+ const id = requireString(args, "analysis_id");
11
+ const note = optionalString(args, "note") ?? null;
12
+ let replaced = null;
13
+ const updated = store.mutate(id, "agent", (draft) => {
14
+ const review = draft.review;
15
+ if (review.state !== "submitted") {
16
+ const reopened = review.previousSubmission;
17
+ throw new AnalysisError(reopened
18
+ ? `The review of analysis "${id}" is not submitted: the reviewer reopened the feedback submitted at ${reopened.submittedAt} and is changing it. ` +
19
+ `Wait until it is submitted again, then read it with get_review_feedback.`
20
+ : `The review of analysis "${id}" is not submitted yet: there is no feedback to mark processed.`);
21
+ }
22
+ replaced = review.processed?.at ?? null;
23
+ review.processed = { at: new Date().toISOString(), note };
24
+ });
25
+ const processed = updated.review.processed;
26
+ const lines = [
27
+ `Marked the feedback of "${updated.title}" (${updated.id}), submitted at ${updated.review.submittedAt}, as processed at ${processed.at}.`,
28
+ `Note: ${processed.note ?? "none"}.`,
29
+ ];
30
+ if (replaced !== null)
31
+ lines.push(`This replaces the processing mark of ${replaced}.`);
32
+ lines.push("The feedback stays readable with get_review_feedback. If the reviewer changes the review, it becomes pending again.");
33
+ return { result: textResult(lines.join("\n")), analysisId: updated.id };
34
+ }