tensorgrid-ui 1.2.2 → 1.3.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.
Files changed (2) hide show
  1. package/lib/review.js +329 -0
  2. package/package.json +12 -1
package/lib/review.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * «Второе мнение» — независимый обзор работы чужими исполнителями.
3
+ *
4
+ * Это строка АГЕНТ-ПЛОСКОСТИ: инструмент регистрируется в реестр пресета, а
5
+ * не профиля. Провайдеры подагентов живут на Host и монтируются отдельно;
6
+ * наличие провайдера само по себе прав агенту не даёт, их даёт эта строка.
7
+ *
8
+ * Зачем это нужно, коротко. Ошибки бывают громкие и тихие. Громкие падают с
9
+ * криком, и их находит первый же пользователь. Тихие проявляются только в
10
+ * определённой последовательности действий — их не находит никто, включая
11
+ * автора, который перечитал свой код и уверен в нём.
12
+ *
13
+ * Проверено на себе: два независимых ревизора нашли восемь настоящих
14
+ * дефектов в коде, который был написан, вычитан и выпущен как готовый. Ни
15
+ * одного из них не нашёл ни автор, ни пользователь.
16
+ *
17
+ * Три решения, взятые из того опыта и не подлежащие упрощению:
18
+ *
19
+ * 1. РЕВИЗОРОВ ДВОЕ. Они находят РАЗНОЕ, а не подстраховывают друг друга:
20
+ * два самых опасных дефекта нашли разные исполнители, по одному у
21
+ * каждого. Один ревизор — гарантированно упущенная половина.
22
+ *
23
+ * 2. ФОРМАТ СТРОГИЙ. Без него приходит поток рассуждений, по которому
24
+ * нельзя работать. С ним — список, где у каждой находки есть место,
25
+ * последствие и способ воспроизвести.
26
+ *
27
+ * 3. НЕУВЕРЕННОСТЬ ЦЕННА. Ревизор, который пишет «возможно, мне не дали
28
+ * этот файл — стоит подтвердить», полезнее уверенного: в том опыте он
29
+ * был прав, файла ему действительно не дали.
30
+ *
31
+ * Зависимость объявлена как peerDependency: так пакет пользуется той же
32
+ * копией `dsh-tools`, что и работающий dsh, а не своей собственной. Импорт
33
+ * ЛЕНИВЫЙ, внутри `apply`, по двум причинам: модуль можно загрузить и
34
+ * проверить там, где dsh не установлен, а отсутствие пакета даёт внятную
35
+ * ошибку одной строки вместо обрушения всего ряда.
36
+ */
37
+
38
+ /** Провайдеры, которых зовём. Порядок влияет только на вывод. */
39
+ const REVIEWERS = ['claude-code', 'codex']
40
+
41
+ /**
42
+ * Задание ревизору.
43
+ *
44
+ * Оно самодостаточно: одноразовые провайдеры не видят нашей переписки, не
45
+ * знают персоны и не помнят прошлого вызова. Всё, что нужно для суждения,
46
+ * должно быть здесь.
47
+ *
48
+ * Формат ответа задан жёстко и с примером. Просьба «найди проблемы» без
49
+ * формата даёт эссе; с форматом — список, пригодный к работе.
50
+ */
51
+ function buildPrompt({ request, paths, focus }) {
52
+ const lines = []
53
+
54
+ lines.push('Ты — независимый ревизор. Работу делал другой исполнитель, ты её раньше не видел.')
55
+ lines.push('Твоя задача — НАЙТИ ПРОБЛЕМЫ. Не переписывать, не хвалить, не предлагать улучшений стиля.')
56
+ lines.push('')
57
+ lines.push('ЧТО ТРЕБОВАЛОСЬ СДЕЛАТЬ')
58
+ lines.push('')
59
+ lines.push(String(request).trim())
60
+ lines.push('')
61
+
62
+ if (Array.isArray(paths) && paths.length > 0) {
63
+ lines.push('ЧТО СМОТРЕТЬ')
64
+ lines.push('')
65
+ for (const path of paths) lines.push(` ${String(path)}`)
66
+ lines.push('')
67
+ lines.push('Пути указаны от рабочей папки, в которой ты запущен.')
68
+ lines.push('')
69
+ }
70
+
71
+ if (typeof focus === 'string' && focus.trim() !== '') {
72
+ lines.push('НА ЧТО ОБРАТИТЬ ОСОБОЕ ВНИМАНИЕ')
73
+ lines.push('')
74
+ lines.push(focus.trim())
75
+ lines.push('')
76
+ }
77
+
78
+ lines.push('ЧТО ИСКАТЬ В ЛЮБОМ СЛУЧАЕ')
79
+ lines.push('')
80
+ lines.push(' 1. Ошибки, из-за которых что-то сломается, зависнет или потеряется')
81
+ lines.push(' 2. Необработанные состояния: гонки, утечки, брошенные обещания, отказы сети')
82
+ lines.push(' 3. Несоответствие тому, что требовалось')
83
+ lines.push(' 4. Безопасность: что можно сделать со стороны, чего не следует')
84
+ lines.push(' 5. Случаи, о которых автор скорее всего не подумал')
85
+ lines.push('')
86
+ lines.push('ФОРМАТ ОТВЕТА — строго такой, без вступления и без заключения.')
87
+ lines.push('Для каждой находки:')
88
+ lines.push('')
89
+ lines.push('### краткое название')
90
+ lines.push('ВАЖНОСТЬ: критично | серьёзно | мелочь')
91
+ lines.push('ГДЕ: файл и строка либо имя функции')
92
+ lines.push('ЧТО НЕ ТАК: одно-два предложения')
93
+ lines.push('ПОЧЕМУ ЭТО ВАЖНО: что произойдёт у пользователя')
94
+ lines.push('КАК ПРОВЕРИТЬ: конкретные действия, по которым это воспроизводится')
95
+ lines.push('')
96
+ lines.push('Если находок нет — напиши ровно: НАХОДОК НЕТ')
97
+ lines.push('')
98
+ lines.push('Если каких-то данных тебе не дали и суждение из-за этого неполное — скажи')
99
+ lines.push('об этом прямо в находке, вместо того чтобы догадываться. Честная')
100
+ lines.push('неуверенность полезнее уверенной ошибки.')
101
+
102
+ return lines.join('\n')
103
+ }
104
+
105
+ /** Уровни важности в порядке убывания — по нему сортируется итог. */
106
+ const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
107
+
108
+ /**
109
+ * Разобрать ответ ревизора в список находок.
110
+ *
111
+ * Разбор намеренно снисходительный: ревизор — языковая модель, и точного
112
+ * соблюдения формата от неё ждать нельзя. Не разобранное не выбрасывается,
113
+ * а попадает в `raw`: потерять находку из-за лишнего пробела хуже, чем
114
+ * показать её неразмеченной.
115
+ */
116
+ export function parseFindings(text, reviewer) {
117
+ const source = String(text ?? '')
118
+ if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !source.includes('###')) return []
119
+
120
+ const findings = []
121
+ const blocks = source.split(/^###\s+/m).slice(1)
122
+
123
+ for (const block of blocks) {
124
+ const [titleLine, ...rest] = block.split('\n')
125
+ const body = rest.join('\n')
126
+ // Поле тянется до следующего заголовка или до конца блока.
127
+ //
128
+ // Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
129
+ // объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
130
+ // и вторая строка обычно и есть самое полезное. Начало строки
131
+ // обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
132
+ const field = (label) => {
133
+ const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n[А-ЯЁ ]{3,}:|$)`))
134
+ return match === null ? null : match[1].trim()
135
+ }
136
+
137
+ const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
138
+ const severity = SEVERITY_ORDER.find((level) => severityRaw.includes(level)) ?? 'мелочь'
139
+
140
+ findings.push({
141
+ reviewer,
142
+ title: titleLine.trim(),
143
+ severity,
144
+ where: field('ГДЕ'),
145
+ what: field('ЧТО НЕ ТАК'),
146
+ why: field('ПОЧЕМУ ЭТО ВАЖНО'),
147
+ howToCheck: field('КАК ПРОВЕРИТЬ'),
148
+ })
149
+ }
150
+
151
+ return findings
152
+ }
153
+
154
+ /** Вытащить текст из блоков ответа подагента. */
155
+ function textOf(output) {
156
+ if (!Array.isArray(output)) return ''
157
+ return output.filter((block) => block?.type === 'text').map((block) => String(block.text ?? '')).join('')
158
+ }
159
+
160
+ /**
161
+ * Позвать одного ревизора и вернуть его находки.
162
+ *
163
+ * Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
164
+ * пользователь должен видеть, кто именно не ответил.
165
+ */
166
+ async function askReviewer(ctx, provider, prompt, parent, signal) {
167
+ let run
168
+ try {
169
+ run = await ctx.subagents.start(provider, {
170
+ label: `обзор: ${provider}`,
171
+ prompt: [{ type: 'text', text: prompt }],
172
+ parent,
173
+ signal,
174
+ })
175
+ } catch (error) {
176
+ return { provider, failed: String(error?.message ?? error), findings: [] }
177
+ }
178
+
179
+ try {
180
+ const result = await run.result
181
+ const answer = textOf(result?.output)
182
+ if (answer.trim() === '') {
183
+ return { provider, failed: 'пустой ответ', findings: [] }
184
+ }
185
+ return { provider, failed: null, findings: parseFindings(answer, provider), answer }
186
+ } catch (error) {
187
+ return { provider, failed: String(error?.message ?? error), findings: [] }
188
+ } finally {
189
+ try {
190
+ await run.dispose()
191
+ } catch {
192
+ // Неудача уборки не должна подменять результат обзора.
193
+ }
194
+ }
195
+ }
196
+
197
+ /** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
198
+ function ordered(findings) {
199
+ return [...findings].sort((a, b) => {
200
+ const bySeverity = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
201
+ return bySeverity !== 0 ? bySeverity : a.reviewer.localeCompare(b.reviewer)
202
+ })
203
+ }
204
+
205
+ /** Человекочитаемый вид итога. */
206
+ export function render(value) {
207
+ const lines = []
208
+
209
+ if (value.reviewers.length === 0) {
210
+ return 'Ни один ревизор не доступен: провайдеры подагентов не смонтированы.'
211
+ }
212
+
213
+ const failed = value.reviewers.filter((r) => r.failed !== null)
214
+ const worked = value.reviewers.filter((r) => r.failed === null)
215
+
216
+ lines.push(`Ревизоров опрошено: ${worked.length} из ${value.reviewers.length}. Находок: ${value.findings.length}.`)
217
+ for (const entry of failed) lines.push(` ✗ ${entry.provider} — ${entry.failed}`)
218
+ lines.push('')
219
+
220
+ if (value.findings.length === 0) {
221
+ lines.push(worked.length === 0 ? 'Судить не по чему: не ответил ни один ревизор.' : 'Находок нет.')
222
+ return lines.join('\n')
223
+ }
224
+
225
+ // Сортируем здесь, а не полагаемся на вызывающего: опасное должно быть
226
+ // сверху при любом способе получить эти данные.
227
+ for (const finding of ordered(value.findings)) {
228
+ lines.push(`### ${finding.title} [${finding.severity}] — ${finding.reviewer}`)
229
+ if (finding.where) lines.push(`ГДЕ: ${finding.where}`)
230
+ if (finding.what) lines.push(`ЧТО НЕ ТАК: ${finding.what}`)
231
+ if (finding.why) lines.push(`ПОЧЕМУ ЭТО ВАЖНО: ${finding.why}`)
232
+ if (finding.howToCheck) lines.push(`КАК ПРОВЕРИТЬ: ${finding.howToCheck}`)
233
+ lines.push('')
234
+ }
235
+
236
+ // Совпадение двух независимых ревизоров — сильный признак: они друг друга
237
+ // не видели. Расхождение тоже полезно, но его надо проверять самому.
238
+ const byReviewer = new Map()
239
+ for (const finding of value.findings) {
240
+ byReviewer.set(finding.reviewer, (byReviewer.get(finding.reviewer) ?? 0) + 1)
241
+ }
242
+ lines.push(`По ревизорам: ${[...byReviewer].map(([name, count]) => `${name} — ${count}`).join(', ')}.`)
243
+ lines.push('Находки не проверены: это мнение со стороны, а не приговор. Подтвердите каждую прежде чем чинить.')
244
+
245
+ return lines.join('\n')
246
+ }
247
+
248
+ export const name = 'tensorgrid-second-opinion'
249
+ export const inject = ['tools', 'subagents']
250
+
251
+ /**
252
+ * @param ctx - контекст агент-плоскости с реестрами `tools` и `subagents`.
253
+ */
254
+ export async function apply(ctx) {
255
+ const { defineTool } = await import('@deepseek-ai/dsh-tools')
256
+
257
+ ctx.tools.register(
258
+ defineTool({
259
+ name: 'second_opinion',
260
+ description:
261
+ 'Ask independent reviewers (Claude Code and Codex) to find problems in work that was just done. ' +
262
+ 'They run as separate products in the same workspace, do not see this conversation, and return only findings — ' +
263
+ 'severity, location, what is wrong, why it matters, how to reproduce. ' +
264
+ 'Use after non-trivial work, before declaring it finished: quiet defects that only appear in a specific sequence ' +
265
+ 'are exactly what the author cannot see. Do not use for trivial edits — each review costs a full run of another product.',
266
+ parameters: {
267
+ request: {
268
+ type: 'string',
269
+ required: true,
270
+ description:
271
+ 'What was originally asked for, restated so someone who never saw this conversation can judge whether it was met.',
272
+ },
273
+ paths: {
274
+ type: 'array',
275
+ items: { type: 'string' },
276
+ description: 'Files or directories to examine, relative to the workspace.',
277
+ },
278
+ focus: {
279
+ type: 'string',
280
+ description: 'Optional: what deserves particular attention, such as a risky area or a known uncertainty.',
281
+ },
282
+ reviewers: {
283
+ type: 'array',
284
+ items: { type: 'string', enum: REVIEWERS },
285
+ description:
286
+ 'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
287
+ },
288
+ },
289
+ output: {
290
+ schema: {
291
+ type: 'object',
292
+ additionalProperties: true,
293
+ properties: {
294
+ findings: { type: 'array', items: { type: 'object', additionalProperties: true } },
295
+ reviewers: { type: 'array', items: { type: 'object', additionalProperties: true } },
296
+ },
297
+ },
298
+ render: (_args, value) => [{ type: 'text', text: render(value) }],
299
+ },
300
+ async execute(args, exec) {
301
+ const parent = exec.agent
302
+ const available = ctx.subagents.list()
303
+ const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0 ? args.reviewers : REVIEWERS
304
+ const usable = wanted.filter((provider) => available.includes(provider))
305
+
306
+ if (usable.length === 0) {
307
+ return {
308
+ findings: [],
309
+ reviewers: [],
310
+ problem: `нет доступных ревизоров; провайдеры на Host: ${available.join(', ') || 'нет'}`,
311
+ }
312
+ }
313
+
314
+ const prompt = buildPrompt(args)
315
+
316
+ // Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
317
+ // удвоил бы ожидание без всякой пользы.
318
+ const answers = await Promise.all(
319
+ usable.map((provider) => askReviewer(ctx, provider, prompt, parent, exec.signal)),
320
+ )
321
+
322
+ return {
323
+ findings: ordered(answers.flatMap((entry) => entry.findings)),
324
+ reviewers: answers.map((entry) => ({ provider: entry.provider, failed: entry.failed, found: entry.findings.length })),
325
+ }
326
+ },
327
+ }),
328
+ )
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,11 +11,22 @@
11
11
  "./client": {
12
12
  "default": "./lib/client.js"
13
13
  },
14
+ "./review": {
15
+ "default": "./lib/review.js"
16
+ },
14
17
  "./package.json": "./package.json"
15
18
  },
16
19
  "files": [
17
20
  "lib"
18
21
  ],
22
+ "peerDependencies": {
23
+ "@deepseek-ai/dsh-tools": "*"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "@deepseek-ai/dsh-tools": {
27
+ "optional": true
28
+ }
29
+ },
19
30
  "publishConfig": {
20
31
  "registry": "https://registry.npmjs.org"
21
32
  },