tensorgrid-ui 1.4.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/review.js CHANGED
@@ -42,13 +42,17 @@
42
42
  /**
43
43
  * Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
44
44
  *
45
- * Здесь стоял свой список, третий по счёту: ещё один в `review-settings.js`
46
- * и ещё один в справочнике запуска. Ревизор указал, чем это кончится:
47
- * добавление третьего продукта в один файл из трёх делает
48
- * `settings.choices[kind]` неопределённым, и раздел настроек падает целиком
49
- * React снимает поддерево, пользователь теряет и остальные настройки.
45
+ * Здесь стоял свой литерал, а ещё один жил в `review-settings.js`: три
46
+ * источника правды на один список. Ревизор указал, чем это кончится —
47
+ * добавление продукта в один файл из трёх делает запись выбора
48
+ * неопределённой и роняет весь раздел настроек. В прошлый раз я починил
49
+ * половину и отчитался за целое; следующий обзор это и поймал.
50
+ *
51
+ * Импорт статический: `reviewers.js` тянет только встроенные модули Node,
52
+ * поэтому он загружается везде, где загружается этот файл, — включая тесты
53
+ * на машине без dsh.
50
54
  */
51
- const KNOWN_REVIEWERS = ['claude-code', 'codex']
55
+ const KNOWN_REVIEWERS = KNOWN
52
56
 
53
57
  /**
54
58
  * Задание ревизору.
@@ -114,9 +118,58 @@ function buildPrompt({ request, paths, focus }) {
114
118
  return lines.join('\n')
115
119
  }
116
120
 
121
+ import { KNOWN } from './reviewers.js'
122
+
117
123
  /** Уровни важности в порядке убывания — по нему сортируется итог. */
118
- const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
124
+ const SEVERITY_ORDER = ['критично', 'не указана', 'серьёзно', 'мелочь']
119
125
 
126
+ /**
127
+ * Найти пути, уводящие за пределы рабочей папки.
128
+ *
129
+ * Проверка идёт по РЕАЛЬНОМУ пути, а не по написанному: символическая
130
+ * ссылка внутри папки может указывать наружу, и сравнение строк этого не
131
+ * увидит. Несуществующий путь не считается выходом — о нём ревизор скажет
132
+ * сам, и отказывать в обзоре из-за опечатки незачем.
133
+ *
134
+ * @param cwd - рабочая папка чата.
135
+ * @param paths - пути из вызова, возможно отсутствующие.
136
+ * @returns те из них, что оказались снаружи.
137
+ */
138
+ async function pathsOutside(cwd, paths) {
139
+ if (!Array.isArray(paths) || paths.length === 0) return []
140
+
141
+ const { realpath } = await import('node:fs/promises')
142
+ const { isAbsolute, join, relative, resolve } = await import('node:path')
143
+
144
+ const settle = async (value) => {
145
+ try {
146
+ return await realpath(value)
147
+ } catch {
148
+ return resolve(value)
149
+ }
150
+ }
151
+
152
+ const root = await settle(cwd)
153
+ const outside = []
154
+
155
+ for (const entry of paths) {
156
+ const text = String(entry)
157
+ const full = await settle(isAbsolute(text) ? text : join(cwd, text))
158
+ const step = relative(root, full)
159
+ // Пустой `step` — это сам корень; `..` в начале — выход наружу.
160
+ if (step !== '' && (step.startsWith('..') || isAbsolute(step))) outside.push(text)
161
+ }
162
+
163
+ return outside
164
+ }
165
+
166
+ /** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
167
+ function ordered(findings) {
168
+ return [...findings].sort((a, b) => {
169
+ const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
170
+ return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
171
+ })
172
+ }
120
173
  /**
121
174
  * Пределы на разбор.
122
175
  *
@@ -171,8 +224,6 @@ export function parseFindings(text, reviewer) {
171
224
  }
172
225
 
173
226
  for (const block of blocks) {
174
- if (findings.length >= MAX_FINDINGS) break
175
-
176
227
  const [titleLine, ...rest] = block.split('\n')
177
228
  // Жирная разметка вокруг названий полей снимается: модели любят её
178
229
  // добавлять, а без этого поле просто не находилось и содержимое терялось.
@@ -195,12 +246,18 @@ export function parseFindings(text, reviewer) {
195
246
  }
196
247
 
197
248
  const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
198
- const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level)) ?? 'мелочь'
249
+ // Важность, которую ревизор не указал, ОТМЕЧАЕТСЯ как неуказанную.
250
+ //
251
+ // Прежде она молча становилась «мелочью», и критичная находка с
252
+ // пропущенной строкой уезжала в самый низ списка — туда, куда не
253
+ // смотрят. Теперь неуказанная идёт СРАЗУ ПОСЛЕ критичных: пусть лучше
254
+ // человек прочтёт лишнее, чем пропустит важное.
255
+ const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level))
199
256
 
200
257
  findings.push({
201
258
  reviewer,
202
259
  title: clip(titleLine.replace(/\*\*/g, '').trim()),
