tensorgrid-ui 1.6.1 → 2.0.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/LICENSE +15 -0
- package/brand/tensorgrid.ico +0 -0
- package/env/snapshot.json +916 -0
- package/install.ps1 +457 -0
- package/lib/cli.mjs +67 -0
- package/lib/client.js +134 -104
- package/lib/http-runtime.js +58 -23
- package/lib/index.js +62 -2
- package/lib/review-log.js +296 -35
- package/lib/review-settings.js +205 -144
- package/lib/review.js +36 -26
- package/lib/reviewers.js +73 -16
- package/package.json +12 -2
- package/preset/agent.cordis.yml +304 -0
- package/preset/preset.yml +5 -0
- package/profile/cordis.patch.yml +65 -0
- package/profile/package.json +14 -0
- package/tools/round-ico-corners.mjs +113 -0
package/lib/review-settings.js
CHANGED
|
@@ -6,120 +6,183 @@
|
|
|
6
6
|
* значит хранилище должно быть общим для обеих сторон. Память браузера для
|
|
7
7
|
* этого не годится, Host её не видит.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* ФОРМА: СПИСОК, А НЕ ФИКСИРОВАННЫЕ ГНЁЗДА
|
|
10
|
+
*
|
|
11
|
+
* Раньше ревизоры были четырьмя жёсткими гнёздами, а сверху стояли три
|
|
12
|
+
* готовых режима — быстрый, обычный, глубокий. Это оказалось не тем
|
|
13
|
+
* решением: готовый режим отвечает за человека на вопрос, на который он
|
|
14
|
+
* вполне способен ответить сам, а четыре гнезда не дают позвать, скажем,
|
|
15
|
+
* три разные модели одного семейства.
|
|
16
|
+
*
|
|
17
|
+
* Теперь это список произвольной длины. Каждая запись говорит: кем
|
|
18
|
+
* работать (нашим подагентом или чужим продуктом), какой моделью, с каким
|
|
19
|
+
* усилием, участвует ли сейчас.
|
|
11
20
|
*
|
|
12
21
|
* Импортируются только встроенные модули Node.
|
|
13
22
|
*/
|
|
14
|
-
import { KNOWN } from './reviewers.js'
|
|
15
|
-
import {
|
|
23
|
+
import { KNOWN, REVIEWERS } from './reviewers.js'
|
|
24
|
+
import { randomBytes } from 'node:crypto'
|
|
25
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
16
26
|
import { existsSync } from 'node:fs'
|
|
17
27
|
import { dirname, join } from 'node:path'
|
|
18
28
|
import { fileURLToPath } from 'node:url'
|
|
19
29
|
|
|
20
30
|
const FILE_NAME = 'tensorgrid-review.json'
|
|
21
31
|
|
|
22
|
-
/**
|
|
23
|
-
* Ревизоры берутся из `reviewers.js` — единственного источника.
|
|
24
|
-
* Свой литерал здесь был третьим по счёту и расходился бы молча.
|
|
25
|
-
*/
|
|
32
|
+
/** Ревизоры берутся из `reviewers.js` — единственного источника. */
|
|
26
33
|
export { KNOWN } from './reviewers.js'
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
|
-
*
|
|
36
|
+
* Сколько ревизоров можно завести.
|
|
30
37
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* обзоре. В интерфейсе это было невидимо — список с неизвестным значением
|
|
35
|
-
* показывает пустое поле, неотличимое от «как настроено».
|
|
38
|
+
* Предел есть, потому что каждый — отдельный прогон модели: десяток
|
|
39
|
+
* означает десяток ожиданий и десяток счетов. Число намеренно щедрое:
|
|
40
|
+
* это заслон от случайной сотни, а не указание, сколько «правильно».
|
|
36
41
|
*/
|
|
37
|
-
const
|
|
38
|
-
const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,95}$/
|
|
42
|
+
export const MAX_REVIEWERS = 8
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
|
-
*
|
|
45
|
+
* Что допустимо в провайдере, модели и усилии.
|
|
42
46
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
47
|
+
* Проверка стоит ЗДЕСЬ, при сохранении, а не в момент вызова: раньше
|
|
48
|
+
* кривое значение сохранялось молча и отвергалось через двадцать минут,
|
|
49
|
+
* при следующем обзоре, причём в интерфейсе выглядело как «как настроено».
|
|
46
50
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
51
|
+
* Строгий вариант — для внешних продуктов: значение уходит в аргументы
|
|
52
|
+
* командной строки, и косая черта там ни в одном имени не нужна.
|
|
53
|
+
* Послабленный — для наших: маршрут называет модели вида `x-ai/grok-4.6`,
|
|
54
|
+
* и общая проверка отвергала бы половину подключённых.
|
|
49
55
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
label: 'Быстрый',
|
|
53
|
-
hint: 'Один ревизор, обычное усилие. Минуты. Для рядовой работы.',
|
|
54
|
-
enabled: ['claude-code'],
|
|
55
|
-
effort: null,
|
|
56
|
-
},
|
|
57
|
-
normal: {
|
|
58
|
-
label: 'Обычный',
|
|
59
|
-
hint: 'Два разных продукта. Они находят разное — один упускает половину.',
|
|
60
|
-
enabled: ['claude-code', 'codex'],
|
|
61
|
-
effort: null,
|
|
62
|
-
},
|
|
63
|
-
deep: {
|
|
64
|
-
label: 'Глубокий',
|
|
65
|
-
hint: 'Все ревизоры на полном усилии, включая нашего на выбранной модели. Долго и дорого — для того, что увидят люди.',
|
|
66
|
-
// «Глубокий» означает ВСЕ, а не перечисленных поимённо: иначе добавление
|
|
67
|
-
// четвёртого ревизора молча оставило бы самый тщательный режим без него.
|
|
68
|
-
// Остальные режимы называют состав явно — в этом и есть их смысл.
|
|
69
|
-
enabled: [...KNOWN],
|
|
70
|
-
effort: 'high',
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Режим по умолчанию: два ревизора — проверенная середина. */
|
|
75
|
-
const DEFAULT_MODE = 'normal'
|
|
56
|
+
const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
57
|
+
const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
|
|
76
58
|
|
|
77
59
|
/**
|
|
78
60
|
* Что действует, пока пользователь ничего не выбрал.
|
|
79
61
|
*
|
|
80
|
-
* Два ревизора
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
62
|
+
* Два ревизора на РАЗНЫХ семействах моделей из двух подписок. Два —
|
|
63
|
+
* потому что они находят разное: один упускает половину. Разных семейств —
|
|
64
|
+
* потому что два прогона одной модели дают почти одинаковые находки.
|
|
65
|
+
*
|
|
66
|
+
* Обе модели по подписке: обзор по умолчанию не должен молча тратить
|
|
67
|
+
* деньги поштучно.
|
|
84
68
|
*/
|
|
85
69
|
export function defaults() {
|
|
86
70
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
71
|
+
reviewers: [
|
|
72
|
+
{
|
|
73
|
+
id: 'r1',
|
|
74
|
+
kind: 'internal',
|
|
75
|
+
provider: REVIEWERS.internal.defaultProvider,
|
|
76
|
+
model: REVIEWERS.internal.defaultModel,
|
|
77
|
+
effort: null,
|
|
78
|
+
enabled: true,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'r2',
|
|
82
|
+
kind: 'internal',
|
|
83
|
+
provider: REVIEWERS['internal-alt'].defaultProvider,
|
|
84
|
+
model: REVIEWERS['internal-alt'].defaultModel,
|
|
85
|
+
effort: null,
|
|
86
|
+
enabled: true,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
93
89
|
}
|
|
94
90
|
}
|
|
95
91
|
|
|
92
|
+
/** Признак записи. Короткий и без общих счётчиков: их сложно держать в файле. */
|
|
93
|
+
function freshId() {
|
|
94
|
+
return `r${randomBytes(4).toString('hex')}`
|
|
95
|
+
}
|
|
96
|
+
|
|
96
97
|
/**
|
|
97
|
-
*
|
|
98
|
+
* Привести прочитанное к ожидаемой форме: файл правит и человек руками.
|
|
98
99
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* Понимает и ПРЕЖНЮЮ форму — `enabled` + `choices` + `mode`. Без этого
|
|
101
|
+
* переход на список молча обнулил бы настройки у всех, кто уже что-то
|
|
102
|
+
* выбрал: файл бы не разобрался, а пустой файл означает умолчания.
|
|
102
103
|
*/
|
|
103
|
-
export function
|
|
104
|
-
|
|
105
|
-
if (spec === undefined) return settings
|
|
104
|
+
export function normalise(raw) {
|
|
105
|
+
if (raw === null || typeof raw !== 'object') return defaults()
|
|
106
106
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
const source = Array.isArray(raw.reviewers) ? raw.reviewers : migrate(raw)
|
|
108
|
+
if (source === null) return defaults()
|
|
109
|
+
|
|
110
|
+
const seen = new Set()
|
|
111
|
+
const reviewers = []
|
|
112
|
+
|
|
113
|
+
for (const entry of source) {
|
|
114
|
+
if (reviewers.length >= MAX_REVIEWERS) break
|
|
115
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
116
|
+
|
|
117
|
+
const kind = KNOWN.includes(entry.kind) ? entry.kind : null
|
|
118
|
+
if (kind === null) continue
|
|
119
|
+
|
|
120
|
+
const allowed = REVIEWERS[kind].kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
|
|
121
|
+
const pick = (value) => {
|
|
122
|
+
if (typeof value !== 'string') return null
|
|
123
|
+
const trimmed = value.trim()
|
|
124
|
+
return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
|
|
114
125
|
}
|
|
126
|
+
|
|
127
|
+
// Признак обязан быть уникальным: по нему интерфейс отличает строки, и
|
|
128
|
+
// совпадение слило бы две записи в одну при первой же правке.
|
|
129
|
+
let id = typeof entry.id === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(entry.id) ? entry.id : freshId()
|
|
130
|
+
while (seen.has(id)) id = freshId()
|
|
131
|
+
seen.add(id)
|
|
132
|
+
|
|
133
|
+
reviewers.push({
|
|
134
|
+
id,
|
|
135
|
+
kind,
|
|
136
|
+
// Провайдер имеет смысл только у наших: внешний продукт ходит своим
|
|
137
|
+
// путём, и наш маршрут ему не указ.
|
|
138
|
+
provider: REVIEWERS[kind].kind === 'internal' ? pick(entry.provider) : null,
|
|
139
|
+
model: pick(entry.model),
|
|
140
|
+
effort: pick(entry.effort),
|
|
141
|
+
enabled: entry.enabled !== false,
|
|
142
|
+
})
|
|
115
143
|
}
|
|
116
|
-
|
|
144
|
+
|
|
145
|
+
// Пустой список допустим: это осознанный выбор «никого не звать», и
|
|
146
|
+
// подменять его умолчаниями значило бы звать тех, кого выключили.
|
|
147
|
+
return { reviewers }
|
|
117
148
|
}
|
|
118
149
|
|
|
119
150
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
151
|
+
* Перевести настройки прежней формы в список.
|
|
152
|
+
*
|
|
153
|
+
* Прежде было: `enabled` — какие гнёзда включены, `choices` — настройка
|
|
154
|
+
* каждого гнезда. Переносим один к одному, сохраняя и выключенные: человек
|
|
155
|
+
* их настраивал, и терять эту работу при обновлении незачем.
|
|
156
|
+
*
|
|
157
|
+
* @returns записи в новой форме либо null, если прежней формы не видно.
|
|
122
158
|
*/
|
|
159
|
+
function migrate(raw) {
|
|
160
|
+
const hasOld = Array.isArray(raw.enabled) || (raw.choices !== null && typeof raw.choices === 'object')
|
|
161
|
+
if (!hasOld) return null
|
|
162
|
+
|
|
163
|
+
const enabled = new Set(Array.isArray(raw.enabled) ? raw.enabled : [])
|
|
164
|
+
const entries = []
|
|
165
|
+
|
|
166
|
+
for (const kind of KNOWN) {
|
|
167
|
+
const choice = raw.choices?.[kind]
|
|
168
|
+
const configured = choice !== undefined && choice !== null && typeof choice === 'object'
|
|
169
|
+
// Гнездо, которое не включали и не настраивали, переносить незачем:
|
|
170
|
+
// иначе список сразу открывается четырьмя пустыми строками.
|
|
171
|
+
if (!enabled.has(kind) && !configured) continue
|
|
172
|
+
entries.push({
|
|
173
|
+
id: freshId(),
|
|
174
|
+
kind,
|
|
175
|
+
provider: configured ? choice.provider : null,
|
|
176
|
+
model: configured ? choice.model : null,
|
|
177
|
+
effort: configured ? choice.effort : null,
|
|
178
|
+
enabled: enabled.has(kind),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return entries
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Каталог профиля: поднимаемся, пока не найдём отметку об установке. */
|
|
123
186
|
function findProfileRoot(startDir) {
|
|
124
187
|
let dir = startDir
|
|
125
188
|
for (let depth = 0; depth < 10; depth += 1) {
|
|
@@ -134,80 +197,66 @@ function findProfileRoot(startDir) {
|
|
|
134
197
|
/**
|
|
135
198
|
* Где лежит файл настроек.
|
|
136
199
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
200
|
+
* Каталог ищется ТОЛЬКО от собственного расположения модуля. Запасной
|
|
201
|
+
* подъём от рабочей папки убран намеренно: подложенная в папку чата отметка
|
|
202
|
+
* об установке увела бы настройки в чужое дерево — в тот самый
|
|
203
|
+
* непроверенный код, который отдан на обзор. Две половины одного продукта
|
|
204
|
+
* не могут искать свой файл по разным правилам.
|
|
140
205
|
*
|
|
141
|
-
* @param cwd -
|
|
206
|
+
* @param cwd - оставлен ради совместимости вызовов; не используется.
|
|
207
|
+
* @param root - явный каталог; только для проверок.
|
|
142
208
|
*/
|
|
143
|
-
export function settingsPath(cwd) {
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
return
|
|
209
|
+
export function settingsPath(cwd, root = null) {
|
|
210
|
+
if (root !== null) return join(root, FILE_NAME)
|
|
211
|
+
const found = findProfileRoot(dirname(fileURLToPath(import.meta.url)))
|
|
212
|
+
return found === null ? null : join(found, FILE_NAME)
|
|
147
213
|
}
|
|
148
214
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
for (const kind of KNOWN) {
|
|
162
|
-
const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
|
|
163
|
-
// Недопустимое значение отбрасывается молча и превращается в «как
|
|
164
|
-
// настроено»: сохранить его значило бы выключить обзор до тех пор, пока
|
|
165
|
-
// кто-нибудь не догадается открыть файл.
|
|
166
|
-
//
|
|
167
|
-
// Наш ревизор ходит по маршруту, где имена содержат косую черту
|
|
168
|
-
// (`x-ai/grok-4.6`), внешние — по командной строке, где её быть не
|
|
169
|
-
// должно. Одна общая проверка отвергала бы половину моделей.
|
|
170
|
-
const allowed = kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
|
|
171
|
-
const pick = (value) => {
|
|
172
|
-
if (typeof value !== 'string') return null
|
|
173
|
-
const trimmed = value.trim()
|
|
174
|
-
return trimmed !== '' && allowed.test(trimmed) ? trimmed : null
|
|
175
|
-
}
|
|
176
|
-
choices[kind] = {
|
|
177
|
-
provider: pick(source?.provider),
|
|
178
|
-
model: pick(source?.model),
|
|
179
|
-
effort: pick(source?.effort),
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Режим — подсказка для интерфейса, а не источник состава: состав уже
|
|
184
|
-
// записан в `enabled`. Неизвестное имя превращается в «свой набор»,
|
|
185
|
-
// потому что именно им оно и является.
|
|
186
|
-
const mode = typeof raw.mode === 'string' && MODES[raw.mode] !== undefined ? raw.mode : null
|
|
215
|
+
/**
|
|
216
|
+
* Очередь записи на каждый файл.
|
|
217
|
+
*
|
|
218
|
+
* Запись идёт по схеме «прочитать целиком → изменить → записать целиком»,
|
|
219
|
+
* и без очереди одновременные вызовы читают ОДНО состояние, а пишут каждый
|
|
220
|
+
* своё: побеждает последний, остальные исчезают бесследно. Проверено —
|
|
221
|
+
* двенадцать одновременных записей давали одну.
|
|
222
|
+
*
|
|
223
|
+
* Очередь внутрипроцессная, и этого достаточно: оба пути — Host и браузер —
|
|
224
|
+
* проходят через один процесс.
|
|
225
|
+
*/
|
|
226
|
+
const queues = new Map()
|
|
187
227
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
228
|
+
function serialise(path, work) {
|
|
229
|
+
const previous = queues.get(path) ?? Promise.resolve()
|
|
230
|
+
const next = previous.then(work, work)
|
|
231
|
+
// В очереди держим только факт завершения: иначе одна неудачная запись
|
|
232
|
+
// уронила бы все следующие.
|
|
233
|
+
queues.set(path, next.then(() => undefined, () => undefined))
|
|
234
|
+
return next
|
|
191
235
|
}
|
|
192
236
|
|
|
193
237
|
/**
|
|
194
|
-
*
|
|
238
|
+
* Переименовать с повтором.
|
|
195
239
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
240
|
+
* На Windows одновременное переименование двух временных файлов в одну цель
|
|
241
|
+
* изредка отказывает с EPERM или EBUSY: файл в этот момент держит чужая
|
|
242
|
+
* операция. Это не ошибка вызывающего и не повод терять запись.
|
|
199
243
|
*/
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
244
|
+
async function renameWithRetry(from, to, attempts = 5) {
|
|
245
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
246
|
+
try {
|
|
247
|
+
await rename(from, to)
|
|
248
|
+
return
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const retriable = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
|
|
251
|
+
if (!retriable || attempt >= attempts) throw error
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 15))
|
|
253
|
+
}
|
|
204
254
|
}
|
|
205
|
-
return null
|
|
206
255
|
}
|
|
207
256
|
|
|
208
257
|
/** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
|
|
209
|
-
export async function readSettings(cwd) {
|
|
210
|
-
const path = settingsPath(cwd)
|
|
258
|
+
export async function readSettings(cwd, root = null) {
|
|
259
|
+
const path = settingsPath(cwd, root)
|
|
211
260
|
if (path === null) return defaults()
|
|
212
261
|
try {
|
|
213
262
|
const text = await readFile(path, 'utf8')
|
|
@@ -220,17 +269,29 @@ export async function readSettings(cwd) {
|
|
|
220
269
|
/**
|
|
221
270
|
* Записать настройки. Возвращает то, что действительно сохранено.
|
|
222
271
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
272
|
+
* Присланное ЗАМЕЩАЕТ список целиком, и это правильно именно для списка:
|
|
273
|
+
* удаление записи иначе было бы невыразимо — слияние всегда возвращало бы
|
|
274
|
+
* удалённое обратно. Ответственность за целость списка лежит на том, кто
|
|
275
|
+
* его прислал, а интерфейс всегда шлёт его полностью.
|
|
227
276
|
*/
|
|
228
|
-
export async function writeSettings(cwd, value) {
|
|
229
|
-
|
|
277
|
+
export async function writeSettings(cwd, value, root = null) {
|
|
278
|
+
return serialise(settingsPath(cwd, root) ?? 'нет-пути', () => writeSettingsNow(cwd, value, root))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function writeSettingsNow(cwd, value, root) {
|
|
282
|
+
const path = settingsPath(cwd, root)
|
|
230
283
|
if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
|
|
284
|
+
|
|
231
285
|
const normalised = normalise(value)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
286
|
+
// Уникальное имя временного файла: у двух одновременных записей прежнее
|
|
287
|
+
// совпадало, и вторая получала отказ на переименовании.
|
|
288
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
289
|
+
try {
|
|
290
|
+
await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
|
|
291
|
+
await renameWithRetry(temporary, path)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await rm(temporary, { force: true }).catch(() => {})
|
|
294
|
+
throw error
|
|
295
|
+
}
|
|
235
296
|
return normalised
|
|
236
297
|
}
|
package/lib/review.js
CHANGED
|
@@ -292,17 +292,17 @@ export function parseFindings(text, reviewer) {
|
|
|
292
292
|
* Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
|
|
293
293
|
* пользователь должен видеть, кто именно не ответил.
|
|
294
294
|
*/
|
|
295
|
-
async function askReviewer(ctx, runReviewer, kind, prompt, cwd,
|
|
295
|
+
async function askReviewer(ctx, runReviewer, kind, prompt, cwd, entry, signal) {
|
|
296
296
|
const outcome = await runReviewer(ctx, kind, {
|
|
297
297
|
prompt,
|
|
298
298
|
cwd,
|
|
299
|
-
provider:
|
|
300
|
-
model:
|
|
301
|
-
effort:
|
|
299
|
+
provider: entry?.provider ?? null,
|
|
300
|
+
model: entry?.model ?? null,
|
|
301
|
+
effort: entry?.effort ?? null,
|
|
302
302
|
signal,
|
|
303
303
|
})
|
|
304
304
|
|
|
305
|
-
const used = { model:
|
|
305
|
+
const used = { model: entry?.model ?? null, effort: entry?.effort ?? null }
|
|
306
306
|
|
|
307
307
|
if (outcome.failed !== null) {
|
|
308
308
|
return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
|
|
@@ -458,47 +458,57 @@ export async function apply(ctx) {
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
//
|
|
461
|
+
// Состав целиком из настроек: список, который человек собрал сам.
|
|
462
462
|
const settings = await readSettings(cwd)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return {
|
|
463
|
+
let chosen = settings.reviewers.filter((entry) => entry.enabled)
|
|
464
|
+
|
|
465
|
+
// Вызов может сузить состав по виду ревизора — например «только
|
|
466
|
+
// Codex». Это именно СУЖЕНИЕ настроенного, а не свой набор: модель
|
|
467
|
+
// и усилие всё равно берутся из настроек, потому что их выбирал
|
|
468
|
+
// человек, а не тот, кто зовёт инструмент.
|
|
469
|
+
if (Array.isArray(args.reviewers) && args.reviewers.length > 0) {
|
|
470
|
+
const kinds = new Set(args.reviewers.filter((kind) => KNOWN_REVIEWERS.includes(kind)))
|
|
471
|
+
if (kinds.size > 0) chosen = chosen.filter((entry) => kinds.has(entry.kind))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (chosen.length === 0) {
|
|
475
|
+
const anyConfigured = settings.reviewers.length > 0
|
|
476
|
+
return {
|
|
477
|
+
findings: [],
|
|
478
|
+
reviewers: [],
|
|
479
|
+
problem: anyConfigured
|
|
480
|
+
? 'ни один ревизор не включён — включите нужных в настройках'
|
|
481
|
+
: 'ревизоры не настроены — добавьте хотя бы одного в настройках',
|
|
482
|
+
}
|
|
477
483
|
}
|
|
478
484
|
|
|
479
485
|
const prompt = buildPrompt(args)
|
|
480
486
|
|
|
481
|
-
// Спрашиваем
|
|
482
|
-
//
|
|
487
|
+
// Спрашиваем ВСЕХ разом: они независимы, и последовательный опрос
|
|
488
|
+
// умножил бы ожидание без всякой пользы.
|
|
483
489
|
const answers = await Promise.all(
|
|
484
|
-
|
|
490
|
+
chosen.map((entry) =>
|
|
485
491
|
askReviewer(
|
|
486
492
|
// Наш ревизор заводит подагента, и тому нужен родитель —
|
|
487
493
|
// агент, от имени которого идёт вызов. Внешним он не нужен.
|
|
488
494
|
{ ...ctx, get: (name) => ctx.get(name), reviewParent: exec.agent },
|
|
489
495
|
runReviewer,
|
|
490
|
-
kind,
|
|
496
|
+
entry.kind,
|
|
491
497
|
prompt,
|
|
492
498
|
cwd,
|
|
493
|
-
|
|
499
|
+
entry,
|
|
494
500
|
exec.signal,
|
|
495
501
|
),
|
|
496
502
|
),
|
|
497
503
|
)
|
|
498
504
|
|
|
499
505
|
const findings = ordered(answers.flatMap((entry) => entry.findings))
|
|
500
|
-
const reviewers = answers.map((entry) => ({
|
|
506
|
+
const reviewers = answers.map((entry, index) => ({
|
|
501
507
|
provider: entry.provider,
|
|
508
|
+
// Признак записи сохраняется вместе с итогом: в списке может быть
|
|
509
|
+
// несколько ревизоров одного вида на разных моделях, и без него
|
|
510
|
+
// статистика слила бы их в одну строку.
|
|
511
|
+
slot: chosen[index].id,
|
|
502
512
|
failed: entry.failed,
|
|
503
513
|
cancelled: entry.cancelled === true,
|
|
504
514
|
found: entry.findings.length,
|