203
- severity,
260
+ severity: severity ?? 'не указана',
204
261
  where: field('ГДЕ'),
205
262
  what: field('ЧТО НЕ ТАК'),
206
263
  why: field('ПОЧЕМУ ЭТО ВАЖНО'),
@@ -208,7 +265,25 @@ export function parseFindings(text, reviewer) {
208
265
  })
209
266
  }
210
267
 
211
- return findings
268
+ // Предел применяется ПОСЛЕ сортировки по важности, а не по ходу разбора.
269
+ //
270
+ // Раньше обрезка шла по порядку в тексте: критичная находка на 61-м месте
271
+ // пропадала, а шестьдесят мелочей выше неё оставались. Ревизор выкладывает
272
+ // находки не по важности, так что порядок в тексте ничего не значит.
273
+ if (findings.length <= MAX_FINDINGS) return findings
274
+
275
+ const kept = ordered(findings).slice(0, MAX_FINDINGS)
276
+ kept.push({
277
+ reviewer,
278
+ title: `показаны не все находки: ${findings.length - MAX_FINDINGS} отброшено`,
279
+ severity: 'мелочь',
280
+ where: null,
281
+ what: `Ревизор вернул ${findings.length} находок, показаны ${MAX_FINDINGS} самых важных.`,
282
+ why: 'Остальные отброшены, чтобы обзор не вытеснил из работы то, что он проверял.',
283
+ howToCheck: null,
284
+ truncationNotice: true,
285
+ })
286
+ return kept
212
287
  }
213
288
 
214
289
  /**
@@ -221,6 +296,7 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
221
296
  const outcome = await runReviewer(ctx, kind, {
222
297
  prompt,
223
298
  cwd,
299
+ provider: choice?.provider ?? null,
224
300
  model: choice?.model ?? null,
225
301
  effort: choice?.effort ?? null,
226
302
  signal,
@@ -235,19 +311,18 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
235
311
  return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
236
312
  }
237
313
 
238
- /** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
239
- function ordered(findings) {
240
- return [...findings].sort((a, b) => {
241
- const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
242
- return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
243
- })
244
- }
245
-
246
314
  /** Человекочитаемый вид итога. */
247
315
  export function render(value) {
248
316
  const lines = []
249
317
 
250
- if (value.reviewers.length === 0) {
318
+ // Массив ревизоров может отсутствовать: схема результата его не требует,
319
+ // и любой ранний выход из инструмента давал объект без него. Обращение к
320
+ // `.length` роняло отрисовку — то есть пользователь вместо объяснения
321
+ // получал ошибку инструмента.
322
+ const reviewers = Array.isArray(value.reviewers) ? value.reviewers : []
323
+ const findings = Array.isArray(value.findings) ? value.findings : []
324
+
325
+ if (reviewers.length === 0) {
251
326
  // Настоящая причина приходит в `problem` и обязана быть показана.
252
327
  //
253
328
  // Здесь стояла одна фраза на все случаи — про несмонтированные
@@ -260,18 +335,18 @@ export function render(value) {
260
335
  : 'Обзор не выполнен: ни один ревизор не выбран.'
261
336
  }
262
337
 
263
- const failed = value.reviewers.filter((r) => r.failed !== null)
264
- const worked = value.reviewers.filter((r) => r.failed === null)
338
+ const failed = reviewers.filter((r) => r.failed !== null)
339
+ const worked = reviewers.filter((r) => r.failed === null)
265
340
 
266
- lines.push(`Ревизоров опрошено: ${worked.length} из ${value.reviewers.length}. Находок: ${value.findings.length}.`)
341
+ lines.push(`Ревизоров опрошено: ${worked.length} из ${reviewers.length}. Находок: ${findings.length}.`)
267
342
  for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
268
343
  lines.push('')
269
344
 
270
- if (value.findings.length === 0) {
345
+ if (findings.length === 0) {
271
346
  // «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
272
347
  // Отказ или отмена, поданные как чистый результат, — худшее, что этот
273
348
  // инструмент может сделать: пользователь сочтёт работу проверенной.
274
- const cancelled = value.reviewers.some((entry) => entry.cancelled === true)
349
+ const cancelled = reviewers.some((entry) => entry.cancelled === true)
275
350
  if (cancelled) lines.push('Обзор отменён — судить не по чему.')
276
351
  else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
277
352
  else lines.push('Находок нет.')
@@ -280,7 +355,7 @@ export function render(value) {
280
355
 
281
356
  // Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
282
357
  // сверху при любом способе получить эти данные.
283
- for (const finding of ordered(value.findings)) {
358
+ for (const finding of ordered(findings)) {
284
359
  lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
285
360
  if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
286
361
  if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
@@ -292,7 +367,7 @@ export function render(value) {
292
367
  // Совпадение двух независимых ревизоров — сильный признак: они друг друга
293
368
  // не видели. Расхождение тоже полезно, но его надо проверять самому.
294
369
  const byReviewer = new Map()
295
- for (const finding of value.findings) {
370
+ for (const finding of findings) {
296
371
  byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
297
372
  }
298
373
  lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
@@ -358,12 +433,31 @@ export async function apply(ctx) {
358
433
  const { readSettings } = await import('./review-settings.js')
359
434
 
360
435
  // Рабочая папка берётся у сессии, как это делают поставочные
361
- // провайдеры: ревизор должен смотреть тот же код, что и автор.
436
+ // провайдеры: ревизор смотрит ту же папку, в которой идёт работа.
362
437
  const cwd = exec.agent?.session?.header?.cwd
363
438
  if (typeof cwd !== 'string' || cwd === '') {
364
439
  return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
365
440
  }
366
441
 
442
+ // Ревизор не выходит за папку чата.
443
+ //
444
+ // Он запускается с правами на чтение, и без этой проверки путь вида
445
+ // `../../` или абсолютный увёл бы его в чужое дерево — к переписке,
446
+ // документам, настройкам других программ. Отказ явный, с указанием
447
+ // пути: молчаливое усечение списка выглядело бы как обзор, который
448
+ // просто ничего не нашёл.
449
+ const outside = await pathsOutside(cwd, args.paths)
450
+ if (outside.length > 0) {
451
+ return {
452
+ findings: [],
453
+ reviewers: [],
454
+ problem:
455
+ `эти пути вне рабочей папки чата: ${outside.join(', ')}. ` +
456
+ 'Ревизор читает только папку, в которой идёт работа. Чтобы посмотреть другую, ' +
457
+ 'откройте чат в ней или разрешите доступ явно.',
458
+ }
459
+ }
460
+
367
461
  // Выбор пользователя из настроек; вызов может его перекрыть.
368
462
  const settings = await readSettings(cwd)
369
463
  const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
@@ -387,19 +481,98 @@ export async function apply(ctx) {
387
481
  // Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
388
482
  // удвоил бы ожидание без всякой пользы.
389
483
  const answers = await Promise.all(
390
- wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
484
+ wanted.map((kind) =>
485
+ askReviewer(
486
+ // Наш ревизор заводит подагента, и тому нужен родитель —
487
+ // агент, от имени которого идёт вызов. Внешним он не нужен.
488
+ { ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
489
+ runReviewer,
490
+ kind,
491
+ prompt,
492
+ cwd,
493
+ settings.choices[kind],
494
+ exec.signal,
495
+ ),
496
+ ),
391
497
  )
392
498
 
499
+ const findings = ordered(answers.flatMap((entry) => entry.findings))
500
+ const reviewers = answers.map((entry) => ({
501
+ provider: entry.provider,
502
+ failed: entry.failed,
503
+ cancelled: entry.cancelled === true,
504
+ found: entry.findings.length,
505
+ model: entry.model,
506
+ effort: entry.effort,
507
+ }))
508
+
509
+ // Обзор записывается в журнал — иначе приговор выносится в переписке
510
+ // и там же теряется. Без записи никто не узнает, какой ревизор на
511
+ // какой модели чаще прав, а это единственная величина, по которой
512
+ // видно, стоит ли он своих денег.
513
+ //
514
+ // Неудача записи НЕ отменяет обзор: находки важнее статистики.
515
+ let recorded = null
516
+ try {
517
+ const { recordRun } = await import('./review-log.js')
518
+ recorded = await recordRun({ request: args.request, reviewers, findings })
519
+ } catch {
520
+ recorded = null
521
+ }
522
+
393
523
  return {
394
- findings: ordered(answers.flatMap((entry) => entry.findings)),
395
- reviewers: answers.map((entry) => ({
396
- provider: entry.provider,
397
- failed: entry.failed,
398
- cancelled: entry.cancelled === true,
399
- found: entry.findings.length,
400
- model: entry.model,
401
- effort: entry.effort,
402
- })),
524
+ findings: recorded === null
525
+ ? findings
526
+ : findings.map((finding, index) => ({ ...finding, id: recorded.findingIds[index] })),
527
+ reviewers,
528
+ ...(recorded === null
529
+ ? { note: 'журнал обзоров недоступен — приговоры не сохранятся' }
530
+ : { runId: recorded.runId }),
531
+ }
532
+ },
533
+ }),
534
+ )
535
+
536
+ ctx.tools.register(
537
+ defineTool({
538
+ name: 'review_verdict',
539
+ description:
540
+ 'Record your verdict on one finding from the last second_opinion review, with the evidence you gathered. ' +
541
+ 'Roughly a third of findings are wrong — often plausibly wrong, reasoned from real evidence toward a false conclusion. ' +
542
+ 'Verify before you judge: run it, read the surrounding code, reproduce the steps the reviewer gave. ' +
543
+ 'The evidence field is not a formality: a verdict without it is an opinion, and this whole tool exists against opinions passed off as facts. ' +
544
+ 'Call this once per finding after a review, before telling the user what you are going to fix.',
545
+ parameters: {
546
+ id: { type: 'string', required: true, description: 'Finding id returned by second_opinion.' },
547
+ verdict: {
548
+ type: 'string',
549
+ required: true,
550
+ enum: ['confirmed', 'false', 'deferred'],
551
+ description: 'confirmed — you reproduced or proved it; false — you proved it wrong; deferred — real but not worth acting on now.',
552
+ },
553
+ evidence: {
554
+ type: 'string',
555
+ required: true,
556
+ description:
557
+ 'What exactly you checked: the command you ran and its result, the file and line you read, the behaviour you observed. ' +
558
+ 'Not "looks right" — what you actually did.',
559
+ },
560
+ },
561
+ output: {
562
+ schema: { type: 'object', additionalProperties: true },
563
+ render: (_args, value) => [{ type: 'text', text: value.text ?? '' }],
564
+ },
565
+ async execute(args) {
566
+ try {
567
+ const { recordVerdict } = await import('./review-log.js')
568
+ const result = await recordVerdict(args.id, args.verdict, args.evidence, 'agent')
569
+ if (result.applied === false) {
570
+ return { text: `Приговор не изменён: по этой находке уже высказался человек (${result.kept.verdict}).` }
571
+ }
572
+ const names = { confirmed: 'подтверждено', false: 'ложная', deferred: 'отложено' }
573
+ return { text: `Записано: ${names[args.verdict]} — ${args.evidence.slice(0, 120)}` }
574
+ } catch (error) {
575
+ return { text: `Записать не удалось: ${String(error?.message ?? error)}` }
403
576
  }
404
577
  },
405
578
  }),