@y9931666/dsh-plugin-crossfire 0.1.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/index.js ADDED
@@ -0,0 +1,3909 @@
1
+ import { a as newReviewId, c as reviewDirOf, d as withCrossfireLock, f as writeJsonAtomic, i as listReviewIds, l as reviewLockKey, n as appendJsonLine, o as readJsonFile, p as writeTextAtomic, s as readJsonLines, u as stateRootOf } from "./state-DzrgVdrB.js";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { dirname, join } from "node:path";
5
+ import { mkdir, readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+
9
+ //#region src/budget.ts
10
+ /**
11
+ * 估算文本的 token 成本(粗估:CJK 0.6/字,其它非空白 0.3/字符,向上取整)。
12
+ *
13
+ * 口径与圆桌 `budget.ts` 的 `estimateTokens` **完全一致**(逐字符 `for...of`
14
+ * 按码点遍历,`code >= 0x2e80` 记 CJK,空白不计入 other,最后 `Math.ceil`)。
15
+ * 这是刻意照搬而不是重新设计:两个插件对同一段中文给出的预算数字必须可比。
16
+ *
17
+ * 再次强调:这是一个**已说出的文本长度估算**,不是模型真实用量。
18
+ */
19
+ function estimateTokens(text$1) {
20
+ let cjk = 0;
21
+ let other = 0;
22
+ for (const char of text$1) if ((char.codePointAt(0) ?? 0) >= 11904) cjk += 1;
23
+ else if (char.trim() !== "") other += 1;
24
+ return Math.ceil(cjk * .6 + other * .3);
25
+ }
26
+ /**
27
+ * 哪条预算轴被突破(无则 `undefined`)。
28
+ *
29
+ * 语义(与圆桌一致,见文件头):两条轴的 `max*` 为 `0` 表示该轴不限制。
30
+ * 返回的是**轴名**,不是布尔 —— 调用方需要把它透出给用户,让"是哪条线断了"
31
+ * 可诊断,而不是笼统的一句"预算用完了"。
32
+ */
33
+ function budgetExceeded(record) {
34
+ const { maxRounds, maxTokens, usedRounds, usedTokens } = record.budget;
35
+ if (maxRounds > 0 && usedRounds > maxRounds) return "rounds";
36
+ if (maxTokens > 0 && usedTokens >= maxTokens) return "tokens";
37
+ }
38
+ /**
39
+ * 预算熔断错误。
40
+ *
41
+ * 不是"会议已结束",也不是"会议被静音":针锋只有这一种停止原因,且**可恢复**
42
+ * —— 用户把设置里的上限调大即可继续(状态从未被改动,所以不存在"复活"一说)。
43
+ */
44
+ var CrossfireMutedError = class extends Error {
45
+ axis;
46
+ constructor(reviewId, axis) {
47
+ super(`crossfire: review "${reviewId}" hit the ${axis} budget cap — later work stops here; raise the cap in the Crossfire settings to continue (the review status was not changed)`);
48
+ this.name = "CrossfireMutedError";
49
+ this.axis = axis;
50
+ }
51
+ };
52
+ /**
53
+ * 熔断门禁:超限即抛 {@link CrossfireMutedError},否则什么都不做。
54
+ *
55
+ * **不修改 `record`**(与圆桌 `markMuted` 的本质区别):针锋不把"超预算"写成
56
+ * 一个持久状态,因为那个状态会反过来挡住用户自救的路径 —— 圆桌的真实事故就是
57
+ * `ensureActive` 先改状态再抛错,导致连补预算/导出都被同一个门禁拒绝,花完预算
58
+ * 的会议永远卡在 `active` 却什么都做不了。这里只抛、不改,问题无从被固化。
59
+ *
60
+ * @param record - 被检查的评审记录。
61
+ * @param options.allowMuted - 收尾路径(导出、表态、定稿)必须保持可达,
62
+ * 否则用户连"把已经打出来的缺陷看完并裁决"都做不了;置 `true` 时直接放行。
63
+ */
64
+ function assertBudget(record, options = {}) {
65
+ if (options.allowMuted === true) return;
66
+ const axis = budgetExceeded(record);
67
+ if (axis !== void 0) throw new CrossfireMutedError(record.reviewId, axis);
68
+ }
69
+ /**
70
+ * 把一段专家输出计入 token 用量(**原地修改** `record`,由调用方负责落盘)。
71
+ *
72
+ * 只在文本非空时累加,避免空发言把 `usedTokens` 上一堆无意义的 `+0` 调用;
73
+ * 估算口径交给 {@link estimateTokens},本函数不做任何额外加权。
74
+ */
75
+ function chargeTokens(record, text$1) {
76
+ if (text$1 === "") return;
77
+ record.budget.usedTokens += estimateTokens(text$1);
78
+ }
79
+ /**
80
+ * 推进一轮:`reviewPass += 1` 并同步 `budget.usedRounds`,返回新的轮次。
81
+ *
82
+ * 这里**不**做预算检查:门禁由调用方在动手之前调用 {@link assertBudget},因为
83
+ * "先检查后推进"的顺序一旦反过来,第 N+1 轮的副作用就已经发生了。
84
+ * `usedRounds` 与 `reviewPass` 必须同进同退,两者脱钩会让预算判据失去意义。
85
+ */
86
+ function beginPass(record) {
87
+ record.reviewPass += 1;
88
+ record.budget.usedRounds = record.reviewPass;
89
+ return record.reviewPass;
90
+ }
91
+
92
+ //#endregion
93
+ //#region src/types.ts
94
+ /**
95
+ * Crossfire(针锋)的领域类型。
96
+ *
97
+ * 设计边界(与圆桌会议刻意不同,务必守住):
98
+ * - 没有 nodes / edges / topology —— 专家是行,不是图。
99
+ * - 没有 charter 的多模式 —— 只有一种角色:红队。
100
+ * - 没有 kb / skill / proxy-thinking 字段 —— 这个插件不读知识库、不传 skill。
101
+ *
102
+ * @module dsh-plugin-crossfire/types
103
+ */
104
+ /**
105
+ * 固定维度池。
106
+ *
107
+ * 为什么必须固定:抽样 5 份真实圆桌 `review.json`,`dimension` 实值是
108
+ * `维度②`(×5) / `维度④`(×1) / `其他`(×4) / 空串(×1) —— 只有 1 份给出了
109
+ * 有意义的 `架构/产品/成本`。原因是 LLM 拆分经常失败并退到本地启发式兜底,
110
+ * 而兜底把段首的圆圈数字直接当成了维度名。用户看到的因此是一堆编号。
111
+ * 固定池 + 落库归一化是这件事的唯一可靠解法。
112
+ */
113
+ const DIMENSION_POOL = [
114
+ "安全",
115
+ "数据",
116
+ "交互",
117
+ "性能",
118
+ "兼容",
119
+ "流程",
120
+ "反例"
121
+ ];
122
+ /** 三档强度的完整定义。 */
123
+ const INTENSITY_SPECS = {
124
+ quick: {
125
+ expertsPerDimension: 1,
126
+ crossExamination: false,
127
+ maxReviewPass: 1,
128
+ label: "快审:一轮出结论,不复审"
129
+ },
130
+ standard: {
131
+ expertsPerDimension: 1,
132
+ crossExamination: false,
133
+ maxReviewPass: 2,
134
+ label: "标准:一轮攻击,可复审一次"
135
+ },
136
+ deep: {
137
+ expertsPerDimension: 1,
138
+ crossExamination: true,
139
+ maxReviewPass: 3,
140
+ label: "深挖:加一轮交叉质证,可复审两次"
141
+ }
142
+ };
143
+
144
+ //#endregion
145
+ //#region src/review.ts
146
+ /** 当前 schema 版本。 */
147
+ const CURRENT_SCHEMA_VERSION = 1;
148
+ /** `<reviewDir>/review.json`。 */
149
+ function reviewFile(stateRoot, reviewId) {
150
+ return join(reviewDirOf(stateRoot, reviewId), "review.json");
151
+ }
152
+ /** `<reviewDir>/transcript.jsonl`。 */
153
+ function transcriptFile(stateRoot, reviewId) {
154
+ return join(reviewDirOf(stateRoot, reviewId), "transcript.jsonl");
155
+ }
156
+ /** `<reviewDir>/export.md`。 */
157
+ function exportFile(stateRoot, reviewId) {
158
+ return join(reviewDirOf(stateRoot, reviewId), "export.md");
159
+ }
160
+ /** 通用:取一个非空字符串,否则返回 undefined。 */
161
+ function asText(value, max = 4096) {
162
+ if (typeof value !== "string") return void 0;
163
+ const text$1 = value.trim();
164
+ if (text$1 === "") return void 0;
165
+ return text$1.slice(0, max);
166
+ }
167
+ /** 通用:取一个非负整数,否则返回 undefined。 */
168
+ function asCount(value) {
169
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
170
+ const n = Math.floor(value);
171
+ return n < 0 ? void 0 : n;
172
+ }
173
+ function sanitizeEvidence(value) {
174
+ if (value === null || typeof value !== "object") return void 0;
175
+ const raw = value;
176
+ if (raw.kind !== "repro" && raw.kind !== "argument") return void 0;
177
+ const text$1 = asText(raw.text, 600);
178
+ if (text$1 === void 0) return void 0;
179
+ return {
180
+ kind: raw.kind,
181
+ text: text$1
182
+ };
183
+ }
184
+ function sanitizeRecheck(value) {
185
+ if (value === null || typeof value !== "object") return void 0;
186
+ const raw = value;
187
+ const pass = asCount(raw.pass);
188
+ if (pass === void 0) return void 0;
189
+ const verdict = raw.verdict === "fixed" || raw.verdict === "unfixed" || raw.verdict === "partial" ? raw.verdict : void 0;
190
+ const note = asText(raw.note, 500);
191
+ return {
192
+ pass,
193
+ ...verdict === void 0 ? {} : { verdict },
194
+ ...note === void 0 ? {} : { note }
195
+ };
196
+ }
197
+ /** 净化一条缺陷;内容为空的条目直接丢弃。 */
198
+ function sanitizeDefect(value, index, allowed) {
199
+ if (value === null || typeof value !== "object") return void 0;
200
+ const raw = value;
201
+ const content = asText(raw.content, 2e3);
202
+ if (content === void 0) return void 0;
203
+ const id = asText(raw.id, 128) ?? `defect-${index}`;
204
+ const utteranceId = asText(raw.utteranceId, 128) ?? id;
205
+ const expertKey = asText(raw.expertKey, 64) ?? "";
206
+ const rawDimension = asText(raw.dimension, 32);
207
+ const matched = rawDimension !== void 0 && allowed.includes(rawDimension) ? rawDimension : void 0;
208
+ const defect = {
209
+ id,
210
+ utteranceId,
211
+ expertKey,
212
+ content,
213
+ dimension: matched ?? allowed[0] ?? DIMENSION_POOL[0],
214
+ status: raw.status === "endorsed" || raw.status === "rejected" ? raw.status : "pending",
215
+ ts: asCount(raw.ts) ?? Date.now(),
216
+ seq: asCount(raw.seq) ?? 0
217
+ };
218
+ if (matched === void 0) defect.dimensionFallback = true;
219
+ else if (raw.dimensionFallback === true) defect.dimensionFallback = true;
220
+ const quote$1 = asText(raw.quote, 120);
221
+ if (quote$1 !== void 0) defect.quote = quote$1;
222
+ const evidence = sanitizeEvidence(raw.evidence);
223
+ if (evidence !== void 0) defect.evidence = evidence;
224
+ if (raw.strikesStance === true) defect.strikesStance = true;
225
+ const rejectReason = asText(raw.rejectReason, 500);
226
+ if (rejectReason !== void 0) defect.rejectReason = rejectReason;
227
+ const recheck = sanitizeRecheck(raw.recheck);
228
+ if (recheck !== void 0) defect.recheck = recheck;
229
+ return defect;
230
+ }
231
+ /** 净化一位专家的状态。 */
232
+ function sanitizeExpert(value) {
233
+ if (value === null || typeof value !== "object") return void 0;
234
+ const raw = value;
235
+ const key = asText(raw.key, 64);
236
+ if (key === void 0) return void 0;
237
+ const expert = {
238
+ key,
239
+ dimension: asText(raw.dimension, 32) ?? DIMENSION_POOL[0],
240
+ status: raw.status === "running" || raw.status === "done" || raw.status === "failed" || raw.status === "silent" ? raw.status : "pending",
241
+ defectCount: asCount(raw.defectCount) ?? 0
242
+ };
243
+ const childId = asText(raw.childId, 128);
244
+ if (childId !== void 0) expert.childId = childId;
245
+ const reportCount = asCount(raw.reportCount);
246
+ if (reportCount !== void 0) expert.reportCount = reportCount;
247
+ const provider = asText(raw.provider, 64);
248
+ if (provider !== void 0) expert.provider = provider;
249
+ const model = asText(raw.model, 128);
250
+ if (model !== void 0) expert.model = model;
251
+ const role = asText(raw.role, 2e3);
252
+ if (role !== void 0) expert.role = role;
253
+ const note = asText(raw.note, 500);
254
+ if (note !== void 0) expert.note = note;
255
+ return expert;
256
+ }
257
+ function sanitizeStance(value, now) {
258
+ const raw = value !== null && typeof value === "object" ? value : {};
259
+ const text$1 = asText(raw.text, 2e3) ?? "";
260
+ const origin = raw.origin === "manual" || raw.origin === "distilled" ? raw.origin : "edited";
261
+ const distilledAt = asCount(raw.distilledAt);
262
+ return {
263
+ text: text$1,
264
+ origin: text$1 === "" ? "manual" : origin,
265
+ ...distilledAt === void 0 ? {} : { distilledAt },
266
+ updatedAt: asCount(raw.updatedAt) ?? now
267
+ };
268
+ }
269
+ function sanitizeSubject(value) {
270
+ const raw = value !== null && typeof value === "object" ? value : {};
271
+ const source = raw.source === "chat" || raw.source === "file" ? raw.source : "manual";
272
+ const subject = {
273
+ title: asText(raw.title, 200) ?? "未命名评审",
274
+ content: asText(raw.content, 2e4) ?? "",
275
+ source
276
+ };
277
+ const sourceRef = asText(raw.sourceRef, 512);
278
+ if (sourceRef !== void 0) subject.sourceRef = sourceRef;
279
+ return subject;
280
+ }
281
+ function sanitizeBudget(value, fallback) {
282
+ const raw = value !== null && typeof value === "object" ? value : {};
283
+ return {
284
+ maxRounds: asCount(raw.maxRounds) ?? fallback.maxRounds,
285
+ maxTokens: asCount(raw.maxTokens) ?? fallback.maxTokens,
286
+ usedRounds: asCount(raw.usedRounds) ?? 0,
287
+ usedTokens: asCount(raw.usedTokens) ?? 0
288
+ };
289
+ }
290
+ /** 净化一轮历史快照。 */
291
+ function sanitizeSnapshot(value, index, allowed) {
292
+ if (value === null || typeof value !== "object") return void 0;
293
+ const raw = value;
294
+ const rawDefects = Array.isArray(raw.defects) ? raw.defects : [];
295
+ const defects = [];
296
+ for (const [position, item] of rawDefects.entries()) {
297
+ const defect = sanitizeDefect(item, position, allowed);
298
+ if (defect !== void 0) defects.push(defect);
299
+ }
300
+ const intensity = raw.intensity === "quick" || raw.intensity === "deep" ? raw.intensity : "standard";
301
+ const snapshot = {
302
+ pass: asCount(raw.pass) ?? index + 1,
303
+ subject: asText(raw.subject, 2e4) ?? "",
304
+ stanceText: asText(raw.stanceText, 2e3) ?? "",
305
+ dimensions: Array.isArray(raw.dimensions) ? raw.dimensions.filter((item) => typeof item === "string") : [...allowed],
306
+ intensity,
307
+ defects,
308
+ finishedAt: asCount(raw.finishedAt) ?? Date.now()
309
+ };
310
+ const revisedSummary = asText(raw.revisedSummary, 2e3);
311
+ if (revisedSummary !== void 0) snapshot.revisedSummary = revisedSummary;
312
+ return snapshot;
313
+ }
314
+ /**
315
+ * 把任意一份读出来的记录收敛成当前 schema 的**完整可用**记录。
316
+ *
317
+ * @param raw - 磁盘上读到的原始值(可能来自旧版本、可能被人手改坏)。
318
+ * @param reviewId - 目录名(权威 id:即使文件里写的 id 不一致也以目录为准)。
319
+ */
320
+ function normalizeRecord(raw, reviewId) {
321
+ const source = raw !== null && typeof raw === "object" ? raw : {};
322
+ const now = Date.now();
323
+ const schemaVersion = CURRENT_SCHEMA_VERSION;
324
+ const intensity = source.intensity === "quick" || source.intensity === "deep" ? source.intensity : "standard";
325
+ const spec = INTENSITY_SPECS[intensity];
326
+ const dimensions = Array.isArray(source.dimensions) ? source.dimensions.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim().slice(0, 32)).filter((item, index, all) => all.indexOf(item) === index) : [];
327
+ const allowed = dimensions.length > 0 ? dimensions : [...DIMENSION_POOL];
328
+ const defects = [];
329
+ if (Array.isArray(source.defects)) for (const [index, item] of source.defects.entries()) {
330
+ const defect = sanitizeDefect(item, index, allowed);
331
+ if (defect !== void 0) defects.push(defect);
332
+ }
333
+ const experts = [];
334
+ if (Array.isArray(source.experts)) for (const item of source.experts) {
335
+ const expert = sanitizeExpert(item);
336
+ if (expert !== void 0) experts.push(expert);
337
+ }
338
+ const history = [];
339
+ if (Array.isArray(source.history)) for (const [index, item] of source.history.entries()) {
340
+ const snapshot = sanitizeSnapshot(item, index, allowed);
341
+ if (snapshot !== void 0) history.push(snapshot);
342
+ }
343
+ const status = source.status === "attacking" || source.status === "judging" || source.status === "settled" ? source.status : "draft";
344
+ const reviewPass = Math.max(1, asCount(source.reviewPass) ?? 1);
345
+ const maxReviewPass = Math.max(1, asCount(source.maxReviewPass) ?? spec.maxReviewPass);
346
+ const record = {
347
+ schemaVersion,
348
+ reviewId,
349
+ status,
350
+ subject: sanitizeSubject(source.subject),
351
+ stance: sanitizeStance(source.stance, now),
352
+ dimensions: allowed,
353
+ intensity,
354
+ experts,
355
+ defects,
356
+ reviewPass,
357
+ maxReviewPass,
358
+ history,
359
+ budget: sanitizeBudget(source.budget, {
360
+ maxRounds: 6,
361
+ maxTokens: 15e4,
362
+ usedRounds: 0,
363
+ usedTokens: 0
364
+ }),
365
+ startedAt: asCount(source.startedAt) ?? now,
366
+ updatedAt: asCount(source.updatedAt) ?? now
367
+ };
368
+ const sourceMeeting = asText(source.sourceMeeting, 128);
369
+ if (sourceMeeting !== void 0) record.sourceMeeting = sourceMeeting;
370
+ const ownerSessionId = asText(source.ownerSessionId, 128);
371
+ if (ownerSessionId !== void 0) record.ownerSessionId = ownerSessionId;
372
+ const settledAt = asCount(source.settledAt);
373
+ if (settledAt !== void 0) record.settledAt = settledAt;
374
+ return record;
375
+ }
376
+ /** 按输入构造一份全新的记录(status 恒为 `draft`)。 */
377
+ function newRecord(input) {
378
+ const now = input.now ?? Date.now();
379
+ const spec = INTENSITY_SPECS[input.intensity];
380
+ const stanceText = (input.stanceText ?? "").trim().slice(0, 2e3);
381
+ const subject = {
382
+ title: input.title.trim().slice(0, 200) || "未命名评审",
383
+ content: input.content.trim().slice(0, 2e4),
384
+ source: input.source
385
+ };
386
+ if (input.sourceRef !== void 0 && input.sourceRef !== "") subject.sourceRef = input.sourceRef;
387
+ const record = {
388
+ schemaVersion: CURRENT_SCHEMA_VERSION,
389
+ reviewId: input.reviewId,
390
+ status: "draft",
391
+ subject,
392
+ stance: {
393
+ text: stanceText,
394
+ origin: "manual",
395
+ updatedAt: now
396
+ },
397
+ dimensions: input.dimensions.length > 0 ? [...input.dimensions] : [...DIMENSION_POOL],
398
+ intensity: input.intensity,
399
+ experts: [],
400
+ defects: [],
401
+ reviewPass: 1,
402
+ maxReviewPass: spec.maxReviewPass,
403
+ history: [],
404
+ budget: {
405
+ maxRounds: input.maxRounds,
406
+ maxTokens: input.maxTokens,
407
+ usedRounds: 0,
408
+ usedTokens: 0
409
+ },
410
+ startedAt: now,
411
+ updatedAt: now
412
+ };
413
+ if (input.sourceMeeting !== void 0 && input.sourceMeeting !== "") record.sourceMeeting = input.sourceMeeting;
414
+ if (input.ownerSessionId !== void 0 && input.ownerSessionId !== "") record.ownerSessionId = input.ownerSessionId;
415
+ return record;
416
+ }
417
+ /** 读一场评审;缺失或损坏都返回 undefined。 */
418
+ async function readRecord(stateRoot, reviewId) {
419
+ const raw = await readJsonFile(reviewFile(stateRoot, reviewId));
420
+ if (raw === void 0) return void 0;
421
+ return normalizeRecord(raw, reviewId);
422
+ }
423
+ /** 写一场评审(自动刷新 updatedAt)。 */
424
+ async function writeRecord(stateRoot, record) {
425
+ record.updatedAt = Date.now();
426
+ await writeJsonAtomic(reviewFile(stateRoot, record.reviewId), record);
427
+ }
428
+ /** 读发言记录(坏行计数一并返回,不许静默吞掉)。 */
429
+ async function readTranscriptReport(stateRoot, reviewId) {
430
+ const report = await readJsonLines(transcriptFile(stateRoot, reviewId));
431
+ return {
432
+ items: report.items.filter((item) => item !== null && typeof item === "object" && typeof item.content === "string" && item.content.trim() !== ""),
433
+ malformed: report.malformed
434
+ };
435
+ }
436
+ /** 追加一条专家发言。 */
437
+ async function appendTranscript(stateRoot, reviewId, utterance) {
438
+ await appendJsonLine(transcriptFile(stateRoot, reviewId), utterance);
439
+ }
440
+ /**
441
+ * 在锁内读-改-写一场评审。
442
+ *
443
+ * @returns 变更函数的返回值;记录不存在时返回 undefined。
444
+ */
445
+ async function mutateRecord(stateRoot, reviewId, mutate) {
446
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
447
+ const record = await readRecord(stateRoot, reviewId);
448
+ if (record === void 0) return void 0;
449
+ const result = mutate(record);
450
+ await writeRecord(stateRoot, record);
451
+ return result;
452
+ });
453
+ }
454
+ /**
455
+ * 设置一条缺陷的三态(可互切)。
456
+ *
457
+ * 驳回必须给理由 —— 这条约束同时是"用户在想什么"这一信息的唯一来源,
458
+ * 因此它在数据层就被强制,而不是只靠界面拦。
459
+ *
460
+ * @returns true = 确有变更;false = 记录/缺陷不存在,或状态本来就相同。
461
+ */
462
+ async function setDefectStatus(stateRoot, reviewId, defectId, status, rejectReason) {
463
+ const reason = (rejectReason ?? "").trim();
464
+ if (status === "rejected" && reason === "") throw new Error("rejecting a defect requires a non-empty reason");
465
+ return await mutateRecord(stateRoot, reviewId, (record) => {
466
+ const defect = record.defects.find((candidate) => candidate.id === defectId);
467
+ if (defect === void 0) return false;
468
+ if (defect.status === status && (status !== "rejected" || defect.rejectReason === reason)) return false;
469
+ defect.status = status;
470
+ if (status === "rejected") defect.rejectReason = reason.slice(0, 500);
471
+ return true;
472
+ }) === true;
473
+ }
474
+ /** 设置一条核对项的复审核对结论。 */
475
+ async function setRecheckVerdict(stateRoot, reviewId, defectId, verdict, note) {
476
+ return await mutateRecord(stateRoot, reviewId, (record) => {
477
+ const defect = record.defects.find((candidate) => candidate.id === defectId);
478
+ if (defect === void 0) return false;
479
+ const pass = Math.max(1, record.reviewPass - 1);
480
+ const trimmed = (note ?? "").trim();
481
+ defect.recheck = trimmed === "" ? {
482
+ pass,
483
+ verdict
484
+ } : {
485
+ pass,
486
+ verdict,
487
+ note: trimmed.slice(0, 500)
488
+ };
489
+ return true;
490
+ }) === true;
491
+ }
492
+ /** 统计三态。 */
493
+ function countDefects(record) {
494
+ let pending = 0;
495
+ let endorsed = 0;
496
+ let rejected = 0;
497
+ let strikes = 0;
498
+ for (const defect of record.defects) {
499
+ if (defect.status === "endorsed") endorsed += 1;
500
+ else if (defect.status === "rejected") rejected += 1;
501
+ else pending += 1;
502
+ if (defect.strikesStance === true) strikes += 1;
503
+ }
504
+ return {
505
+ total: record.defects.length,
506
+ pending,
507
+ endorsed,
508
+ rejected,
509
+ strikes
510
+ };
511
+ }
512
+ /** 已认定缺陷清单(定稿时固化的 known-flaws)。 */
513
+ function knownFlaws(record) {
514
+ return record.defects.filter((defect) => defect.status === "endorsed");
515
+ }
516
+ /** 归一化用于比较的文本:去空白与标点、统一小写。 */
517
+ function compareKey(text$1) {
518
+ return text$1.replace(/[\s\u3000]+/g, "").replace(/[,。、;:!?"'“”‘’()()\[\]【】《》<>—\-·…,.!?;:]/g, "").toLowerCase();
519
+ }
520
+ /**
521
+ * 两条缺陷是否应视为"同一条"。
522
+ *
523
+ * 两档判据(刻意不同,因为代价不对称):
524
+ * - **完全相同**(规范化后)→ 同一条,**哪怕它很短**。"缺日志"就是"缺日志",
525
+ * 用户驳回过它,就不该第二次弹出来。
526
+ * - **部分包含** → 只有较短一方 ≥ 8 个字符时才敢判。否则"缺日志"会把
527
+ * "缺日志且没有轮转机制" 这类同族但不同的缺陷一起吞掉 —— 漏报一条真缺陷,
528
+ * 比多弹一条重复项严重得多。
529
+ */
530
+ function isSameDefect(a, b) {
531
+ const x = compareKey(a);
532
+ const y = compareKey(b);
533
+ if (x === "" || y === "") return false;
534
+ if (x === y) return true;
535
+ const shorter = x.length <= y.length ? x : y;
536
+ const longer = x.length <= y.length ? y : x;
537
+ if (shorter.length < 8) return false;
538
+ return longer.includes(shorter);
539
+ }
540
+ /** 历史上被用户**驳回**过的全部缺陷内容(用于"不再重复弹同一观点")。 */
541
+ function rejectedContents(record) {
542
+ const out = [];
543
+ for (const snapshot of record.history) for (const defect of snapshot.defects) if (defect.status === "rejected") out.push(defect.content);
544
+ for (const defect of record.defects) if (defect.status === "rejected") out.push(defect.content);
545
+ return out;
546
+ }
547
+ /** 上一轮(history 的最后一轮)中已被用户**认定**的缺陷。 */
548
+ function lastEndorsedDefects(record) {
549
+ const previous = record.history[record.history.length - 1];
550
+ if (previous === void 0) return [];
551
+ return previous.defects.filter((defect) => defect.status === "endorsed");
552
+ }
553
+ /**
554
+ * 复审开轮:把上一轮已认定的缺陷变成本轮的"修复核对项"。
555
+ *
556
+ * 语义(R7):复审**只核对旧账**,不许开新战线。所以本轮的缺陷集合不是清的,
557
+ * 而是被这些核对项替换。
558
+ *
559
+ * **刻意不预置 `verdict`**:开轮时机器不替人下结论。此前这里写死
560
+ * `verdict: 'unfixed'`("未被证明修复之前都算没修"),结果是复审轮一打开,
561
+ * 三态按钮里"未修复"就已经是高亮选中态,而备注框旁的保存按钮会把这个机器默认值
562
+ * 当作人的表态落库(红队评审抓出的真实缺陷)。现在缺席即"尚未判断",由人来选。
563
+ */
564
+ function buildRecheckItems(record) {
565
+ const previousPass = Math.max(1, record.reviewPass - 1);
566
+ return lastEndorsedDefects(record).map((defect) => ({
567
+ id: defect.id,
568
+ utteranceId: defect.utteranceId,
569
+ expertKey: defect.expertKey,
570
+ content: defect.content,
571
+ dimension: defect.dimension,
572
+ ...defect.dimensionFallback === true ? { dimensionFallback: true } : {},
573
+ ...defect.quote === void 0 ? {} : { quote: defect.quote },
574
+ ...defect.evidence === void 0 ? {} : { evidence: defect.evidence },
575
+ status: "endorsed",
576
+ ts: Date.now(),
577
+ seq: defect.seq,
578
+ recheck: { pass: previousPass }
579
+ }));
580
+ }
581
+ /**
582
+ * 过滤掉"上一轮已被驳回"的候选缺陷。
583
+ *
584
+ * 这是 D8 允许的唯一历史用法:提升信噪比。**绝不**把用户偏好告诉红队 ——
585
+ * 模型一旦知道用户历史上总驳回某类观点,就会干脆不提那类问题,系统性削弱
586
+ * 对抗强度,与这个插件存在的理由正好相反。
587
+ */
588
+ function dropRejectedDuplicates(candidates, pastRejected) {
589
+ if (pastRejected.length === 0) return {
590
+ kept: candidates,
591
+ dropped: 0
592
+ };
593
+ const kept = [];
594
+ let dropped = 0;
595
+ for (const candidate of candidates) {
596
+ if (pastRejected.some((rejected) => isSameDefect(candidate.content, rejected))) {
597
+ dropped += 1;
598
+ continue;
599
+ }
600
+ kept.push(candidate);
601
+ }
602
+ return {
603
+ kept,
604
+ dropped
605
+ };
606
+ }
607
+
608
+ //#endregion
609
+ //#region src/expert-events.ts
610
+ /** childId → 评审位置。进程内,理由见模块注释。 */
611
+ const expertIndex = /* @__PURE__ */ new Map();
612
+ /** 记住一位刚被拉起的专家属于哪场评审。 */
613
+ function registerExpertChild(childId, location) {
614
+ if (childId === "") return;
615
+ expertIndex.set(childId, location);
616
+ }
617
+ /** 按子会话 id 查一位专家的评审位置。 */
618
+ function locateExpertChild(childId) {
619
+ return expertIndex.get(childId);
620
+ }
621
+ /**
622
+ * 宽限查询:索引里没有就等一个极短窗口再查一次。
623
+ *
624
+ * ## 为什么需要它(红队在真实评审里抓到的竞态)
625
+ *
626
+ * 索引是在 `spawnExpert` **返回之后**才登记的,而宿主的 `startContinuable` 文档
627
+ * 写明它"resolve at initial idle" —— 也就是说子代理可能在 `spawnExpert` 返回的
628
+ * 那一瞬间就已经跑完第一轮并提交了发言。这几毫秒的窗口里:
629
+ *
630
+ * - `crossfire_report` 查不到归属 → 抛"你不在任何评审里",专家的产出直接丢掉;
631
+ * - `subagent/end` 查不到归属 → 被当作"不属于任何评审"丢弃,专家永远停在 running。
632
+ *
633
+ * 概率极低(需要专家在几毫秒内完成整轮),但代价是**丢掉一整位专家的工作**,而且
634
+ * 它无法复现、无法从外部诊断。所以这里不是掩盖问题,而是把这个窗口等过去。
635
+ *
636
+ * @param childId - 子会话 id。
637
+ * @param graceMs - 等待窗口(默认 400ms,仅在一次查不到时才付出这点时间)。
638
+ */
639
+ async function locateExpertChildWithGrace(childId, graceMs = 400) {
640
+ const immediate = expertIndex.get(childId);
641
+ if (immediate !== void 0) return immediate;
642
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
643
+ return expertIndex.get(childId);
644
+ }
645
+ /** 把一条助手输出里的文本块拼起来(没有则返回空串)。 */
646
+ function blocksToText(blocks) {
647
+ if (blocks === void 0 || blocks.length === 0) return "";
648
+ const parts = [];
649
+ for (const block of blocks) {
650
+ const candidate = block;
651
+ if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
652
+ }
653
+ return parts.join("\n").trim();
654
+ }
655
+ /**
656
+ * 判定一次结束的子代理运行给评审带来了什么。
657
+ *
658
+ * 一个 `completed` 但没有任何文本的运行**不是**硬故障(专家可能只回了一句确认),
659
+ * 所以它有自己的一句措辞 —— 要点是绝不让用户去猜"还在跑"和"已经死了"。
660
+ */
661
+ function describeExpertEnd(info) {
662
+ const text$1 = blocksToText(info.lastAssistantMessage);
663
+ if (text$1 !== "") return {
664
+ text: text$1,
665
+ note: ""
666
+ };
667
+ const reason = info.stopReason ?? "unknown";
668
+ return {
669
+ text: "",
670
+ note: reason === "completed" ? "本轮结束但没有产出内容(可能只回了确认,未见 crossfire_report)" : `本轮未产出内容(stopReason=${reason})`
671
+ };
672
+ }
673
+ /** 一句可读的 provider 请求失败说明。 */
674
+ function describeRequestFailure(provider, failure) {
675
+ const status = typeof failure?.status === "number" ? ` HTTP ${failure.status}` : "";
676
+ const code = typeof failure?.code === "string" && failure.code !== "" ? ` ${failure.code}` : "";
677
+ const message = typeof failure?.message === "string" ? failure.message.trim().slice(0, 200) : "";
678
+ return `请求失败(provider=${provider}${status}${code})${message === "" ? "" : `:${message}`}`;
679
+ }
680
+ /** 按 childId 更新专家的状态与备注。 */
681
+ async function writeExpertNote(location, childId, note, status) {
682
+ await mutateRecord(location.stateRoot, location.reviewId, (record) => {
683
+ const expert = record.experts.find((candidate) => candidate.childId === childId);
684
+ if (expert === void 0) return void 0;
685
+ expert.status = status;
686
+ expert.note = note;
687
+ });
688
+ }
689
+ /**
690
+ * 捕获一次结束的专家运行。
691
+ *
692
+ * 有产出:作为 `auto-capture` 发言落盘,并清掉备注 —— 这样即便专家从没调用过
693
+ * `crossfire_report`,它的内容也仍然进了转录。
694
+ * 无产出:留下一条可读的专家备注。
695
+ */
696
+ async function onExpertEnd(ctx, info) {
697
+ const childId = String(info.id ?? "");
698
+ if (childId === "") return;
699
+ const location = await locateExpertChildWithGrace(childId);
700
+ if (location === void 0) return;
701
+ const outcome = describeExpertEnd(info);
702
+ try {
703
+ if (outcome.text === "") {
704
+ await writeExpertNote(location, childId, outcome.note, "silent");
705
+ return;
706
+ }
707
+ let expertKey = "";
708
+ let pass = 1;
709
+ await mutateRecord(location.stateRoot, location.reviewId, (record) => {
710
+ const expert = record.experts.find((candidate) => candidate.childId === childId);
711
+ if (expert === void 0) return void 0;
712
+ expertKey = expert.key;
713
+ pass = record.reviewPass;
714
+ expert.status = "done";
715
+ expert.note = void 0;
716
+ record.budget.usedTokens += estimateTokens(outcome.text);
717
+ });
718
+ if (expertKey === "") return;
719
+ const utterance = {
720
+ id: randomUUID(),
721
+ expertKey,
722
+ kind: "auto-capture",
723
+ content: outcome.text,
724
+ pass,
725
+ ts: Date.now()
726
+ };
727
+ await appendTranscript(location.stateRoot, location.reviewId, utterance);
728
+ } catch (error) {
729
+ ctx.logger.warn(`crossfire: capturing output of expert ${childId} failed: ${String(error)}`);
730
+ }
731
+ }
732
+ /** 记住每位专家最近一次 provider 失败(可观测性,不做恢复)。 */
733
+ function onRequestError(ctx, payload) {
734
+ const agentId = String(payload.agent?.id ?? "");
735
+ if (agentId === "") return;
736
+ const location = locateExpertChild(agentId);
737
+ if (location === void 0) return;
738
+ writeExpertNote(location, agentId, describeRequestFailure(String(payload.provider ?? ""), payload.failure), "failed").catch((error) => {
739
+ ctx.logger.warn(`crossfire: recording request failure of expert ${agentId} failed: ${String(error)}`);
740
+ });
741
+ }
742
+ /**
743
+ * 订阅两个让专家产出与专家失败变得可见的宿主事件。
744
+ *
745
+ * 刻意非侵入:`subagent/end` 是普通 emit;`agent/request-error` 是 waterfall
746
+ * 监听器,且**永远通过 `next()` 委托**,因此宿主自己的重试/恢复策略完全不受影响
747
+ * —— 本插件只观察。
748
+ */
749
+ function attachExpertEvents(ctx) {
750
+ ctx.on("subagent/end", (info) => {
751
+ onExpertEnd(ctx, info);
752
+ });
753
+ ctx.on("agent/request-error", (payload, next) => {
754
+ try {
755
+ onRequestError(ctx, payload);
756
+ } catch {}
757
+ return next();
758
+ });
759
+ }
760
+
761
+ //#endregion
762
+ //#region src/etag.ts
763
+ /** 计算一个内容摘要,用作 ETag 的取值(不带引号,由调用方按需包)。 */
764
+ function contentDigest(body) {
765
+ return createHash("sha1").update(body).digest("hex");
766
+ }
767
+ /**
768
+ * 发送一个 JSON 响应,并在内容未变时改发 `304 Not Modified`。
769
+ *
770
+ * @param requestHeaders - 入站请求头(读 `if-none-match`)。
771
+ * @returns `true` 表示请求已被处理(200 或 304 都已发出);调用方**不要**再写响应。
772
+ *
773
+ * 两条边界:
774
+ * - **没有 `if-none-match` 时行为与从前逐字相同**(只是多一个 ETag 头),所以这是
775
+ * 可增量上线的改动;客户端不需要同时改。
776
+ * - `cache-control` 用 `no-cache` 而不是 `no-store`:前者允许浏览器存副本但**每次
777
+ * 都必须回源校验**(我们因此能收到 `if-none-match`),语义上依然是"不直接用陈旧
778
+ * 数据"。`no-store` 会让浏览器根本不存,ETag 也就永远不会被回传。
779
+ */
780
+ function sendJsonConditional(requestHeaders, res, payload, status = 200) {
781
+ const body = JSON.stringify(payload);
782
+ const etag = `"${contentDigest(body)}"`;
783
+ const ifNoneMatch = requestHeaders["if-none-match"];
784
+ const sent = Array.isArray(ifNoneMatch) ? ifNoneMatch.join(",") : ifNoneMatch ?? "";
785
+ if (sent !== "" && sent.split(",").map((part) => part.trim()).includes(etag)) {
786
+ res.writeHead(304, {
787
+ etag,
788
+ "cache-control": "no-cache"
789
+ });
790
+ res.end();
791
+ return true;
792
+ }
793
+ res.writeHead(status, {
794
+ "content-type": "application/json; charset=utf-8",
795
+ etag,
796
+ "cache-control": "no-cache"
797
+ });
798
+ res.end(body);
799
+ return true;
800
+ }
801
+
802
+ //#endregion
803
+ //#region src/prompt.ts
804
+ /**
805
+ * 注入全局系统提示的用法段落(纯文本模块,体积可被测试断言)。
806
+ *
807
+ * 与圆桌会议的 `prompt.ts` 关键差异:这里**没有**主持人编排协议。圆桌要求模型
808
+ * 按轮次统筹、转述、聚合;针锋的推进由卡片按钮经 RPC 直推 host 状态机完成,
809
+ * 模型不是编排者。所以这段文字交代的是:针锋是什么、四条不可越界的边界、工具
810
+ * 清单与调用顺序,以及**什么时候不该用它**。
811
+ *
812
+ * @module dsh-plugin-crossfire/prompt
813
+ */
814
+ /** 七步编排与那一条不可越界的规则。 */
815
+ const USAGE_STEPS = [
816
+ "1. `crossfire_open` —— 把待验证对象摆上台(标题 + 正文 + 维度 + 强度,可选立场)。状态 `draft`。",
817
+ "2. `crossfire_stance`(可选但强烈建议)—— 写入\"用户的立场与理由\"。用户自己写最好;他没写就**从对话提炼**并传 `distilled=true`,但**绝不虚构**他没表达过的立场(虚构的立场等于给红队一个假靶子)。有了立场,每条红队缺陷都必须回应其中一条理由,并标注是否被打穿。用户随时可以在卡片上改。",
818
+ "3. `crossfire_attack` —— 按维度拉起只读红队专家。状态 `attacking`。",
819
+ "4. `crossfire_collect` —— 把专家发言拆成逐条可裁决的缺陷(自动过滤过程叙述、归一化维度、去掉用户上轮驳回过的重复项)。状态 `judging`。",
820
+ "5. **等人在卡片上逐条表态**。这一步没有工具可代劳。",
821
+ "6. `crossfire_finish` —— 定稿,把已认定缺陷固化为 known-flaws 清单并写入历史。状态 `settled`。",
822
+ "7. `crossfire_recheck` —— 复审:只核对上一轮已认定的缺陷是否修复,**不许开新战线**;`crossfire_export` 导出 Markdown。"
823
+ ];
824
+ /**
825
+ * 当前版本的用法段落。
826
+ */
827
+ function usageSectionText() {
828
+ return [
829
+ "## 针锋 Crossfire(对抗式评审)",
830
+ "",
831
+ "针锋只做一件事:把一个**已经定稿**的成果或方案摆上台,让若干红队专家从指定维度分头攻击,把攻击拆成逐条可裁决的缺陷。",
832
+ "",
833
+ "四条边界(不要越界,越界即破坏了它存在的意义):",
834
+ "",
835
+ "1. **表态永远由人做。** 不存在\"代表认定缺陷\"的工具,也不该替你认定。认定 / 驳回只能由用户在卡片上操作。",
836
+ "2. **针锋不产出方案、不改代码、不自动修复。** 它是验收器,不是执行器。",
837
+ "3. **红队专家只读。** 它们的工具被限制为只读检索(read / read_image / glob / grep)加一个 `crossfire_report`:只挑错,不动工作区。",
838
+ "4. **不许把用户偏好喂给红队。** 历史表态记录只用于去重与复审递进,绝不用来塑造专家对\"用户喜欢什么\"的预期。",
839
+ "",
840
+ "### 编排(七步,每步可单独重试)",
841
+ "",
842
+ ...USAGE_STEPS,
843
+ "",
844
+ "### 什么时候该用 / 不该用",
845
+ "",
846
+ "- **该用**:一个方案、一段设计、一份清单**已经定稿**,你或用户想知道它在哪些地方站不住。",
847
+ "- **该用**:需要\"逐条可追溯的判断\"而不是一句\"看起来还行\"——每条缺陷都有来源专家、维度与证据分级,用户逐条认定或驳回。",
848
+ "- **不该用**:还处在\"要做什么\"的阶段。针锋不产出方案,此时应该先正常讨论再定稿。",
849
+ "- **不该用**:需要多 agent 分工把活干完。那是协作型团队的场景;针锋的专家不写代码、不产出交付物。",
850
+ "",
851
+ "### 两条容易搞错的细节",
852
+ "",
853
+ "- `crossfire_attack` 返回之后专家**仍在跑**:它是后台子代理,`crossfire_collect` 之前请确认它们已经提交(卡片上会显示每位专家的状态)。",
854
+ "- 用户在卡片上的表态**随时可能发生**,也可能一直不发生。不要在用户没表态时反复调用 `crossfire_finish` 催结论——未完成的清单就是未完成,那是刻意的。"
855
+ ].join("\n");
856
+ }
857
+
858
+ //#endregion
859
+ //#region src/preferences.ts
860
+ /**
861
+ * 解析 DeepSeek Harness home。
862
+ *
863
+ * 与宿主 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome` 优先级对齐:
864
+ * `configured` > `$DSH_HOME` > `~/.dsh`。第一级做不到(它是宿主进程内部传入的
865
+ * 显式覆盖,插件拿不到),但环境变量与默认值这两级完全一致。
866
+ *
867
+ * 刻意**不**静态 import `@deepseek-ai/dsh-home-paths`:那会把它变成加载门禁,
868
+ * 而本模块只是要一个路径。
869
+ */
870
+ function dshHomeDir() {
871
+ const fromEnv = process.env.DSH_HOME?.trim();
872
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
873
+ return join(homedir(), ".dsh");
874
+ }
875
+ /** 偏好文件的绝对路径(DSH home 下的 `crossfire/preferences.json`)。 */
876
+ function preferencesFilePath() {
877
+ return join(dshHomeDir(), "crossfire", "preferences.json");
878
+ }
879
+ /** 没有任何持久化数据时的偏好。`defaultIntensity` 由调用方按插件 Config 覆盖。 */
880
+ function defaultPreferences(defaultIntensity) {
881
+ return {
882
+ defaultIntensity,
883
+ maxRounds: 6,
884
+ maxTokens: 15e4,
885
+ expertMaxTokens: 0,
886
+ expertMaxOpinions: 3,
887
+ customDimensions: [],
888
+ rolePresets: [],
889
+ squads: [],
890
+ dismissedHints: {}
891
+ };
892
+ }
893
+ /** 自定义维度的硬上限(客户端与服务端共用;服务端截断,客户端提前拦截)。 */
894
+ const CUSTOM_DIMENSION_MAX = 12;
895
+ /** 单条维度名的最大长度。 */
896
+ const DIMENSION_NAME_MAX = 12;
897
+ /**
898
+ * 把任意输入收敛成合法的自定义维度清单。
899
+ *
900
+ * 三条规则:空/超长丢弃;与固定池重名的丢弃(池内的不算"自定义",否则界面上
901
+ * 会出现两个"安全");去重且截断到上限。
902
+ */
903
+ function sanitizeCustomDimensions(value) {
904
+ if (!Array.isArray(value)) return [];
905
+ const pool = DIMENSION_POOL;
906
+ const out = [];
907
+ for (const item of value) {
908
+ const raw = typeof item === "string" ? item.trim() : "";
909
+ if (raw === "" || raw.length > DIMENSION_NAME_MAX) continue;
910
+ if (pool.includes(raw) || out.includes(raw)) continue;
911
+ out.push(raw);
912
+ if (out.length >= CUSTOM_DIMENSION_MAX) break;
913
+ }
914
+ return out;
915
+ }
916
+ /** 角色预设条目的硬上限。 */
917
+ const ROLE_PRESET_MAX = 50;
918
+ /** 阵容条目的硬上限。 */
919
+ const SQUAD_MAX = 20;
920
+ /** 单个阵容的成员上限。 */
921
+ const SQUAD_MEMBER_MAX = 8;
922
+ /** 名称/角色说明的最大长度。 */
923
+ const TEXT_MAX = 120;
924
+ /** 把任意输入收敛成合法的角色预设清单(缺 name/role 的条目直接丢弃)。 */
925
+ function sanitizeRolePresets(value) {
926
+ if (!Array.isArray(value)) return [];
927
+ const out = [];
928
+ const seen = /* @__PURE__ */ new Set();
929
+ for (const item of value) {
930
+ const entry = item !== null && typeof item === "object" ? item : {};
931
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
932
+ const name$1 = typeof entry.name === "string" ? entry.name.trim().slice(0, TEXT_MAX) : "";
933
+ const role = typeof entry.role === "string" ? entry.role.trim().slice(0, TEXT_MAX) : "";
934
+ if (id === "" || name$1 === "" || role === "" || seen.has(id)) continue;
935
+ seen.add(id);
936
+ const preset = {
937
+ id,
938
+ name: name$1,
939
+ role
940
+ };
941
+ if (typeof entry.provider === "string" && entry.provider.trim() !== "") preset.provider = entry.provider.trim();
942
+ if (typeof entry.model === "string" && entry.model.trim() !== "") preset.model = entry.model.trim();
943
+ out.push(preset);
944
+ if (out.length >= ROLE_PRESET_MAX) break;
945
+ }
946
+ return out;
947
+ }
948
+ /** 阵容成员 key 的合法化(小写、去空白;空则丢弃)。 */
949
+ function sanitizeMemberKey(value) {
950
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
951
+ return /^[a-z0-9][a-z0-9_-]*$/.test(raw) ? raw.slice(0, 32) : "";
952
+ }
953
+ /** 把任意输入收敛成合法的阵容清单。 */
954
+ function sanitizeSquads(value) {
955
+ if (!Array.isArray(value)) return [];
956
+ const out = [];
957
+ const seen = /* @__PURE__ */ new Set();
958
+ for (const item of value) {
959
+ const entry = item !== null && typeof item === "object" ? item : {};
960
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
961
+ const name$1 = typeof entry.name === "string" ? entry.name.trim().slice(0, TEXT_MAX) : "";
962
+ if (id === "" || name$1 === "" || seen.has(id)) continue;
963
+ const rawMembers = Array.isArray(entry.members) ? entry.members : [];
964
+ const members = [];
965
+ const memberKeys = /* @__PURE__ */ new Set();
966
+ for (const rawMember of rawMembers) {
967
+ const member = rawMember !== null && typeof rawMember === "object" ? rawMember : {};
968
+ const key = sanitizeMemberKey(member.key);
969
+ if (key === "" || memberKeys.has(key)) continue;
970
+ memberKeys.add(key);
971
+ const sanitized = {
972
+ key,
973
+ role: typeof member.role === "string" ? member.role.trim().slice(0, TEXT_MAX) : ""
974
+ };
975
+ if (typeof member.provider === "string" && member.provider.trim() !== "") sanitized.provider = member.provider.trim();
976
+ if (typeof member.model === "string" && member.model.trim() !== "") sanitized.model = member.model.trim();
977
+ members.push(sanitized);
978
+ if (members.length >= SQUAD_MEMBER_MAX) break;
979
+ }
980
+ if (members.length === 0) continue;
981
+ seen.add(id);
982
+ out.push({
983
+ id,
984
+ name: name$1,
985
+ members
986
+ });
987
+ if (out.length >= SQUAD_MAX) break;
988
+ }
989
+ return out;
990
+ }
991
+ /**
992
+ * 四个数字字段共用的净化规则:**0 = 不限制**,只接受非负整数,并夹到上限
993
+ * 避免误填一个天文数字。
994
+ *
995
+ * 为什么四个字段必须同规则:设置页对这四个字段都写着「0 = 不限制」。如果
996
+ * `maxTokens` 单独要求 ≥1000,用户填 0 就会被静默拒绝并退回旧值 —— 界面显示
997
+ * 一个数、实际生效另一个数,是最难被用户理解的一类失败。
998
+ */
999
+ function clampLimit(value) {
1000
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1001
+ const n = Math.floor(value);
1002
+ if (n < 0) return void 0;
1003
+ return Math.min(n, 5e5);
1004
+ }
1005
+ /**
1006
+ * 把任意一层输入叠加到基准偏好上。
1007
+ *
1008
+ * 与 {@link defaultPreferences} 的分工:`base` 是"当前生效值",`layer` 是"这次
1009
+ * 提交的部分更新"。缺字段一律沿用 base,非法字段一律退回 base(而不是默认值)
1010
+ * —— 这样一次错误的提交不会把用户的其它设置顺手清掉。
1011
+ */
1012
+ function normalizePreferences(layer, base) {
1013
+ const raw = layer !== null && typeof layer === "object" ? layer : {};
1014
+ const pick = (key, fallback, accept) => {
1015
+ if (!(key in raw)) return fallback;
1016
+ const accepted = accept(raw[key]);
1017
+ return accepted === void 0 ? fallback : accepted;
1018
+ };
1019
+ const asIntensity = (value) => value === "quick" || value === "standard" || value === "deep" ? value : void 0;
1020
+ return {
1021
+ defaultIntensity: pick("defaultIntensity", base.defaultIntensity, asIntensity),
1022
+ maxRounds: pick("maxRounds", base.maxRounds, clampLimit),
1023
+ maxTokens: pick("maxTokens", base.maxTokens, clampLimit),
1024
+ expertMaxTokens: pick("expertMaxTokens", base.expertMaxTokens, clampLimit),
1025
+ expertMaxOpinions: pick("expertMaxOpinions", base.expertMaxOpinions, clampLimit),
1026
+ customDimensions: pick("customDimensions", base.customDimensions, sanitizeCustomDimensions),
1027
+ rolePresets: pick("rolePresets", base.rolePresets, sanitizeRolePresets),
1028
+ squads: pick("squads", base.squads, sanitizeSquads),
1029
+ dismissedHints: pick("dismissedHints", base.dismissedHints, sanitizeDismissedHints)
1030
+ };
1031
+ }
1032
+ /** 已关闭说明的键长度上限(键是代码里的常量,不是用户输入)。 */
1033
+ const DISMISSED_HINT_KEY_MAX = 64;
1034
+ /** 已关闭说明的条数上限 —— 防止有人用 prefs.patch 往里塞无上限的垃圾。 */
1035
+ const DISMISSED_HINT_MAX = 100;
1036
+ /**
1037
+ * 把任意输入收敛成"已关闭说明"的键集。
1038
+ *
1039
+ * 规则:键必须是非空短字符串、值必须是 `true`(只记"关掉了"这一事实,不存别的东西)、
1040
+ * 去重并截断。存 `true` 而不是存时间戳,是因为界面只需要回答"要不要显示这一段"。
1041
+ */
1042
+ function sanitizeDismissedHints(value) {
1043
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
1044
+ const out = {};
1045
+ let count = 0;
1046
+ for (const [key, flag] of Object.entries(value)) {
1047
+ if (flag !== true) continue;
1048
+ const trimmed = key.trim();
1049
+ if (trimmed === "" || trimmed.length > DISMISSED_HINT_KEY_MAX) continue;
1050
+ if (out[trimmed] === true) continue;
1051
+ out[trimmed] = true;
1052
+ count += 1;
1053
+ if (count >= DISMISSED_HINT_MAX) break;
1054
+ }
1055
+ return out;
1056
+ }
1057
+ /** 本次可用的完整维度清单:固定池在前,自定义在后。 */
1058
+ function effectiveDimensions(prefs) {
1059
+ return [...DIMENSION_POOL, ...prefs.customDimensions];
1060
+ }
1061
+ /**
1062
+ * 创建偏好存储。
1063
+ *
1064
+ * 读盘是**惰性且异步**的(构造后立刻开始),因此 `get()` 在首次读盘完成前返回
1065
+ * 基准值而不是阻塞宿主启动 —— 这也是它比 `ctx.inject(['settings'])` 更稳的地方:
1066
+ * 没有可选能力可缺。
1067
+ */
1068
+ function createPreferenceStore(options) {
1069
+ const file = options.file ?? preferencesFilePath();
1070
+ let current = options.base;
1071
+ let writeQueue = Promise.resolve();
1072
+ const listeners = /* @__PURE__ */ new Set();
1073
+ const load = (async () => {
1074
+ try {
1075
+ const text$1 = await readFile(file, "utf8");
1076
+ current = normalizePreferences(JSON.parse(text$1), options.base);
1077
+ } catch (error) {
1078
+ if (error.code !== "ENOENT") options.log?.(`crossfire: preferences at ${file} unreadable; using defaults`, error);
1079
+ }
1080
+ })();
1081
+ const publish = (next) => {
1082
+ current = next;
1083
+ for (const listener of listeners) try {
1084
+ listener(next);
1085
+ } catch (error) {
1086
+ options.log?.("crossfire: a preference listener threw", error);
1087
+ }
1088
+ };
1089
+ return {
1090
+ get: () => current,
1091
+ ready: () => load.then(() => void 0),
1092
+ watch(listener) {
1093
+ listeners.add(listener);
1094
+ return () => {
1095
+ listeners.delete(listener);
1096
+ };
1097
+ },
1098
+ async update(patch) {
1099
+ await load;
1100
+ const next = normalizePreferences(patch, current);
1101
+ publish(next);
1102
+ writeQueue = writeQueue.then(async () => {
1103
+ try {
1104
+ await mkdir(dirname(file), { recursive: true });
1105
+ await writeTextAtomic(file, JSON.stringify(next, null, 2));
1106
+ } catch (error) {
1107
+ options.log?.(`crossfire: failed to persist preferences to ${file}`, error);
1108
+ }
1109
+ });
1110
+ await writeQueue;
1111
+ return next;
1112
+ }
1113
+ };
1114
+ }
1115
+
1116
+ //#endregion
1117
+ //#region src/dimensions.ts
1118
+ /**
1119
+ * 固定池维度的同义词表(中英混合,含常见越界说法)。
1120
+ *
1121
+ * 顺序有意义:一个词命中多个维度时取**先定义**的那个(例如「迁移」同时像
1122
+ * 「数据」与「兼容」,这里判给「数据」,因为 schema/字段迁移是数据面的事)。
1123
+ */
1124
+ const DIMENSION_ALIASES = {
1125
+ 安全: [
1126
+ "security",
1127
+ "secure",
1128
+ "安全",
1129
+ "安全审查",
1130
+ "权限",
1131
+ "鉴权",
1132
+ "授权",
1133
+ "隐私",
1134
+ "密钥",
1135
+ "凭据",
1136
+ "注入",
1137
+ "漏洞",
1138
+ "攻击面",
1139
+ "越权",
1140
+ "加密",
1141
+ "合规风险"
1142
+ ],
1143
+ 数据: [
1144
+ "data",
1145
+ "数据",
1146
+ "schema",
1147
+ "字段",
1148
+ "表结构",
1149
+ "迁移",
1150
+ "一致性",
1151
+ "持久化",
1152
+ "备份",
1153
+ "丢失",
1154
+ "序列化",
1155
+ "编码"
1156
+ ],
1157
+ 交互: [
1158
+ "ux",
1159
+ "ui",
1160
+ "交互",
1161
+ "体验",
1162
+ "可用性",
1163
+ "入口",
1164
+ "提示",
1165
+ "文案",
1166
+ "无障碍",
1167
+ "键盘",
1168
+ "布局",
1169
+ "反馈"
1170
+ ],
1171
+ 性能: [
1172
+ "performance",
1173
+ "perf",
1174
+ "性能",
1175
+ "延迟",
1176
+ "吞吐",
1177
+ "内存",
1178
+ "cpu",
1179
+ "缓存",
1180
+ "并发",
1181
+ "扩展性",
1182
+ "规模",
1183
+ "耗时"
1184
+ ],
1185
+ 兼容: [
1186
+ "compat",
1187
+ "compatibility",
1188
+ "兼容",
1189
+ "版本",
1190
+ "平台",
1191
+ "windows",
1192
+ "macos",
1193
+ "linux",
1194
+ "浏览器",
1195
+ "宿主",
1196
+ "升级",
1197
+ "跨平台"
1198
+ ],
1199
+ 流程: [
1200
+ "process",
1201
+ "流程",
1202
+ "发布",
1203
+ "回滚",
1204
+ "部署",
1205
+ "ci",
1206
+ "持续集成",
1207
+ "测试",
1208
+ "验收",
1209
+ "灰度",
1210
+ "评审流程",
1211
+ "协作",
1212
+ "职责"
1213
+ ],
1214
+ 反例: [
1215
+ "counter",
1216
+ "counterexample",
1217
+ "反例",
1218
+ "边界",
1219
+ "极端",
1220
+ "失败路径",
1221
+ "异常",
1222
+ "空值",
1223
+ "假设",
1224
+ "前提",
1225
+ "反直觉",
1226
+ "误导"
1227
+ ]
1228
+ };
1229
+ /** 规范化:去空白、去包裹引号、小写。 */
1230
+ function normalizeKey(value) {
1231
+ return value.replace(/[\s\u3000]+/g, "").replace(/^[「【"'“”『\[((]+|[」】"'“”』\]))]+$/g, "").toLowerCase();
1232
+ }
1233
+ /**
1234
+ * 把一个自由文本维度收敛到 `allowed` 里。
1235
+ *
1236
+ * @param raw - 模型/用户给出的原始维度(任意值,非字符串会被当作缺失)。
1237
+ * @param allowed - 本次评审实际采用的维度清单(固定池 + 自定义)。
1238
+ * @returns 归一化结果;`allowed` 为空时退回固定池。
1239
+ */
1240
+ function resolveDimension(raw, allowed) {
1241
+ const pool = allowed.length > 0 ? allowed : DIMENSION_POOL;
1242
+ const source = typeof raw === "string" ? raw : "";
1243
+ const key = normalizeKey(source);
1244
+ if (key === "") return {
1245
+ dimension: pool[0] ?? "其他",
1246
+ fallback: true,
1247
+ how: "default"
1248
+ };
1249
+ for (const candidate of pool) if (source.trim() === candidate) return {
1250
+ dimension: candidate,
1251
+ fallback: false,
1252
+ how: "exact"
1253
+ };
1254
+ for (const candidate of pool) if (normalizeKey(candidate) === key) return {
1255
+ dimension: candidate,
1256
+ fallback: false,
1257
+ how: "normalized"
1258
+ };
1259
+ for (const [poolDimension, aliases] of Object.entries(DIMENSION_ALIASES)) {
1260
+ if (!pool.includes(poolDimension)) continue;
1261
+ if (!aliases.some((alias) => normalizeKey(alias) === key)) continue;
1262
+ return {
1263
+ dimension: poolDimension,
1264
+ fallback: poolDimension !== source.trim(),
1265
+ how: "alias"
1266
+ };
1267
+ }
1268
+ for (const candidate of pool) {
1269
+ const candidateKey = normalizeKey(candidate);
1270
+ if (candidateKey === "") continue;
1271
+ if (key.includes(candidateKey) || candidateKey.includes(key)) return {
1272
+ dimension: candidate,
1273
+ fallback: true,
1274
+ how: "contains"
1275
+ };
1276
+ }
1277
+ return {
1278
+ dimension: pool[0] ?? "其他",
1279
+ fallback: true,
1280
+ how: "default"
1281
+ };
1282
+ }
1283
+ /**
1284
+ * 把一组候选维度收敛成"本次实际采用"的清单。
1285
+ *
1286
+ * 用于 `crossfire_open`:用户给的维度可能重复、越界、或全是垃圾。结果永远非空
1287
+ * (最差退回固定池全集),因为"没有维度"会让分发逻辑无处安放。
1288
+ */
1289
+ function resolveDimensions(raw, customAllowed) {
1290
+ const pool = [...DIMENSION_POOL];
1291
+ for (const extra of customAllowed) if (!pool.includes(extra)) pool.push(extra);
1292
+ if (!Array.isArray(raw)) return pool;
1293
+ const out = [];
1294
+ for (const item of raw) {
1295
+ const { dimension } = resolveDimension(item, pool);
1296
+ if (!out.includes(dimension)) out.push(dimension);
1297
+ }
1298
+ return out.length > 0 ? out : pool;
1299
+ }
1300
+
1301
+ //#endregion
1302
+ //#region src/noise.ts
1303
+ /**
1304
+ * 专家发言的噪音过滤(缺陷收集的第一道闸门)。
1305
+ *
1306
+ * ## 为什么必须有这一层
1307
+ *
1308
+ * 圆桌的真实数据里,下面这类**过程叙述**被当成缺陷收进了裁决列表:
1309
+ *
1310
+ * `[当前状态] 已收到主持人任务,完成对 17 项清单的四维红队审查;按…`
1311
+ * `[当前状态] 主持人再次派发任务;第一轮 3 条观点已提交(第 7 项语义矛盾 / 批次 10–…`
1312
+ *
1313
+ * 它们有两个来源:
1314
+ * - 宿主的 `subagent/end` 自动补捕(`kind: 'auto-capture'`):专家没主动上报结果就
1315
+ * 结束了,宿主把它的最后一段话整段当成发言收下 —— 通常只是收尾寒暄或进度叙述;
1316
+ * - 专家自己写的进度汇报:以 `[当前状态]`/`[进度]` 之类的方括号标记开头。
1317
+ *
1318
+ * 它们不是缺陷,却会挤进用户的表态面:用户被迫对"我收到了任务"点认定或驳回。
1319
+ * 判定在这里集中成一处,**缺陷收集侧只调 `isNoiseUtterance`**,规则不外泄。
1320
+ *
1321
+ * @module dsh-plugin-crossfire/noise
1322
+ */
1323
+ /**
1324
+ * 行首的进度/状态标记。命中即视为过程叙述,而不是缺陷。
1325
+ *
1326
+ * 只认**方括号标记在行首**:真正的缺陷正文不会以 `[当前状态]` 开头,而
1327
+ * "第 3 步会打印 `[状态]`"这种出现在正文中间的说法不该被误杀 —— 所以
1328
+ * 这里刻意不做全文搜索。
1329
+ */
1330
+ const PROGRESS_MARKER = /^\s*[\[【]\s*(当前状态|状态|进度|任务|当前任务|汇报|确认)\s*[\]】]/;
1331
+ /**
1332
+ * 极短发言里的客套话。**只在发言足够短时才生效**(见 rule 4)。
1333
+ *
1334
+ * 「开始」「已完成确认」这类词单独看毫无信息量;但一条真正的缺陷正文
1335
+ * 完全可能包含它们(例如「开始运行前没有做密钥校验」),所以这张表
1336
+ * 必须与"长度 ≤ 24"联合判定,不能单独使用。
1337
+ */
1338
+ const ACK_PHRASES = [
1339
+ "收到",
1340
+ "明白",
1341
+ "好的",
1342
+ "已收到",
1343
+ "了解",
1344
+ "开始",
1345
+ "已完成确认"
1346
+ ];
1347
+ /** 被认为"极短"的长度上限(按去空白后的字符数)。 */
1348
+ const ACK_MAX_LENGTH = 24;
1349
+ /** 去空白:所有空白(含全角空格)都折叠掉,便于长度与开头判定。 */
1350
+ function squeeze(value) {
1351
+ return value.replace(/[\s\u3000]+/g, "");
1352
+ }
1353
+ /**
1354
+ * 命中的噪音规则名(诊断/测试用;不是噪音时返回 `undefined`)。
1355
+ *
1356
+ * 按顺序判定,命中即返回:`auto-capture` → `progress-report` → `empty`
1357
+ * → `acknowledgement`。
1358
+ */
1359
+ function noiseReason(kind, content) {
1360
+ if (kind === "auto-capture") return "auto-capture";
1361
+ if (PROGRESS_MARKER.test(content)) return "progress-report";
1362
+ const squeezed = squeeze(content);
1363
+ if (squeezed === "") return "empty";
1364
+ if (squeezed.length <= ACK_MAX_LENGTH && ACK_PHRASES.some((phrase) => squeezed.includes(phrase))) return "acknowledgement";
1365
+ }
1366
+ /** 这条发言是否应当被排除在缺陷收集之外。 */
1367
+ function isNoiseUtterance(kind, content) {
1368
+ return noiseReason(kind, content) !== void 0;
1369
+ }
1370
+
1371
+ //#endregion
1372
+ //#region src/split.ts
1373
+ /** 拆分工具的 JSON schema(防线①)。导出以便测试断言 schema 形状。 */
1374
+ const SPLIT_TOOL = {
1375
+ name: "split_crossfire_defect",
1376
+ description: "把一条红队专家发言拆成 1~3 条独立缺陷(每条缺陷只含一个问题)。",
1377
+ parameters: {
1378
+ type: "object",
1379
+ additionalProperties: false,
1380
+ properties: { defects: {
1381
+ type: "array",
1382
+ minItems: 1,
1383
+ maxItems: 3,
1384
+ items: {
1385
+ type: "object",
1386
+ additionalProperties: false,
1387
+ properties: {
1388
+ content: {
1389
+ type: "string",
1390
+ description: "单条缺陷的完整表述。"
1391
+ },
1392
+ quote: {
1393
+ type: "string",
1394
+ description: "发言原文中与该缺陷对应的子串(可选,必须逐字来自发言原文)。"
1395
+ },
1396
+ dimension: {
1397
+ type: "string",
1398
+ description: "缺陷所属维度。必须逐字取自给定清单 allowed 中的一项,不得自创。"
1399
+ },
1400
+ strikes_stance: {
1401
+ type: "boolean",
1402
+ description: "该缺陷是否打穿了用户在「立场」里给出的某条理由。"
1403
+ },
1404
+ evidence: {
1405
+ type: "object",
1406
+ description: "证据分级(C1):代码/bug 类缺陷填 repro(附可复现步骤 1. 2. 3.);设计类缺陷填 argument(附论证链,不编造伪复现步骤)。无法给出证据时省略该字段。",
1407
+ additionalProperties: false,
1408
+ properties: {
1409
+ kind: {
1410
+ type: "string",
1411
+ enum: ["repro", "argument"],
1412
+ description: "repro=可复现步骤(代码/bug 类);argument=论证链(设计类)。"
1413
+ },
1414
+ text: {
1415
+ type: "string",
1416
+ description: "证据正文:复现步骤(编号 1. 2. 3.)或论证链,≤600 字符。"
1417
+ }
1418
+ },
1419
+ required: ["kind", "text"]
1420
+ }
1421
+ },
1422
+ required: ["content", "dimension"]
1423
+ }
1424
+ } },
1425
+ required: ["defects"]
1426
+ }
1427
+ };
1428
+ const SPLIT_TIMEOUT_MS = 6e4;
1429
+ /** 调用 LLM 一次,收集工具调用参数(或文本 JSON)为字符串;失败返回 null。 */
1430
+ async function callSplitLlm(llm, config, system, userText) {
1431
+ const controller = new AbortController();
1432
+ const timer = setTimeout(() => controller.abort(), SPLIT_TIMEOUT_MS);
1433
+ try {
1434
+ const deltas = [];
1435
+ let assembled = null;
1436
+ const stream = llm.stream({
1437
+ provider: config.provider,
1438
+ model: config.model,
1439
+ messages: [{
1440
+ id: randomUUID(),
1441
+ role: "user",
1442
+ content: [{
1443
+ type: "text",
1444
+ text: userText
1445
+ }],
1446
+ source: { kind: "user" }
1447
+ }],
1448
+ system,
1449
+ tools: [SPLIT_TOOL],
1450
+ temperature: 0,
1451
+ maxTokens: 1024,
1452
+ signal: controller.signal
1453
+ });
1454
+ for await (const chunk of stream) {
1455
+ const raw$1 = chunk;
1456
+ if (raw$1.type === "tool-call-delta" && typeof raw$1.argumentsDelta === "string") deltas.push(raw$1.argumentsDelta);
1457
+ else if (raw$1.type === "block-end" && raw$1.block?.type === "tool-call" && typeof raw$1.block.arguments === "string") assembled = raw$1.block.arguments;
1458
+ else if (raw$1.type === "text" && typeof raw$1.delta === "string") deltas.push(raw$1.delta);
1459
+ else if (raw$1.type === "text" && typeof raw$1.text === "string") deltas.push(raw$1.text);
1460
+ else if (raw$1.type === "text-delta" && typeof raw$1.text === "string") deltas.push(raw$1.text);
1461
+ }
1462
+ const raw = (assembled ?? deltas.join("")).trim();
1463
+ return raw === "" ? null : raw;
1464
+ } catch {
1465
+ return null;
1466
+ } finally {
1467
+ clearTimeout(timer);
1468
+ }
1469
+ }
1470
+ /**
1471
+ * 本地启发式兜底拆分(零 token、确定性):红队专家发言通常按
1472
+ * "**观点 N(指向 X,维度Y)**" 的固定结构分条,直接按该标记切段,
1473
+ * 并从段首括号里提取维度标签。找不到任何观点标记时返回 null,
1474
+ * 由调用方整条兜底(seq=0)。
1475
+ */
1476
+ const VIEWPOINT_MARKER = /(?:\*{1,2})?\s*观点\s*(\d+)\s*[((]([^))]*)[))]/g;
1477
+ const TAIL_SECTION = /\[(?:核心产出|下一步建议)[^\]]*\]/;
1478
+ /** 括号里可能出现的维度引导词,去掉它们才剩下维度名。 */
1479
+ const DIMENSION_LABEL_WORD = /^(维度|方面|角度|类别|类型)/;
1480
+ /**
1481
+ * 从段首括号的文字里提取维度,并交给 `resolveDimension` 归一化。
1482
+ *
1483
+ * 上一版的兜底把括号里的圆圈数字(①)直接当成了维度名,产出 `维度②`
1484
+ * 这种垃圾值 —— 真实数据显示 5 份 review.json 里全是这种编号。现在改成:
1485
+ * 按分隔符拆成若干候选,取**第一个能被归一化到具体维度**的那段;全都不行时
1486
+ * 传空串给 `resolveDimension`,由它给出默认维度(并标 fallback)。
1487
+ */
1488
+ function dimensionOfSegmentHead(head, allowed) {
1489
+ const pieces = head.split(/[,,、;;||/\s]+/).filter((piece) => piece.trim() !== "");
1490
+ for (const piece of pieces) {
1491
+ const candidate = piece.trim().replace(DIMENSION_LABEL_WORD, "").replace(/[::]+$/, "");
1492
+ if (candidate === "") continue;
1493
+ const resolved = resolveDimension(candidate, allowed);
1494
+ if (resolved.how !== "default") return {
1495
+ dimension: resolved.dimension,
1496
+ fallback: resolved.fallback
1497
+ };
1498
+ }
1499
+ const fallback = resolveDimension("", allowed);
1500
+ return {
1501
+ dimension: fallback.dimension,
1502
+ fallback: fallback.fallback
1503
+ };
1504
+ }
1505
+ function splitByMarkers(content, allowed) {
1506
+ const trimmed = content.replace(/\s+/g, " ").trim();
1507
+ if (trimmed === "") return null;
1508
+ const marks = [];
1509
+ VIEWPOINT_MARKER.lastIndex = 0;
1510
+ let m;
1511
+ while ((m = VIEWPOINT_MARKER.exec(trimmed)) !== null) marks.push({
1512
+ start: m.index,
1513
+ end: m.index + m[0].length,
1514
+ label: m[1] ?? "",
1515
+ head: m[2] ?? ""
1516
+ });
1517
+ if (marks.length === 0) return null;
1518
+ const lines = [];
1519
+ for (let i = 0; i < marks.length; i++) {
1520
+ const mark = marks[i];
1521
+ if (mark === void 0) continue;
1522
+ const next = marks[i + 1];
1523
+ const rawEnd = next !== void 0 ? next.start : trimmed.length;
1524
+ let raw = trimmed.slice(mark.end, rawEnd);
1525
+ const tail = raw.search(TAIL_SECTION);
1526
+ if (tail !== -1) raw = raw.slice(0, tail);
1527
+ const text$1 = raw.replace(/^[\s*::]+/, "").replace(/\s+/g, " ").trim();
1528
+ if (text$1 === "") continue;
1529
+ const { dimension, fallback } = dimensionOfSegmentHead(mark.head, allowed);
1530
+ lines.push({
1531
+ content: `观点 ${mark.label}(${mark.head}):${text$1}`,
1532
+ dimension,
1533
+ ...fallback ? { dimensionFallback: true } : {},
1534
+ quote: void 0
1535
+ });
1536
+ }
1537
+ return lines.length > 0 ? lines : null;
1538
+ }
1539
+ /**
1540
+ * 拆分一条发言为若干独立缺陷;任何失败返回 null(调用方整条兜底)。
1541
+ * quote 与发言原文做规范化子串校验(防线③),不匹配即丢弃。
1542
+ * dimension 一律过 `resolveDimension`(落库值必须 ∈ allowed)。
1543
+ */
1544
+ async function splitUtterance(llm, config, expertKey, content, allowed) {
1545
+ const trimmed = content.replace(/\s+/g, " ").trim();
1546
+ if (trimmed === "") return null;
1547
+ const raw = await callSplitLlm(llm, config, "你是 Crossfire「针锋」评审的缺陷拆分器。把下面一条红队专家发言拆成 1~3 条独立缺陷,每条缺陷只包含一个问题,不得发明原文没有的内容。dimension 必须**逐字取自给定清单 allowed 中的一项**,不得自创标签、不得输出编号;若该缺陷用「立场」中的某条理由打穿了用户的说法,把 strikes_stance 设为 true。对每条缺陷给出证据分级(C1):代码/bug 类缺陷的 evidence.kind=repro 并写可复现步骤(1. 2. 3.);设计类缺陷的 evidence.kind=argument 并写论证链——禁止为设计类缺陷编造\"运行某命令即可复现\"这类伪复现步骤。无法从原文提取证据时省略 evidence 字段。必须调用 split_crossfire_defect 工具返回结构化结果。", `本次评审采用的维度清单 allowed(dimension 只能取其中之一):${allowed.length > 0 ? allowed.join(" / ") : "(未限定,按通用维度判断)"}\n\n专家 ${expertKey} 的发言:\n${trimmed}`);
1548
+ if (raw !== null) {
1549
+ let parsed;
1550
+ try {
1551
+ parsed = JSON.parse(raw);
1552
+ } catch {
1553
+ parsed = void 0;
1554
+ }
1555
+ const list = parsed?.defects;
1556
+ if (Array.isArray(list) && list.length > 0) {
1557
+ const lines = [];
1558
+ for (const item of list) {
1559
+ const obj = item;
1560
+ const line = typeof obj?.content === "string" ? obj.content.replace(/\s+/g, " ").trim() : "";
1561
+ if (line === "") continue;
1562
+ let quote$1;
1563
+ if (typeof obj?.quote === "string") {
1564
+ const candidate = obj.quote.replace(/\s+/g, " ").trim();
1565
+ if (candidate !== "" && candidate.length <= 120 && trimmed.includes(candidate)) quote$1 = candidate;
1566
+ }
1567
+ const resolved = resolveDimension(obj?.dimension, allowed);
1568
+ let evidence;
1569
+ const ev = obj?.evidence;
1570
+ if (ev !== null && typeof ev === "object" && (ev.kind === "repro" || ev.kind === "argument") && typeof ev.text === "string") {
1571
+ const text$1 = ev.text.replace(/\s+/g, " ").trim();
1572
+ if (text$1 !== "") evidence = {
1573
+ kind: ev.kind,
1574
+ text: text$1.slice(0, 600)
1575
+ };
1576
+ }
1577
+ lines.push({
1578
+ content: line,
1579
+ quote: quote$1,
1580
+ dimension: resolved.dimension,
1581
+ ...obj?.strikes_stance === true ? { strikesStance: true } : {},
1582
+ ...resolved.fallback ? { dimensionFallback: true } : {},
1583
+ evidence
1584
+ });
1585
+ if (lines.length >= Math.max(1, config.maxOpinions)) break;
1586
+ }
1587
+ if (lines.length > 0) return lines;
1588
+ }
1589
+ }
1590
+ return splitByMarkers(trimmed, allowed);
1591
+ }
1592
+
1593
+ //#endregion
1594
+ //#region src/engine.ts
1595
+ /**
1596
+ * 按强度档与维度算出这一轮要拉起谁。
1597
+ *
1598
+ * 纯函数(可单测):同样一份记录 + 同一份 roster 永远得到同一份阵容。规模上限按
1599
+ * `record.dimensions` 的顺序截断,所以**用户排在前面的维度优先** —— 这是可预期的,
1600
+ * 而不是随机丢人。
1601
+ *
1602
+ * 人选来源两条,按优先级:
1603
+ * 1. `roster` 里 `key` 等于维度名的那一条(精确指定,最符合直觉);
1604
+ * 2. 否则按**顺序**取 roster 的第 i 条(i = 维度序号)—— 用户不必把 key 改名成
1605
+ * 维度名也能用上一份现成的阵容;
1606
+ * 3. 都取不到时只写维度与状态(专家继承主持人路由、无补充定位),与从前完全一致。
1607
+ *
1608
+ * @param roster - 可选人选来源;缺席或为空时行为与从前逐字一致。
1609
+ */
1610
+ function planExperts(record, maxExperts, roster = []) {
1611
+ const spec = INTENSITY_SPECS[record.intensity];
1612
+ const experts = [];
1613
+ let dimensionIndex = 0;
1614
+ for (const dimension of record.dimensions) {
1615
+ const count = Math.max(1, spec.expertsPerDimension);
1616
+ const matched = roster.find((entry) => entry.key === dimension) ?? roster[dimensionIndex];
1617
+ dimensionIndex += 1;
1618
+ for (let index = 0; index < count; index += 1) {
1619
+ const expert = {
1620
+ key: count === 1 ? dimension : `${dimension}-${index + 1}`,
1621
+ dimension,
1622
+ status: "pending",
1623
+ defectCount: 0
1624
+ };
1625
+ if (matched !== void 0) {
1626
+ if (matched.role !== void 0 && matched.role.trim() !== "") expert.role = matched.role.trim();
1627
+ if (matched.provider !== void 0 && matched.model !== void 0) {
1628
+ expert.provider = matched.provider;
1629
+ expert.model = matched.model;
1630
+ }
1631
+ }
1632
+ experts.push(expert);
1633
+ }
1634
+ }
1635
+ return experts.length > maxExperts ? experts.slice(0, maxExperts) : experts;
1636
+ }
1637
+ /**
1638
+ * 有界并发的 `map`(零依赖)。
1639
+ *
1640
+ * 为什么需要它:拆分一条发言要打一次 LLM(`split.ts` 每次 60s 超时),而此前是
1641
+ * **串行** `await` —— 一场 7 位专家、每人报告 3 次的评审 = 21 次串行往返,收集动作
1642
+ * 的墙钟时间 ≈ 21 × 单次往返(十秒级),期间界面只有一个禁用的"收集"按钮,既没有
1643
+ * 进度也没有取消(红队评审抓出的性能缺陷)。
1644
+ *
1645
+ * 并发上限刻意**不高**:这些请求打的是同一个 provider,并发拉满只会撞速率限制、
1646
+ * 反而更慢,而且要留出余量给界面自己的请求。
1647
+ *
1648
+ * 结果顺序与输入**逐位对应**(按下标回填,而不是按完成先后),所以拆出来的缺陷顺序
1649
+ * 与串行版本完全一致 —— 这是可测的,见 `test/engine.test.mjs`。
1650
+ */
1651
+ async function collectWithConcurrency(items, limit, worker) {
1652
+ const results = new Array(items.length);
1653
+ let cursor = 0;
1654
+ const width = Math.max(1, Math.min(limit, items.length));
1655
+ const workers = Array.from({ length: width }, async () => {
1656
+ while (cursor < items.length) {
1657
+ const index = cursor;
1658
+ cursor += 1;
1659
+ const item = items[index];
1660
+ if (item === void 0) continue;
1661
+ results[index] = await worker(item, index);
1662
+ }
1663
+ });
1664
+ await Promise.all(workers);
1665
+ return results;
1666
+ }
1667
+ /**
1668
+ * 把本轮专家发言收成逐条可裁决的缺陷。
1669
+ *
1670
+ * 五道处理,顺序有意义:
1671
+ * 1. **只收本轮**(`utterance.pass === record.reviewPass`);
1672
+ * 2. **幂等**(`utteranceId` 已收集过就跳过 —— 拆分后 defect.id 会变,不能用作去重键);
1673
+ * 3. **噪音过滤**(`auto-capture` 与进度汇报;真实数据里前者曾被当成缺陷收进裁决面);
1674
+ * 4. **拆分**(LLM 三道防线优先 → 本地标记切分兜底 → 整条兜底 `seq = 0`);
1675
+ * 5. **维度归一化 + 历史去重**(落库维度必须 ∈ 本次采用维度;上轮被驳回的观点不再弹)。
1676
+ *
1677
+ * 复审轮(`reviewPass > 1` 且已有历史)**不新增缺陷**:那一轮唯一的任务是核对旧账。
1678
+ */
1679
+ async function collectDefects(input) {
1680
+ const { record, transcript, llm } = input;
1681
+ const allowed = record.dimensions;
1682
+ const collectedUtterances = new Set(record.defects.map((defect) => defect.utteranceId));
1683
+ const isRecheckRound = record.reviewPass > 1 && record.history.length > 0;
1684
+ const candidates = [];
1685
+ let skippedNoise = 0;
1686
+ let alreadyCollected = 0;
1687
+ const pending = [];
1688
+ for (const utterance of transcript) {
1689
+ if (utterance.pass !== record.reviewPass) continue;
1690
+ if (collectedUtterances.has(utterance.id)) {
1691
+ alreadyCollected += 1;
1692
+ continue;
1693
+ }
1694
+ if (isNoiseUtterance(utterance.kind, utterance.content)) {
1695
+ skippedNoise += 1;
1696
+ continue;
1697
+ }
1698
+ if (isRecheckRound) continue;
1699
+ const content = utterance.content.replace(/\s+/g, " ").trim();
1700
+ if (content === "") continue;
1701
+ pending.push({
1702
+ utterance,
1703
+ content
1704
+ });
1705
+ }
1706
+ /**
1707
+ * 拆一条发言。返回 `usedLlm` 而不是直接改计数器 —— 并发下那会变成共享可变状态。
1708
+ */
1709
+ const collectOne = async (item, _index, activeLlm) => {
1710
+ if (activeLlm === void 0) return {
1711
+ lines: splitByMarkers(item.content, allowed),
1712
+ usedLlm: false
1713
+ };
1714
+ try {
1715
+ return {
1716
+ lines: await splitUtterance(activeLlm, input.splitConfig, item.utterance.expertKey, item.content, allowed),
1717
+ usedLlm: true
1718
+ };
1719
+ } catch {
1720
+ return {
1721
+ lines: splitByMarkers(item.content, allowed),
1722
+ usedLlm: false
1723
+ };
1724
+ }
1725
+ };
1726
+ const splitResults = await collectWithConcurrency(pending, input.llmConcurrency ?? input.splitConfig.llmConcurrency ?? 4, (item, index) => collectOne(item, index, llm));
1727
+ const splitAttempts = splitResults.filter((item) => item.usedLlm).length;
1728
+ for (const [position, item] of pending.entries()) {
1729
+ const lines = splitResults[position]?.lines ?? splitByMarkers(item.content, allowed);
1730
+ const utterance = item.utterance;
1731
+ if (lines === null) {
1732
+ candidates.push({
1733
+ id: utterance.id,
1734
+ utteranceId: utterance.id,
1735
+ expertKey: utterance.expertKey,
1736
+ content: item.content,
1737
+ dimension: allowed[0] ?? "其他",
1738
+ dimensionFallback: true,
1739
+ status: "pending",
1740
+ ts: utterance.ts,
1741
+ seq: 0
1742
+ });
1743
+ continue;
1744
+ }
1745
+ for (const [index, line] of lines.entries()) {
1746
+ const resolved = resolveDimension(line.dimension, allowed);
1747
+ const defect = {
1748
+ id: `${utterance.id}#${index + 1}`,
1749
+ utteranceId: utterance.id,
1750
+ expertKey: utterance.expertKey,
1751
+ content: line.content,
1752
+ dimension: resolved.dimension,
1753
+ status: "pending",
1754
+ ts: utterance.ts,
1755
+ seq: index + 1
1756
+ };
1757
+ if (line.dimensionFallback === true || resolved.fallback) defect.dimensionFallback = true;
1758
+ if (line.quote !== void 0) defect.quote = line.quote;
1759
+ if (line.evidence !== void 0) defect.evidence = line.evidence;
1760
+ if (line.strikesStance === true) defect.strikesStance = true;
1761
+ candidates.push(defect);
1762
+ }
1763
+ }
1764
+ const { kept, dropped } = dropRejectedDuplicates(candidates, rejectedContents(record));
1765
+ return {
1766
+ added: kept,
1767
+ droppedDuplicates: dropped,
1768
+ skippedNoise,
1769
+ splitAttempts,
1770
+ alreadyCollected,
1771
+ malformedLines: input.malformed
1772
+ };
1773
+ }
1774
+ /** 把用户偏好里的阵容与角色预设合成一份 roster。 */
1775
+ function rosterFrom(input) {
1776
+ const squads = input.squads ?? [];
1777
+ const chosen = (input.squadId === void 0 ? void 0 : squads.find((squad) => squad.id === input.squadId)) ?? (input.squadId === void 0 ? squads[0] : void 0);
1778
+ if (chosen !== void 0 && chosen.members.length > 0) return [...chosen.members];
1779
+ return (input.rolePresets ?? []).map((preset) => ({
1780
+ key: preset.key.trim() !== "" ? preset.key : preset.name ?? "",
1781
+ ...preset.role === void 0 ? {} : { role: preset.role },
1782
+ ...preset.provider === void 0 ? {} : { provider: preset.provider },
1783
+ ...preset.model === void 0 ? {} : { model: preset.model }
1784
+ }));
1785
+ }
1786
+ /**
1787
+ * 把收集结果并入记录(原地修改;调用方负责落盘)。
1788
+ */
1789
+ function applyCollected(record, added) {
1790
+ record.defects = [...record.defects, ...added];
1791
+ for (const expert of record.experts) expert.defectCount = record.defects.filter((defect) => defect.expertKey === expert.key).length;
1792
+ }
1793
+
1794
+ //#endregion
1795
+ //#region src/redteam.ts
1796
+ /**
1797
+ * 红队专家允许使用的工具(全局工具白名单,只有列出的这些保持可见)。
1798
+ *
1799
+ * 只有 5 个:`read`、`read_image`、`glob`、`grep` 四个只读检索工具,
1800
+ * 外加唯一一个写入口 `crossfire_report`(写的是评审自己的 transcript,
1801
+ * 不是工作区)。
1802
+ *
1803
+ * **刻意不给** `write` / `edit` / `bash` / `pwsh` / `web_search` / `web_fetch` /
1804
+ * `todo_write` 等。理由(这是本插件最本质的安全边界,不是配置偷懒):
1805
+ * - **只挑错、不动工作区**:评审一旦能改文件,"提缺陷"就会滑向"顺手修好",
1806
+ * 用户拿到的就不再是待裁决的缺陷清单,而是一份未经同意的改动。红队与
1807
+ * "智能体团队"成员最本质的区别就在这里:团队成员有写权限、以交付为目标;
1808
+ * 红队没有写权限、以推翻为目标。
1809
+ * - **不联网**:外部检索会把评审从"针对眼前这份成果"漂移成"泛泛的行业意见",
1810
+ * 也会引入不可复现的外部依据;针锋要的是针对具体位置的论证与复现。
1811
+ * - **不写待办**:红队没有执行阶段,`todo_write` 只会让它开始规划"怎么修",
1812
+ * 直接违反"不产出方案"。
1813
+ *
1814
+ * 想放开任何一项之前,先回答:放开的那个工具能让专家**证明**一个缺陷,还是
1815
+ * 只能让它**动手**?只有前者可以进这张表。
1816
+ */
1817
+ const EXPERT_TOOL_ALLOW = [
1818
+ "read",
1819
+ "read_image",
1820
+ "glob",
1821
+ "grep",
1822
+ "crossfire_report"
1823
+ ];
1824
+ /**
1825
+ * 红队专家禁止使用的工具(黑名单)。
1826
+ *
1827
+ * 这里列的是全部**主持人专属**的 `crossfire_*` 工具:开评审、发起攻击、收集
1828
+ * 缺陷、定稿、复审、导出。这些都是主持人的编排权,专家一旦能调
1829
+ * `crossfire_collect`/`crossfire_finish`,就等于自己给自己结案,"表态由人做"
1830
+ * 当场失效。
1831
+ *
1832
+ * 为什么白名单之外还要再来一份黑名单:两者是**与**关系(必须同时在白名单里
1833
+ * 且不在黑名单里才可见)。将来有人为了加一个只读工具而改动
1834
+ * {@link EXPERT_TOOL_ALLOW} 时,不会顺带把编排权放开 —— 黑名单这一层与被修改
1835
+ * 的那一行无关,能拦住"删一项白名单等于多一份权限"的误操作。
1836
+ *
1837
+ * **注意**:`crossfire_report` 是**专家自己的**发言工具,绝不能列进本表。
1838
+ */
1839
+ const EXPERT_TOOL_DENY = [
1840
+ "crossfire_open",
1841
+ "crossfire_attack",
1842
+ "crossfire_collect",
1843
+ "crossfire_finish",
1844
+ "crossfire_recheck",
1845
+ "crossfire_export",
1846
+ "crossfire_stance"
1847
+ ];
1848
+ /**
1849
+ * 专家的工具限制。
1850
+ *
1851
+ * 每次返回**新的数组实例**,且以 `readonly` 声明:宿主侧可能在拿到后排序或增删,
1852
+ * 共用模块级数组会让一次改动污染后续所有专家。黑名单恒为全量主持人工具,
1853
+ * 与白名单内容无关 —— 它是与被修改的那一行**无关**的第二道闸。
1854
+ */
1855
+ function expertToolRestriction() {
1856
+ return {
1857
+ allow: [...EXPERT_TOOL_ALLOW],
1858
+ deny: [...EXPERT_TOOL_DENY]
1859
+ };
1860
+ }
1861
+ /**
1862
+ * 报告信封的格式说明(persona 与 welcome 共用同一份文本)。
1863
+ *
1864
+ * 为什么格式必须固定到这个程度:缺陷要能被**逐条裁决**,而裁决面是按条渲染的。
1865
+ * 本地兜底切分器(模型没按格式走时的最后一道网)认的就是 `观点 N(指向 …,
1866
+ * 维度Y):` 这个行首标记。标记缺失时整段发言只能作为一条缺陷收进列表,用户
1867
+ * 就得面对一团散文去点"认定/驳回" —— 针锋存在的意义当场归零。
1868
+ *
1869
+ * @param maxOpinions - 最多几条(0 = 不限制),写进语气会随上限收紧。
1870
+ */
1871
+ function reportEnvelopeSpec(maxOpinions) {
1872
+ return [
1873
+ "【报告格式】",
1874
+ "你的发言会被逐条切分并交给用户裁决,所以格式是硬要求,不是风格建议。",
1875
+ "每一条缺陷都必须以这样一个标记作为**第 1 行**:",
1876
+ "",
1877
+ " 观点 N(指向 <具体位置>,维度<维度名>):<这一条缺陷的完整表述>",
1878
+ "",
1879
+ "其中:",
1880
+ "- `N` 从 1 开始连续编号;",
1881
+ "- `<具体位置>` 必须是可定位的东西(文件路径、行号、函数名、章节号、界面元素名、字段名),",
1882
+ " 不能写\"整体\"\"全文\"\"部分实现\"这类无法定位的说法;",
1883
+ "- `维度<维度名>` 写分配给你的维度名,且**只能**写这一个。",
1884
+ "",
1885
+ "标记之后,紧跟下面两行里的**恰好一行**,以及(仅当存在用户立场时)再跟一行立场结论:",
1886
+ "",
1887
+ " 证据(repro):1. … 2. … 3. …(编号步骤,真的可以照着做)",
1888
+ " 证据(argument):…(论证链:前提 → 推理 → 结论)",
1889
+ " 针对立场:打穿 / 未打穿",
1890
+ "",
1891
+ `完整发言${maxOpinions > 0 ? `最多 ${maxOpinions} 条` : "条数不限制(但每一条都必须真的站得住,凑数等于自损可信度)"}。多条缺陷之间空一行,每条都从自己的「观点 N(…):」标记重新开始。`,
1892
+ "",
1893
+ "再次强调:**必须**用 `观点 N(指向 X,维度Y)` 作为每一条的开头。",
1894
+ "否则整段发言会被当成一整条缺陷,用户无法逐条裁决,你这一轮的产出等于作废。"
1895
+ ].join("\n");
1896
+ }
1897
+ /** 非空判定:兼容 `undefined` 与纯空白("没写立场"比"写了几个空格"更常见)。 */
1898
+ function hasText(value) {
1899
+ return value !== void 0 && value.trim() !== "";
1900
+ }
1901
+ /** 截断到上限,并明确标注被截断(截断不能静默发生)。 */
1902
+ function truncate(text$1, max) {
1903
+ const trimmed = text$1.trim();
1904
+ if (trimmed.length <= max) return trimmed;
1905
+ return `${trimmed.slice(0, max)}\n…(此处被截断,需要更多上下文请用只读工具读取原文)`;
1906
+ }
1907
+ /** 逗号连接(维度不长,用中文顿号更好读)。 */
1908
+ function joinList(items) {
1909
+ return items.join("、");
1910
+ }
1911
+ /** 维度清单兜底:空清单退化为"没有指定"而不是空串(空串会读成语义不明的破句)。 */
1912
+ function dimensionList(dimensions) {
1913
+ return dimensions.length > 0 ? joinList(dimensions) : "(未指定)";
1914
+ }
1915
+ /**
1916
+ * 生成红队专家的 persona(系统级人设)。
1917
+ *
1918
+ * 九条硬约束逐条落在文本里(身份 / 单维度 / 不产出方案 / 不要客套 / 一条一缺陷 /
1919
+ * 证据分级 / 立场对攻 / 工具边界 / 不许迎合)。这些约束的共同目标只有一个:
1920
+ * 让产出的东西**可以被判定**,而不是听起来很有道理。
1921
+ */
1922
+ function expertPersona(input) {
1923
+ const { reviewId, dimension, title, stance, dimensions, maxOpinions } = input;
1924
+ const constraints = [
1925
+ {
1926
+ title: "身份",
1927
+ body: `你是「针锋」评审里负责「${dimension}」维度的红队专家。你的唯一任务是把这份成果打穿。`,
1928
+ why: "你在这里没有中立观察者的位置,也不是来帮忙的:你是最想推翻它的那个人。",
1929
+ anti: "任何\"它大体上是成立的\"这类判断都不是你的产出。"
1930
+ },
1931
+ {
1932
+ title: "只攻击你这一个维度",
1933
+ body: `审稿范围只有「${dimension}」。本次评审的其余维度已经分给了别的专家:${dimensionList(dimensions)}。`,
1934
+ why: "重复覆盖等于浪费一次评审,越界还会让别人负责的维度没人认真盯。",
1935
+ anti: "越界到别人维度的观点会被直接丢弃 —— 那部分话等于没说过。"
1936
+ },
1937
+ {
1938
+ title: "不产出方案",
1939
+ body: "不要给出改进建议、不要写代码、不要重写方案、不要提\"可以这样改\"。",
1940
+ why: "你只负责指出\"哪里站不住\";修不修、怎么修是别人的事。",
1941
+ anti: "一旦你开始给方案,这个评审就从\"验收\"变成了\"再来一版\",而\"再来一版\"永远比挑错舒服。"
1942
+ },
1943
+ {
1944
+ title: "不要客套",
1945
+ body: "禁止开场白、禁止总结、禁止致谢、禁止\"总体不错\"\"思路很好\"这类评价。",
1946
+ why: "这些字对用户没有任何可裁决的信息量。",
1947
+ anti: "只输出缺陷本身。你的发言里出现夸奖,就是不合格的发言。"
1948
+ },
1949
+ {
1950
+ title: "每条发言只含一个缺陷",
1951
+ body: maxOpinions > 0 ? `一条缺陷一条发言,最多 ${maxOpinions} 条。` : "一条缺陷一条发言,条数不限制。",
1952
+ why: "缺陷要能被逐条裁决;一条里塞进两个缺陷,用户认定其中一个就没法继续操作。",
1953
+ anti: "\"另外还有一个问题是…\"这种顺手追加,必须拆成新的「观点 N」。"
1954
+ },
1955
+ {
1956
+ title: "证据分级(重要)",
1957
+ body: "代码/行为类缺陷给 `repro`:可复现步骤,用 1. 2. 3. 编号,且必须真的可执行;设计/取舍类缺陷给 `argument`:论证链。",
1958
+ why: "两类缺陷的成立方式根本不同:前者靠\"照做就能看到\",后者靠\"跟着推理推不掉\"。",
1959
+ anti: "**禁止为设计类缺陷编造\"运行某命令即可复现\"这类伪复现步骤** —— 那是最廉价的红队噪音,它唯一的作用是让一条没想清楚的批评看起来像有证据。宁可只给论证链,也不要伪造步骤。"
1960
+ },
1961
+ {
1962
+ title: "立场对攻",
1963
+ body: hasText(stance) ? "用户已经写下了自己的立场与理由(见下方【用户立场与理由】)。你**必须逐条回应其中的每一条理由**,并在对应缺陷里标注是否**打穿**了它(`strikes_stance: true/false`)。" : "本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。",
1964
+ why: "针锋的\"锋\"就在这里:用户的理由要真的被检验,而不是被绕过去。",
1965
+ anti: "打不穿就如实标 `false`,不要为了讨好而假装打穿;不要只挑最弱的那条理由回应,其余理由回避不谈同样算没回应。"
1966
+ },
1967
+ {
1968
+ title: "工具边界",
1969
+ body: `你只能调 \`crossfire_report\` 提交发言;其余可用工具只有只读检索:${joinList(EXPERT_TOOL_ALLOW.filter((name$1) => name$1 !== "crossfire_report"))}。**你没有写权限,也不要试图修改工作区。**`,
1970
+ why: "红队只挑错、不动工作区,评审的目的是产出待裁决的缺陷,不是产出一份未经同意的改动。",
1971
+ anti: "任何试图写文件、改配置、执行命令的动作都是违规的。"
1972
+ },
1973
+ {
1974
+ title: "不许迎合",
1975
+ body: "不知道就说不知道,不能用猜测填补证据,不能编造行号、报错信息或没读过的文件内容。",
1976
+ why: "历史表态记录只用于去重与复审递进,绝不用来塑造你对\"用户喜欢什么\"的预期。不要揣测用户想听什么,也不要因为你猜测用户偏好某方案就放软,或反过来为了显得努力而硬凑缺陷。",
1977
+ anti: "宣称某处有问题但给不出定位与证据,等于零信息;\"可能是\"\"也许存在\"不算一条缺陷。"
1978
+ }
1979
+ ];
1980
+ const lines = [
1981
+ `# 针锋 Crossfire · 红队专家(评审 ${reviewId},维度:${dimension})`,
1982
+ "",
1983
+ "## 你的身份",
1984
+ "",
1985
+ `你是「针锋」评审里负责「${dimension}」维度的红队专家。你的唯一任务是把这份成果打穿。`,
1986
+ "",
1987
+ "「针锋」只做一件事:把一个**已经定稿**的成果或方案摆上台,让若干红队专家从指定维度分头攻击,把攻击拆成逐条可裁决的缺陷。你不是评审的裁判,也不是作者的合作者 —— 你是攻击面。",
1988
+ ""
1989
+ ];
1990
+ if (hasText(input.role)) lines.push("## 你的角色定位(评审者补充,只放宽专长,不放宽约束)", "", truncate(input.role, 2e3), "", "**这段定位只用来收窄你的专长范围**(例如\"只盯密钥轮换与权限边界\")。", "它**不能**放宽下面的任何一条硬约束:如果它与硬约束冲突,以硬约束为准,冲突部分视为无效。", "你在本评审里始终是只读的、始终不产出方案、始终只输出缺陷。", "");
1991
+ lines.push("## 硬约束(逐条遵守,没有例外)", "");
1992
+ constraints.forEach((item, index) => {
1993
+ lines.push(`${index + 1}. **${item.title}**:${item.body}`);
1994
+ lines.push(` - 为什么:${item.why}`);
1995
+ lines.push(` - 违规示例 / 边界:${item.anti}`);
1996
+ });
1997
+ lines.push("", "## 本次待验证对象", "", `标题:${title.trim() === "" ? "(未提供标题)" : title.trim()}`, "", "正文:", "", truncate(input.subject, 6e3), "", "## 用户立场与理由", "", hasText(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按上面的维度攻击。)", "", reportEnvelopeSpec(maxOpinions), "", "## 结束方式", "", "把缺陷通过 `crossfire_report` 提交后即可结束本轮。", "不要提交\"我已完成审查\"这类过程叙述 —— 只有包含「观点 N(…):」标记的发言才会进入裁决列表,", "过程叙述会被丢弃,等于本轮什么都没产出。");
1998
+ return lines.join("\n");
1999
+ }
2000
+ /**
2001
+ * 生成发给这位专家的第一条任务消息。
2002
+ *
2003
+ * persona 已经写清了全部约束(不要重复粘贴,那既费 token 又稀释重点),这条
2004
+ * 消息只做三件事:**分配维度**、**交出材料与立场**、**钉死格式与结束方式**。
2005
+ */
2006
+ function expertWelcome(input) {
2007
+ const { reviewId, dimension, title, subject, stance, dimensions, maxOpinions } = input;
2008
+ return [
2009
+ `[任务] 针锋评审 ${reviewId} · 你负责「${dimension}」维度`,
2010
+ "",
2011
+ `目标:把下面这份成果从「${dimension}」这一个维度打穿,只输出缺陷。`,
2012
+ "",
2013
+ `你负责的维度:${dimension}`,
2014
+ `本次评审的其他维度(不归你管,越界观点会被丢弃):${dimensionList(dimensions.filter((item) => item !== dimension))}`,
2015
+ "",
2016
+ "输入 1 —— 待验证对象:",
2017
+ `标题:${title.trim() === "" ? "(未提供标题)" : title.trim()}`,
2018
+ "正文:",
2019
+ truncate(subject, 6e3),
2020
+ "",
2021
+ "输入 2 —— 用户立场与理由(每条都必须被逐条回应并标注是否打穿):",
2022
+ hasText(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。)",
2023
+ "",
2024
+ "约束:",
2025
+ `- 只攻击「${dimension}」维度,不要越界到别人负责的维度;`,
2026
+ "- 不产出方案、不给改进建议、不写代码;",
2027
+ "- 不要开场白、不要总结、不要夸奖;",
2028
+ "- 一条缺陷一条发言;",
2029
+ "- 代码/行为类缺陷给 `repro`(编号且真的可执行),设计/取舍类缺陷给 `argument`(论证链),**禁止为设计类缺陷编造伪复现步骤**;",
2030
+ hasText(stance) ? "- 逐条回应用户立场里的每一条理由,打不穿就如实标 `false`;" : "- 不要虚构用户立场;",
2031
+ "- 只用只读工具核查,你没有写权限,不要试图修改工作区。",
2032
+ "",
2033
+ "期望产出:",
2034
+ reportEnvelopeSpec(maxOpinions),
2035
+ "",
2036
+ "结束方式:",
2037
+ "提交完就结束。不要写\"我已完成审查\"这类过程叙述 —— 它会被丢弃,只有包含「观点 N(…):」标记的发言才会进入用户的裁决列表。"
2038
+ ].join("\n");
2039
+ }
2040
+
2041
+ //#endregion
2042
+ //#region src/members.ts
2043
+ /** 专家子代理的 label 前缀(持久化为该子会话的创建标签,便于在宿主侧辨认)。 */
2044
+ const EXPERT_LABEL_PREFIX = "crossfire-expert:";
2045
+ /**
2046
+ * 拉起一位红队专家,返回它的子会话 id。
2047
+ *
2048
+ * 失败一律抛错且**不落任何状态** —— 调用方负责把 `expert.status` 记成 `failed`
2049
+ * 并把消息写进 `expert.note`,而不是留下一个"看起来在跑其实根本没起来"的专家。
2050
+ */
2051
+ async function spawnExpert(ctx, config, record, expert, parent, signal, limits = {}) {
2052
+ const provider = ctx.subagents.getProvider(config.provider);
2053
+ if (provider === void 0) throw new Error(`crossfire: no subagent provider "${config.provider}" is registered (available: ${ctx.subagents.list().join(", ") || "none"}) — check that the subagent provider row (e.g. subagent-spawn) is mounted in the composition`);
2054
+ if (provider.prepareContinuable === void 0) throw new Error(`crossfire: provider "${config.provider}" does not support continuable experts`);
2055
+ if (!provider.capabilities.persona) throw new Error(`crossfire: provider "${config.provider}" cannot apply an expert persona`);
2056
+ if (!provider.capabilities.toolFilter) throw new Error(`crossfire: provider "${config.provider}" cannot restrict expert tools (the read-only guarantee needs this)`);
2057
+ const input = {
2058
+ reviewId: record.reviewId,
2059
+ dimension: expert.dimension,
2060
+ title: record.subject.title,
2061
+ subject: record.subject.content,
2062
+ stance: record.stance.text,
2063
+ dimensions: record.dimensions,
2064
+ maxOpinions: limits.maxOpinions ?? 0,
2065
+ ...expert.role === void 0 ? {} : { role: expert.role }
2066
+ };
2067
+ const agentOptions = {};
2068
+ if (expert.provider !== void 0 && expert.model !== void 0) {
2069
+ agentOptions.provider = expert.provider;
2070
+ agentOptions.model = expert.model;
2071
+ }
2072
+ if (limits.maxTokens !== void 0 && limits.maxTokens > 0) agentOptions.maxTokens = limits.maxTokens;
2073
+ const started = await ctx.subagents.startContinuable({
2074
+ provider: config.provider,
2075
+ label: `${EXPERT_LABEL_PREFIX}${record.reviewId}:${expert.key}`,
2076
+ request: {
2077
+ prompt: [{
2078
+ type: "text",
2079
+ text: expertWelcome(input)
2080
+ }],
2081
+ parent,
2082
+ persona: expertPersona(input),
2083
+ toolFilter: expertToolRestriction(),
2084
+ ...Object.keys(agentOptions).length > 0 ? { agentOptions } : {},
2085
+ ...config.maxDepth === void 0 ? {} : { maxDepth: config.maxDepth }
2086
+ },
2087
+ signal
2088
+ });
2089
+ return String(started.childId);
2090
+ }
2091
+
2092
+ //#endregion
2093
+ //#region src/version.ts
2094
+ /**
2095
+ * 插件版本号的源码侧单一来源。
2096
+ *
2097
+ * `package.json` 的 `version` 是发布侧的权威值,两者必须一致:
2098
+ * `test/version.test.mjs` 会在不一致时直接让测试失败。这道闸门沿用圆桌会议
2099
+ * 的教训——那边 `renderReviewMarkdown` 的导出头部曾长期硬编码旧版本号,而
2100
+ * package.json 已经走远,导出物里的版本号说了谎却没有任何测试会发现。
2101
+ *
2102
+ * @module dsh-plugin-crossfire/version
2103
+ */
2104
+ /** 当前插件版本(必须与 package.json 的 version 逐字一致)。 */
2105
+ const PLUGIN_VERSION = "0.1.0";
2106
+ /** 导出物头部使用的插件标识行。 */
2107
+ const PLUGIN_ID = `@y9931666/dsh-plugin-crossfire v${PLUGIN_VERSION}`;
2108
+ /** DeepSeek Harness 版本区间(导出头部展示用)。
2109
+ * 必须与 `compatibility.json` 的 `recommendedHost` 一致 —— 由
2110
+ * `test/version.test.mjs` 强制。 */
2111
+ const HARNESS_RANGE = "DeepSeek Harness 0.2.0-rc.2+";
2112
+
2113
+ //#endregion
2114
+ //#region src/export-md.ts
2115
+ /** 把一段多行文本缩进成引用块(用于议题与立场)。 */
2116
+ function quote(text$1) {
2117
+ const trimmed = text$1.trim();
2118
+ if (trimmed === "") return "> (空)";
2119
+ return trimmed.split("\n").map((line) => `> ${line}`).join("\n");
2120
+ }
2121
+ /** 一条缺陷的正文渲染。 */
2122
+ function renderDefect(defect, index) {
2123
+ const lines = [];
2124
+ const marks = [`\`${defect.dimension}\``];
2125
+ if (defect.dimensionFallback === true) marks.push("维度已归一化");
2126
+ if (defect.strikesStance === true) marks.push("**打穿立场**");
2127
+ if (defect.seq > 0) marks.push(`观点 ${defect.seq}`);
2128
+ lines.push(`#### ${index}. ${marks.join(" · ")}`);
2129
+ lines.push("");
2130
+ lines.push(defect.content);
2131
+ lines.push("");
2132
+ if (defect.quote !== void 0 && defect.quote !== "") {
2133
+ lines.push(`> 原文引用:“${defect.quote}”`);
2134
+ lines.push("");
2135
+ }
2136
+ if (defect.evidence !== void 0) {
2137
+ const label = defect.evidence.kind === "repro" ? "可复现步骤" : "论证链";
2138
+ lines.push(`**证据(${label})**:${defect.evidence.text}`);
2139
+ lines.push("");
2140
+ }
2141
+ lines.push(`来源专家:\`${defect.expertKey === "" ? "(未知)" : defect.expertKey}\``);
2142
+ lines.push("");
2143
+ if (defect.status === "rejected" && defect.rejectReason !== void 0 && defect.rejectReason !== "") {
2144
+ lines.push(`驳回理由:${defect.rejectReason}`);
2145
+ lines.push("");
2146
+ }
2147
+ if (defect.recheck !== void 0) {
2148
+ const verdict = defect.recheck.verdict === "fixed" ? "已修复" : defect.recheck.verdict === "partial" ? "部分修复" : defect.recheck.verdict === "unfixed" ? "未修复" : "尚未判断";
2149
+ lines.push(`复核(第 ${defect.recheck.pass} 轮认定):**${verdict}**${defect.recheck.note === void 0 ? "" : ` — ${defect.recheck.note}`}`);
2150
+ lines.push("");
2151
+ }
2152
+ return lines;
2153
+ }
2154
+ /** 渲染整场评审。 */
2155
+ function renderReviewMarkdown(record, counts) {
2156
+ const spec = INTENSITY_SPECS[record.intensity];
2157
+ const lines = [];
2158
+ lines.push(`# 针锋评审 · ${record.subject.title}`);
2159
+ lines.push("");
2160
+ lines.push(`> ${PLUGIN_ID}`);
2161
+ lines.push(`> 宿主基线:${HARNESS_RANGE}`);
2162
+ lines.push(`> 评审 id:\`${record.reviewId}\``);
2163
+ lines.push(`> 轮次:第 ${record.reviewPass} 轮 / 上限 ${record.maxReviewPass} 轮`);
2164
+ lines.push(`> 强度:${record.intensity}(${spec.label})`);
2165
+ lines.push(`> 状态:${record.status}`);
2166
+ lines.push(`> 维度:${record.dimensions.join("、")}`);
2167
+ lines.push(`> 表态:认定 ${counts.endorsed} · 驳回 ${counts.rejected} · 待定 ${counts.pending}(共 ${counts.total})`);
2168
+ lines.push("");
2169
+ lines.push("## 一、待验证对象");
2170
+ lines.push("");
2171
+ lines.push(`来源:${record.subject.source}${record.subject.sourceRef === void 0 ? "" : `(${record.subject.sourceRef})`}`);
2172
+ lines.push("");
2173
+ lines.push(quote(record.subject.content));
2174
+ lines.push("");
2175
+ lines.push("## 二、用户立场与理由");
2176
+ lines.push("");
2177
+ lines.push(record.stance.text.trim() === "" ? "> (本次评审没有填写立场)" : quote(record.stance.text));
2178
+ lines.push("");
2179
+ const endorsed = record.defects.filter((defect) => defect.status === "endorsed");
2180
+ const pending = record.defects.filter((defect) => defect.status === "pending");
2181
+ const rejected = record.defects.filter((defect) => defect.status === "rejected");
2182
+ lines.push("## 三、已认定缺陷(本次评审的产出)");
2183
+ lines.push("");
2184
+ if (endorsed.length === 0) lines.push("> (没有认定任何缺陷)");
2185
+ else for (const [index, defect] of endorsed.entries()) lines.push(...renderDefect(defect, index + 1));
2186
+ lines.push("");
2187
+ if (pending.length > 0) {
2188
+ lines.push("## 四、待定缺陷(用户尚未表态)");
2189
+ lines.push("");
2190
+ for (const [index, defect] of pending.entries()) lines.push(...renderDefect(defect, index + 1));
2191
+ lines.push("");
2192
+ }
2193
+ if (rejected.length > 0) {
2194
+ lines.push("## 五、被驳回的观点(用户审阅后否定)");
2195
+ lines.push("");
2196
+ lines.push("> 这些观点**不会被再次提出**(去重依据);列出它们是结论的一部分。");
2197
+ lines.push("");
2198
+ for (const [index, defect] of rejected.entries()) lines.push(...renderDefect(defect, index + 1));
2199
+ lines.push("");
2200
+ }
2201
+ if (record.experts.length > 0) {
2202
+ lines.push("## 六、红队阵容");
2203
+ lines.push("");
2204
+ lines.push("| 专家 | 维度 | 模型 | 状态 | 缺陷数 |");
2205
+ lines.push("| --- | --- | --- | --- | --- |");
2206
+ for (const expert of record.experts) {
2207
+ const route = expert.provider === void 0 || expert.model === void 0 ? "(继承主持人)" : `${expert.provider}/${expert.model}`;
2208
+ const note = expert.note === void 0 ? "" : ` ${expert.note}`;
2209
+ lines.push(`| \`${expert.key}\` | ${expert.dimension} | ${route} | ${expert.status}${note} | ${expert.defectCount} |`);
2210
+ }
2211
+ lines.push("");
2212
+ }
2213
+ if (record.history.length > 0) {
2214
+ lines.push("## 七、历史轮次");
2215
+ lines.push("");
2216
+ for (const snapshot of record.history) {
2217
+ const endorsedInPass = snapshot.defects.filter((defect) => defect.status === "endorsed").length;
2218
+ lines.push(`- 第 ${snapshot.pass} 轮:认定 ${endorsedInPass} 条(共 ${snapshot.defects.length} 条观点)${snapshot.revisedSummary === void 0 ? "" : ` — 修订:${snapshot.revisedSummary}`}`);
2219
+ }
2220
+ lines.push("");
2221
+ }
2222
+ lines.push("## 八、边界声明");
2223
+ lines.push("");
2224
+ lines.push("- 本记录由「针锋 Crossfire」生成:机器只负责挑错,**结论由人逐条给出**。");
2225
+ lines.push("- 认定 / 驳回是用户的操作,插件不提供任何\"代表认定\"的入口。");
2226
+ lines.push("- 红队专家的工具为只读:它们只挑错,从未改动工作区。");
2227
+ lines.push("- 用户的历次表态只用于去重与复审递进,不会作为\"用户画像\"喂给红队。");
2228
+ lines.push("");
2229
+ return lines.join("\n");
2230
+ }
2231
+
2232
+ //#endregion
2233
+ //#region src/harness-compat.ts
2234
+ /**
2235
+ * 读一个**可选**宿主服务。
2236
+ *
2237
+ * 统一走 `ctx.get`(cordis 核心、非严格:缺失返回 `undefined`)而不是属性访问
2238
+ * —— 后者在没有 `inject` 时会**直接抛错**:
2239
+ * `cannot get property "connection" without inject`。
2240
+ *
2241
+ * 三种失败一律折成 `undefined`,**本函数不产生任何副作用**:
2242
+ * - 宿主 `Context` 上连 `get` 都没有(形状变化);
2243
+ * - `ctx.get(name)` 自己抛错;
2244
+ * - 服务未挂载(返回 `null` / `undefined`)。
2245
+ * 调用方拿到 `undefined` 后自行决定降级行为与是否留下一条诊断。
2246
+ */
2247
+ function optionalService(ctx, name$1) {
2248
+ const getter = ctx.get;
2249
+ if (typeof getter !== "function") return void 0;
2250
+ let service;
2251
+ try {
2252
+ service = getter.call(ctx, name$1);
2253
+ } catch {
2254
+ return;
2255
+ }
2256
+ if (service === void 0 || service === null) return void 0;
2257
+ return service;
2258
+ }
2259
+ /** 可选服务键的结构式候选(宿主可能改名;按序取第一个可用的)。 */
2260
+ const WEB_SERVER_KEYS = ["webServer", "httpServer"];
2261
+ const WORKSPACE_KEYS = ["workspaceRegistry", "workspace"];
2262
+ /**
2263
+ * 解析 webServer 服务面。
2264
+ *
2265
+ * 服务键是**结构式候选**而非固定名:宿主可能把 `webServer` 换成别的名字,
2266
+ * 所以按 `WEB_SERVER_KEYS` 顺序探测。headless profile 下取不到属预期,
2267
+ * 只降级不报错(返回 `undefined`,本函数不抛错也不打日志)。
2268
+ */
2269
+ function webRouteHostOf(ctx) {
2270
+ for (const key of WEB_SERVER_KEYS) {
2271
+ const host = optionalService(ctx, key);
2272
+ if (host !== void 0 && typeof host.register === "function") return host;
2273
+ }
2274
+ }
2275
+ /** 解析 workspace 注册表面(同上:结构式候选 + 静默降级为 `undefined`)。 */
2276
+ function workspaceRegistryOf(ctx) {
2277
+ for (const key of WORKSPACE_KEYS) {
2278
+ const host = optionalService(ctx, key);
2279
+ if (host !== void 0 && typeof host.list === "function") return host;
2280
+ }
2281
+ }
2282
+ /**
2283
+ * 判定一个 `rpc.handle(...)` 返回值能不能当清理函数用。
2284
+ *
2285
+ * 宿主签名在 rc 线上变过:0.1.1 时代第三参 `{ authority }` 被移除,返回值在
2286
+ * 不同版本里出现过 `disposer` / `undefined` / Promise。这里只做**形状判定**,
2287
+ * 不 import 宿主类型:合法就交出去,不合法就交给调用方记一条降级。
2288
+ */
2289
+ function asDisposer(value) {
2290
+ return typeof value === "function" ? value : void 0;
2291
+ }
2292
+ /**
2293
+ * 在可选的清理函数存在时注册一个 effect。
2294
+ *
2295
+ * `connection.rpc.handle` 的返回值形状随版本变化,直接 `ctx.effect(() => () =>
2296
+ * release())` 会在返回值不是函数时静默留下一个永远不执行的清理器(通道泄漏)。
2297
+ *
2298
+ * @returns `true` 表示已注册 effect;`false` 表示返回值不是清理函数、**没有**注册
2299
+ * (调用方据此决定是否报告一次降级)。
2300
+ */
2301
+ function effectWithOptionalDisposer(ctx, disposer, label) {
2302
+ const release = asDisposer(disposer);
2303
+ if (release === void 0) return false;
2304
+ ctx.effect(() => () => {
2305
+ release();
2306
+ }, label);
2307
+ return true;
2308
+ }
2309
+ /**
2310
+ * 圆桌插件曾在本文件维护一张进程级能力审计表(`capabilityAudit` /
2311
+ * `noteCapability` / `formatCapabilityAudit`),让 `status` 与 `doctor` 能打印
2312
+ * "每个探测点第一次被看到时是什么状态"。
2313
+ *
2314
+ * crossfire 不搬这套:本文件现在的定位是**纯粹的可选能力懒探测层** ——
2315
+ * 只回答"这个服务此刻能不能用",不留全局状态、不写日志、不参与加载决策。
2316
+ * 诊断信息由**探测的调用方**在拿到 `undefined` 时按自己的上下文报告;
2317
+ * 这样探测层不持有跨调用状态,也不会在轮询路径上积累内存。
2318
+ */
2319
+
2320
+ //#endregion
2321
+ //#region src/rpc.ts
2322
+ /** 插件自有 RPC 路由(浏览器半体优先走这条)。 */
2323
+ const RPC_ROUTE = "/plugins/dsh-plugin-crossfire/rpc";
2324
+ /** 插件自有快照路由(卡片每秒轮询它)。 */
2325
+ const STATE_ROUTE = "/plugins/dsh-plugin-crossfire/state";
2326
+ function ok(value) {
2327
+ return {
2328
+ ok: true,
2329
+ value
2330
+ };
2331
+ }
2332
+ function fail(message, code = "internal") {
2333
+ return {
2334
+ ok: false,
2335
+ error: {
2336
+ code,
2337
+ message
2338
+ }
2339
+ };
2340
+ }
2341
+ /** 把记录收敛成卡片快照。 */
2342
+ function snapshotOf(record, stateRoot) {
2343
+ return {
2344
+ reviewId: record.reviewId,
2345
+ status: record.status,
2346
+ subjectTitle: record.subject.title,
2347
+ subjectContent: record.subject.content,
2348
+ subjectSource: record.subject.source,
2349
+ stanceText: record.stance.text,
2350
+ stanceOrigin: record.stance.origin,
2351
+ dimensions: [...record.dimensions],
2352
+ intensity: record.intensity,
2353
+ intensityLabel: INTENSITY_SPECS[record.intensity].label,
2354
+ reviewPass: record.reviewPass,
2355
+ maxReviewPass: record.maxReviewPass,
2356
+ experts: record.experts.map((expert) => ({
2357
+ key: expert.key,
2358
+ dimension: expert.dimension,
2359
+ status: expert.status,
2360
+ defectCount: expert.defectCount,
2361
+ ...expert.reportCount === void 0 ? {} : { reportCount: expert.reportCount },
2362
+ ...expert.provider === void 0 ? {} : { provider: expert.provider },
2363
+ ...expert.model === void 0 ? {} : { model: expert.model },
2364
+ ...expert.role === void 0 ? {} : { role: expert.role },
2365
+ ...expert.note === void 0 ? {} : { note: expert.note }
2366
+ })),
2367
+ defects: record.defects.map((defect) => ({
2368
+ id: defect.id,
2369
+ expertKey: defect.expertKey,
2370
+ dimension: defect.dimension,
2371
+ ...defect.dimensionFallback === true ? { dimensionFallback: true } : {},
2372
+ content: defect.content,
2373
+ ...defect.quote === void 0 ? {} : { quote: defect.quote },
2374
+ ...defect.evidence === void 0 ? {} : { evidence: defect.evidence },
2375
+ ...defect.strikesStance === true ? { strikesStance: true } : {},
2376
+ status: defect.status,
2377
+ ...defect.rejectReason === void 0 ? {} : { rejectReason: defect.rejectReason },
2378
+ ...defect.recheck === void 0 ? {} : { recheck: defect.recheck }
2379
+ })),
2380
+ counts: countDefects(record),
2381
+ budget: { ...record.budget },
2382
+ startedAt: record.startedAt,
2383
+ updatedAt: record.updatedAt,
2384
+ stateRoot
2385
+ };
2386
+ }
2387
+ /** 会话活着时,直接从它的 Agent 拿到工作区(最精确)。 */
2388
+ function workspaceOfSession(ctx, session) {
2389
+ if (session === "") return void 0;
2390
+ const agent = ctx.agents.get(session);
2391
+ if (agent === void 0) return void 0;
2392
+ return agent.session.header?.cwd;
2393
+ }
2394
+ /** 会话活着时把 Agent 找回来(拉起专家需要它当 parent)。 */
2395
+ function agentOfSession(ctx, session) {
2396
+ if (session === "") return void 0;
2397
+ return ctx.agents.get(session);
2398
+ }
2399
+ /**
2400
+ * 判定"谁在操作这一场评审"。
2401
+ *
2402
+ * ## 为什么必须是严格的(这里出过一个真实的安全缺陷)
2403
+ *
2404
+ * 会话 id 在这个插件里**同时是凭据和权限**:`load()` 用它做归属校验,
2405
+ * `review.advance` 的 attack 分支又用它换回 live Agent 去拉起专家。原先的写法把
2406
+ * 空会话当成"跳过校验",于是当客户端没把 session 送进来时:
2407
+ *
2408
+ * - 归属校验整条短路(任何会话都能操作任何记录);
2409
+ * - 而 attack 又会退回用**记录里的 owner** 换 Agent,照样把专家拉起来。
2410
+ *
2411
+ * 结果是一条能真正干活的越权通道,而且它的成功响应与合法调用完全同形 —— 验收时
2412
+ * 根本分不出来。红队在验收流程里抓到了这一点。
2413
+ *
2414
+ * 所以现在的规则是:**必须声称一个会话**。规则本身是纯函数,因此可以被测试直接
2415
+ * 钉住(`test/rpc-identity.test.mjs`),而不是只靠人工点一次按钮。
2416
+ *
2417
+ * @param ownerSessionId - 记录里的 owner(早期数据可能没有)。
2418
+ * @param actorSession - 调用方声称的会话 id(缺失时为 `''`)。
2419
+ */
2420
+ function judgeActor(ownerSessionId, actorSession) {
2421
+ if (actorSession === "") return {
2422
+ ok: false,
2423
+ code: "no-actor",
2424
+ message: "this request carried no session identity — reload the page so the card can send one"
2425
+ };
2426
+ if (ownerSessionId === void 0 || ownerSessionId === "") return {
2427
+ ok: true,
2428
+ bound: false
2429
+ };
2430
+ if (ownerSessionId !== actorSession) return {
2431
+ ok: false,
2432
+ code: "foreign-owner",
2433
+ message: "this review belongs to another session"
2434
+ };
2435
+ return {
2436
+ ok: true,
2437
+ bound: true
2438
+ };
2439
+ }
2440
+ /** 已知的全部候选状态根(工作区注册表可用时)。 */
2441
+ function candidateStateRoots(ctx, stateDir) {
2442
+ const registry = workspaceRegistryOf(ctx);
2443
+ if (registry === void 0) return [];
2444
+ const roots = [];
2445
+ for (const workspace of registry.list()) {
2446
+ const root = stateRootOf(workspace.path, stateDir);
2447
+ if (!roots.includes(root)) roots.push(root);
2448
+ }
2449
+ return roots;
2450
+ }
2451
+ /**
2452
+ * 定位一场评审。
2453
+ *
2454
+ * 优先用客户端回传的 `stateRoot`(一次命中,不再遍历);否则遍历已知工作区。
2455
+ * 评审数量天然很少,所以这个遍历不是瓶颈。
2456
+ */
2457
+ async function locateReview(ctx, stateDir, reviewId, preferredRoot) {
2458
+ const roots = [];
2459
+ if (preferredRoot !== void 0 && preferredRoot !== "") roots.push(preferredRoot);
2460
+ for (const root of candidateStateRoots(ctx, stateDir)) if (!roots.includes(root)) roots.push(root);
2461
+ for (const root of roots) {
2462
+ const record = await readRecord(root, reviewId);
2463
+ if (record !== void 0) return {
2464
+ stateRoot: root,
2465
+ record
2466
+ };
2467
+ }
2468
+ }
2469
+ /** 收集全部评审快照(卡片轮询的数据源)。 */
2470
+ async function collectSnapshots(ctx, stateDir, session) {
2471
+ if (session === "") return {
2472
+ reviews: [],
2473
+ note: "this request carried no session identity — reload the page so the card can send one"
2474
+ };
2475
+ const roots = [];
2476
+ const sessionWorkspace = workspaceOfSession(ctx, session);
2477
+ if (sessionWorkspace !== void 0) roots.push(stateRootOf(sessionWorkspace, stateDir));
2478
+ for (const root of candidateStateRoots(ctx, stateDir)) if (!roots.includes(root)) roots.push(root);
2479
+ if (roots.length === 0) return {
2480
+ reviews: [],
2481
+ note: "no workspace is known for this session (the session may be cold, and no workspace registry is mounted)"
2482
+ };
2483
+ const reviews = [];
2484
+ for (const root of roots) for (const reviewId of await listReviewIds(root)) {
2485
+ const record = await readRecord(root, reviewId);
2486
+ if (record === void 0) continue;
2487
+ if (record.ownerSessionId !== void 0 && record.ownerSessionId !== "" && record.ownerSessionId !== session) continue;
2488
+ reviews.push(snapshotOf(record, root));
2489
+ }
2490
+ reviews.sort((left, right) => right.startedAt - left.startedAt);
2491
+ return { reviews };
2492
+ }
2493
+ /** 注册 RPC 端点,返回可复用的 `dispatch`。 */
2494
+ function registerRpc(ctx, runtime) {
2495
+ const stateDir = runtime.tools.stateDir;
2496
+ /** 读一个评审并校验归属。 */
2497
+ const load = async (reviewId, session, preferredRoot) => {
2498
+ if (reviewId === "") throw new Error("reviewId must not be empty");
2499
+ const located = await locateReview(ctx, stateDir, reviewId, preferredRoot);
2500
+ if (located === void 0) throw new Error(`no review "${reviewId}"`);
2501
+ const verdict = judgeActor(located.record.ownerSessionId, session);
2502
+ if (!verdict.ok) throw new Error(verdict.message);
2503
+ return located;
2504
+ };
2505
+ return async (endpoint, payload) => {
2506
+ try {
2507
+ const body = payload !== null && typeof payload === "object" ? payload : {};
2508
+ const session = typeof body.session === "string" ? body.session : "";
2509
+ const reviewId = typeof body.reviewId === "string" ? body.reviewId : "";
2510
+ const preferredRoot = typeof body.stateRoot === "string" ? body.stateRoot : void 0;
2511
+ switch (endpoint) {
2512
+ case "crossfire/prefs.get": {
2513
+ const prefs = runtime.prefs.get();
2514
+ return ok({
2515
+ prefs,
2516
+ pool: [...DIMENSION_POOL],
2517
+ dimensions: effectiveDimensions(prefs),
2518
+ intensities: INTENSITY_SPECS,
2519
+ stateDir
2520
+ });
2521
+ }
2522
+ case "crossfire/prefs.set": {
2523
+ const gate = judgeActor(void 0, session);
2524
+ if (!gate.ok) throw new Error(gate.message);
2525
+ if (body.patch === void 0) return fail("payload must be { patch }");
2526
+ const next = await runtime.prefs.update(body.patch);
2527
+ return ok({
2528
+ prefs: next,
2529
+ dimensions: effectiveDimensions(next)
2530
+ });
2531
+ }
2532
+ case "crossfire/models.list": {
2533
+ const llm = ctx.get("llm");
2534
+ if (llm === void 0) return ok({ providers: [] });
2535
+ const merged = /* @__PURE__ */ new Map();
2536
+ for (const provider of llm.listProviders()) merged.set(provider.id, {
2537
+ id: provider.id,
2538
+ name: provider.name,
2539
+ active: true
2540
+ });
2541
+ if (typeof llm.listConfigurableProviders === "function") {
2542
+ let declared = [];
2543
+ try {
2544
+ declared = llm.listConfigurableProviders();
2545
+ } catch {
2546
+ declared = [];
2547
+ }
2548
+ for (const provider of declared) {
2549
+ if (merged.has(provider.provider)) continue;
2550
+ merged.set(provider.provider, {
2551
+ id: provider.provider,
2552
+ name: provider.displayName,
2553
+ active: false
2554
+ });
2555
+ }
2556
+ }
2557
+ const providers = [];
2558
+ for (const entry of [...merged.values()].sort((left, right) => {
2559
+ if (left.active !== right.active) return left.active ? -1 : 1;
2560
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
2561
+ })) {
2562
+ let models = [];
2563
+ if (entry.active) try {
2564
+ models = (await llm.listModels(entry.id)).map((model) => ({
2565
+ id: model.id,
2566
+ name: model.name
2567
+ }));
2568
+ } catch {
2569
+ models = [];
2570
+ }
2571
+ providers.push({
2572
+ id: entry.id,
2573
+ name: entry.name,
2574
+ models,
2575
+ active: entry.active
2576
+ });
2577
+ }
2578
+ return ok({ providers });
2579
+ }
2580
+ case "crossfire/reviews.list": return ok(await collectSnapshots(ctx, stateDir, session));
2581
+ case "crossfire/review.get": {
2582
+ const located = await load(reviewId, session, preferredRoot);
2583
+ return ok({ review: snapshotOf(located.record, located.stateRoot) });
2584
+ }
2585
+ case "crossfire/defect.setStatus": {
2586
+ const defectId = typeof body.defectId === "string" ? body.defectId : "";
2587
+ if (defectId === "") return fail("payload must be { reviewId, defectId, status }");
2588
+ const status = body.status;
2589
+ if (status !== "pending" && status !== "endorsed" && status !== "rejected") return fail("status must be \"pending\" | \"endorsed\" | \"rejected\"");
2590
+ const rejectReason = typeof body.rejectReason === "string" ? body.rejectReason : "";
2591
+ if (status === "rejected" && rejectReason.trim() === "") return fail("rejecting a defect requires a non-empty reject_reason (驳回必填理由)");
2592
+ return ok({
2593
+ changed: await setDefectStatus((await load(reviewId, session, preferredRoot)).stateRoot, reviewId, defectId, status, rejectReason),
2594
+ status
2595
+ });
2596
+ }
2597
+ case "crossfire/expert.setRole": {
2598
+ const expertKey = typeof body.expertKey === "string" ? body.expertKey : "";
2599
+ if (expertKey === "") return fail("payload must be { reviewId, expertKey, role }");
2600
+ if (typeof body.role !== "string") return fail("payload must carry a string `role`");
2601
+ const role = body.role.trim().slice(0, 2e3);
2602
+ const outcome = await mutateRecord((await load(reviewId, session, preferredRoot)).stateRoot, reviewId, (current) => {
2603
+ const expert = current.experts.find((item) => item.key === expertKey);
2604
+ if (expert === void 0) return {
2605
+ found: false,
2606
+ changed: false
2607
+ };
2608
+ if ((expert.role ?? "") === role) return {
2609
+ found: true,
2610
+ changed: false
2611
+ };
2612
+ if (role === "") delete expert.role;
2613
+ else expert.role = role;
2614
+ return {
2615
+ found: true,
2616
+ changed: true
2617
+ };
2618
+ });
2619
+ if (outcome === void 0) return fail(`no review "${reviewId}"`);
2620
+ if (!outcome.found) return fail(`no expert "${expertKey}" in this review`);
2621
+ return ok({ changed: outcome.changed });
2622
+ }
2623
+ case "crossfire/recheck.setVerdict": {
2624
+ const defectId = typeof body.defectId === "string" ? body.defectId : "";
2625
+ const verdict = body.verdict;
2626
+ if (defectId === "" || verdict !== "fixed" && verdict !== "unfixed" && verdict !== "partial") return fail("payload must be { reviewId, defectId, verdict: fixed | unfixed | partial }");
2627
+ const located = await load(reviewId, session, preferredRoot);
2628
+ const note = typeof body.note === "string" ? body.note : "";
2629
+ return ok({ changed: await setRecheckVerdict(located.stateRoot, reviewId, defectId, verdict, note) });
2630
+ }
2631
+ case "crossfire/stance.set": {
2632
+ const text$1 = typeof body.text === "string" ? body.text.trim().slice(0, 2e3) : "";
2633
+ await mutateRecord((await load(reviewId, session, preferredRoot)).stateRoot, reviewId, (record) => {
2634
+ record.stance = {
2635
+ text: text$1,
2636
+ origin: "edited",
2637
+ ...record.stance.distilledAt === void 0 ? {} : { distilledAt: record.stance.distilledAt },
2638
+ updatedAt: Date.now()
2639
+ };
2640
+ });
2641
+ return ok({ saved: true });
2642
+ }
2643
+ case "crossfire/review.open": return await openReviewFromRpc(ctx, runtime, {
2644
+ session,
2645
+ title: typeof body.title === "string" ? body.title : "",
2646
+ content: typeof body.content === "string" ? body.content : "",
2647
+ ...body.dimensions === void 0 ? {} : { dimensions: body.dimensions },
2648
+ ...body.intensity === void 0 ? {} : { intensity: body.intensity },
2649
+ ...typeof body.stance === "string" ? { stance: body.stance } : {}
2650
+ });
2651
+ case "crossfire/review.export": {
2652
+ const located = await load(reviewId, session, preferredRoot);
2653
+ assertBudget(located.record, { allowMuted: true });
2654
+ const markdown = renderReviewMarkdown(located.record, countDefects(located.record));
2655
+ const path = exportFile(located.stateRoot, reviewId);
2656
+ await writeTextAtomic(path, markdown);
2657
+ return ok({
2658
+ path,
2659
+ bytes: Buffer.byteLength(markdown, "utf8"),
2660
+ markdown
2661
+ });
2662
+ }
2663
+ case "crossfire/review.advance": {
2664
+ const step = body.step;
2665
+ if (step !== "attack" && step !== "collect" && step !== "finish" && step !== "recheck") return fail("step must be \"attack\" | \"collect\" | \"finish\" | \"recheck\"");
2666
+ return await advance(ctx, runtime, await load(reviewId, session, preferredRoot), step, body, session);
2667
+ }
2668
+ default: return fail(`unknown endpoint "${endpoint}"`);
2669
+ }
2670
+ } catch (error) {
2671
+ const message = error instanceof Error ? error.message : String(error);
2672
+ ctx.logger.warn(`crossfire: rpc "${endpoint}" failed`, error);
2673
+ return fail(message);
2674
+ }
2675
+ };
2676
+ }
2677
+ /**
2678
+ * 卡片按钮推进状态机。
2679
+ *
2680
+ * 四个步骤各自的门禁与工具端**完全一致**(同一套 review/engine 函数),差别只在
2681
+ * "谁是被授权者":工具用 `exec.agent`,这里用会话 id 换回来的 Agent。
2682
+ */
2683
+ async function advance(ctx, runtime, located, step, body, session) {
2684
+ const { stateRoot } = located;
2685
+ const reviewId = located.record.reviewId;
2686
+ const defaults = runtime.tools.getDefaults();
2687
+ if (step === "attack") {
2688
+ if (await readRecord(stateRoot, reviewId) === void 0) return fail(`no review "${reviewId}"`);
2689
+ const agent = agentOfSession(ctx, session);
2690
+ if (agent === void 0) return fail("the session that owns this review is not live right now, so Crossfire cannot send in the red team from the card — ask in the conversation instead (crossfire_attack)", "session-offline");
2691
+ const planned = await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2692
+ const current = await readRecord(stateRoot, reviewId);
2693
+ if (current === void 0) throw new Error(`no review "${reviewId}"`);
2694
+ if (current.status !== "draft" && current.status !== "attacking") throw new Error(`review "${reviewId}" is "${current.status}" — cannot send in the red team now (call crossfire_recheck to open a new pass on a settled review)`);
2695
+ const prefs = runtime.prefs.get();
2696
+ const roster = rosterFrom({
2697
+ squads: prefs.squads.map((squad) => ({
2698
+ id: squad.id,
2699
+ members: squad.members.map((member) => ({
2700
+ key: member.key,
2701
+ ...member.role === void 0 ? {} : { role: member.role },
2702
+ ...member.provider === void 0 ? {} : { provider: member.provider },
2703
+ ...member.model === void 0 ? {} : { model: member.model }
2704
+ }))
2705
+ })),
2706
+ rolePresets: prefs.rolePresets.map((preset) => ({
2707
+ key: preset.name,
2708
+ ...preset.role === void 0 ? {} : { role: preset.role },
2709
+ ...preset.provider === void 0 ? {} : { provider: preset.provider },
2710
+ ...preset.model === void 0 ? {} : { model: preset.model }
2711
+ }))
2712
+ });
2713
+ const experts = planExperts(current, runtime.tools.maxExperts, roster);
2714
+ if (current.reviewPass > 1 && current.history.length > 0 && current.defects.length === 0) current.defects = buildRecheckItems(current);
2715
+ current.experts = experts;
2716
+ current.status = "attacking";
2717
+ await writeRecord(stateRoot, current);
2718
+ return {
2719
+ record: current,
2720
+ experts
2721
+ };
2722
+ });
2723
+ const failures = [];
2724
+ for (const expert of planned.experts) {
2725
+ const controller = new AbortController();
2726
+ try {
2727
+ const childId = await spawnExpert(ctx, {
2728
+ provider: runtime.tools.memberProvider,
2729
+ ...runtime.tools.memberMaxDepth === void 0 ? {} : { maxDepth: runtime.tools.memberMaxDepth }
2730
+ }, planned.record, expert, agent, controller.signal, {
2731
+ maxTokens: defaults.expertMaxTokens,
2732
+ maxOpinions: defaults.expertMaxOpinions
2733
+ });
2734
+ expert.childId = childId;
2735
+ expert.status = "running";
2736
+ registerExpertChild(childId, {
2737
+ stateRoot,
2738
+ reviewId
2739
+ });
2740
+ } catch (error) {
2741
+ expert.status = "failed";
2742
+ expert.note = error instanceof Error ? error.message : String(error);
2743
+ failures.push(`${expert.key}: ${expert.note}`);
2744
+ }
2745
+ }
2746
+ await mutateRecord(stateRoot, reviewId, (current) => {
2747
+ current.experts = planned.experts;
2748
+ });
2749
+ return ok({
2750
+ spawned: planned.experts.filter((expert) => expert.status === "running").length,
2751
+ failed: failures.length,
2752
+ failures
2753
+ });
2754
+ }
2755
+ if (step === "collect") {
2756
+ const record$1 = await readRecord(stateRoot, reviewId);
2757
+ if (record$1 === void 0) return fail(`no review "${reviewId}"`);
2758
+ if (record$1.status !== "attacking" && record$1.status !== "judging") return fail(`review "${reviewId}" is "${record$1.status}" — cannot collect now`);
2759
+ const transcript = await readTranscriptReport(stateRoot, reviewId);
2760
+ const llm = ctx.get("llm");
2761
+ const outcome = await collectDefects({
2762
+ record: record$1,
2763
+ transcript: transcript.items,
2764
+ malformed: transcript.malformed,
2765
+ llm,
2766
+ splitConfig: runtime.tools.reviewSplit
2767
+ });
2768
+ await mutateRecord(stateRoot, reviewId, (current) => {
2769
+ applyCollected(current, outcome.added);
2770
+ current.status = "judging";
2771
+ });
2772
+ return ok({
2773
+ collected: outcome.added.length,
2774
+ droppedDuplicates: outcome.droppedDuplicates,
2775
+ skippedNoise: outcome.skippedNoise,
2776
+ splitAttempts: outcome.splitAttempts,
2777
+ recheckRound: record$1.reviewPass > 1 && record$1.history.length > 0
2778
+ });
2779
+ }
2780
+ if (step === "finish") {
2781
+ const summary = typeof body.summary === "string" ? body.summary.trim() : "";
2782
+ const record$1 = await readRecord(stateRoot, reviewId);
2783
+ if (record$1 === void 0) return fail(`no review "${reviewId}"`);
2784
+ if (record$1.status !== "judging" && record$1.status !== "attacking" && record$1.status !== "settled") return fail(`review "${reviewId}" is "${record$1.status}" — cannot settle now`);
2785
+ return ok({
2786
+ settled: true,
2787
+ counts: await mutateRecord(stateRoot, reviewId, (current) => {
2788
+ current.history = [...current.history, {
2789
+ pass: current.reviewPass,
2790
+ subject: current.subject.content,
2791
+ stanceText: current.stance.text,
2792
+ dimensions: [...current.dimensions],
2793
+ intensity: current.intensity,
2794
+ defects: current.defects.map((defect) => ({ ...defect })),
2795
+ finishedAt: Date.now(),
2796
+ ...summary === "" ? {} : { revisedSummary: summary }
2797
+ }];
2798
+ current.status = "settled";
2799
+ current.settledAt = Date.now();
2800
+ return countDefects(current);
2801
+ }) ?? countDefects(record$1)
2802
+ });
2803
+ }
2804
+ const record = await readRecord(stateRoot, reviewId);
2805
+ if (record === void 0) return fail(`no review "${reviewId}"`);
2806
+ if (record.status !== "settled") return fail(`review "${reviewId}" is "${record.status}" — only a settled review can be re-checked`);
2807
+ const approved = body.approved === true;
2808
+ const nextPass = record.reviewPass + 1;
2809
+ if (nextPass > record.maxReviewPass && !approved) return fail(`re-review cap reached (pass ${nextPass} > max ${record.maxReviewPass}) — the user must approve continuing`, "cap-reached");
2810
+ const result = await mutateRecord(stateRoot, reviewId, (current) => {
2811
+ const items = buildRecheckItems(current);
2812
+ current.reviewPass = beginPass(current);
2813
+ if (approved) current.maxReviewPass = Math.max(current.maxReviewPass, current.reviewPass);
2814
+ current.defects = items;
2815
+ current.experts = [];
2816
+ current.status = "attacking";
2817
+ current.settledAt = void 0;
2818
+ return {
2819
+ pass: current.reviewPass,
2820
+ items: items.length
2821
+ };
2822
+ });
2823
+ return ok({
2824
+ reviewPass: result?.pass ?? nextPass,
2825
+ recheckItems: result?.items ?? 0
2826
+ });
2827
+ }
2828
+ /**
2829
+ * 从界面直接开一场评审。
2830
+ *
2831
+ * 与 `crossfire_open` 工具同源,区别只是工作区来自会话 id 而不是 `exec.agent`。
2832
+ */
2833
+ async function openReviewFromRpc(ctx, runtime, input) {
2834
+ if (input.session === "") return fail("this request carried no session identity — reload the page so the card can send one", "no-actor");
2835
+ const workspace = workspaceOfSession(ctx, input.session);
2836
+ if (workspace === void 0 || workspace === "") return fail("cannot determine the workspace for this session", "no-workspace");
2837
+ const defaults = runtime.tools.getDefaults();
2838
+ const content = input.content.trim();
2839
+ if (content === "") return fail("content must not be empty");
2840
+ const intensity = input.intensity === "quick" || input.intensity === "deep" ? input.intensity : defaults.intensity;
2841
+ const stateRoot = stateRootOf(workspace, runtime.tools.stateDir);
2842
+ const reviewId = newReviewId();
2843
+ const record = newRecord({
2844
+ reviewId,
2845
+ title: input.title.trim() || content.slice(0, 40),
2846
+ content,
2847
+ source: "manual",
2848
+ dimensions: resolveDimensions(input.dimensions, defaults.customDimensions),
2849
+ intensity,
2850
+ stanceText: input.stance ?? "",
2851
+ maxRounds: defaults.maxRounds,
2852
+ maxTokens: defaults.maxTokens,
2853
+ ownerSessionId: input.session
2854
+ });
2855
+ await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2856
+ await writeRecord(stateRoot, record);
2857
+ });
2858
+ return ok({ review: snapshotOf(record, stateRoot) });
2859
+ }
2860
+
2861
+ //#endregion
2862
+ //#region src/tools.ts
2863
+ /** 调用者 agent,或一个响亮的失败(非 agent 调用)。 */
2864
+ function requireAgent(exec) {
2865
+ if (exec.agent === void 0) throw new Error("crossfire tools require a calling agent (exec.agent was undefined)");
2866
+ return exec.agent;
2867
+ }
2868
+ /** 调用者的工作区目录(状态根的父目录)。 */
2869
+ function workspaceOf(agent) {
2870
+ return agent.session.header?.cwd ?? process.cwd();
2871
+ }
2872
+ /** 取参数里的非空字符串。 */
2873
+ function text(value) {
2874
+ return typeof value === "string" ? value.trim() : "";
2875
+ }
2876
+ /** 容错地把参数读成字符串数组(支持真数组、逗号分隔串、空白分隔串)。 */
2877
+ function stringArray(value) {
2878
+ if (Array.isArray(value)) {
2879
+ const items = value.filter((item) => typeof item === "string" && item.trim() !== "");
2880
+ return items.length > 0 ? items : void 0;
2881
+ }
2882
+ if (typeof value === "string" && value.trim() !== "") {
2883
+ const items = value.split(/[,,、;;\s]+/).map((item) => item.trim()).filter((item) => item !== "");
2884
+ return items.length > 0 ? items : void 0;
2885
+ }
2886
+ }
2887
+ function parseIntensity(value) {
2888
+ return value === "quick" || value === "standard" || value === "deep" ? value : void 0;
2889
+ }
2890
+ /** 读一场评审,不存在就抛错。 */
2891
+ async function requireRecord(stateRoot, reviewId) {
2892
+ const record = await readRecord(stateRoot, reviewId);
2893
+ if (record === void 0) throw new Error(`no review "${reviewId}" — call crossfire_open first (or check the review id)`);
2894
+ return record;
2895
+ }
2896
+ /**
2897
+ * 校验调用者是不是这场评审的开启者。
2898
+ *
2899
+ * 旧记录没有 owner 字段时跳过校验(而不是拒绝)—— 宁可放过,也不能让一条早期
2900
+ * 记录变成谁也打不开的死文件。
2901
+ */
2902
+ function requireOwner(record, callerId) {
2903
+ if (record.ownerSessionId === void 0 || record.ownerSessionId === "") return;
2904
+ if (record.ownerSessionId !== callerId) throw new Error(`review "${record.reviewId}" belongs to another session — refusing to operate on it`);
2905
+ }
2906
+ /** 状态机门禁:不在期望状态就抛错,并把当前状态说清楚。 */
2907
+ function requireStatus(record, expected) {
2908
+ if (!expected.includes(record.status)) throw new Error(`review "${record.reviewId}" is "${record.status}" — expected ${expected.map((item) => `"${item}"`).join(" or ")}`);
2909
+ }
2910
+ /** 深拷贝一条缺陷(历史快照必须是快照,不能被后续修改穿透)。 */
2911
+ function cloneDefect(defect) {
2912
+ return {
2913
+ ...defect,
2914
+ ...defect.evidence === void 0 ? {} : { evidence: { ...defect.evidence } },
2915
+ ...defect.recheck === void 0 ? {} : { recheck: { ...defect.recheck } }
2916
+ };
2917
+ }
2918
+ function registerCrossfireTools(ctx, config) {
2919
+ const memberConfig = {
2920
+ provider: config.memberProvider,
2921
+ ...config.memberMaxDepth === void 0 ? {} : { maxDepth: config.memberMaxDepth }
2922
+ };
2923
+ ctx.tools.register(defineTool({
2924
+ name: "crossfire_open",
2925
+ description: "Open a 针锋 (adversarial) review: put ONE settled result on the stand so red-team experts can attack it. Records the subject, your stance (optional but recommended — it gives the attack a target), the dimensions to attack from, and the intensity. Returns a review_id in status \"draft\"; the review card appears in the trajectory. Next step is crossfire_attack. Use this ONLY for something already decided — Crossfire never produces a plan and never fixes anything.",
2926
+ parameters: {
2927
+ title: {
2928
+ type: "string",
2929
+ required: true,
2930
+ description: "One-line title of the thing under review (shown on the card and in the export)."
2931
+ },
2932
+ content: {
2933
+ type: "string",
2934
+ required: true,
2935
+ description: "The settled result / plan / artifact itself: paste the actual text to be attacked."
2936
+ },
2937
+ dimensions: {
2938
+ type: "array",
2939
+ items: { type: "string" },
2940
+ description: "Dimensions to attack from, e.g. [\"安全\",\"性能\"]. Omit to use the full pool (安全/数据/交互/性能/兼容/流程/反例 + your custom ones). Unknown values are normalized to the nearest pool value."
2941
+ },
2942
+ intensity: {
2943
+ type: "string",
2944
+ enum: [
2945
+ "quick",
2946
+ "standard",
2947
+ "deep"
2948
+ ],
2949
+ description: "Attack intensity: quick (one round), standard (default), deep (adds a cross-examination round, allows two re-reviews)."
2950
+ },
2951
+ stance: {
2952
+ type: "string",
2953
+ description: "YOUR position and reasons on this subject, in your own words. When present, the red team must argue against each reason and mark whether it was refuted (strikes_stance). This is what turns a neutral review into 针锋相对."
2954
+ },
2955
+ source: {
2956
+ type: "string",
2957
+ enum: [
2958
+ "manual",
2959
+ "chat",
2960
+ "file"
2961
+ ],
2962
+ description: "Where the subject came from (default manual)."
2963
+ },
2964
+ source_ref: {
2965
+ type: "string",
2966
+ description: "Optional reference for the source (a file path, or a short anchor)."
2967
+ },
2968
+ source_meeting: {
2969
+ type: "string",
2970
+ description: "Optional: the roundtable meeting id this review was handed over from."
2971
+ }
2972
+ },
2973
+ output: {
2974
+ schema: {
2975
+ type: "object",
2976
+ additionalProperties: false,
2977
+ properties: {
2978
+ review_id: {
2979
+ type: "string",
2980
+ required: true
2981
+ },
2982
+ status: {
2983
+ type: "string",
2984
+ required: true
2985
+ },
2986
+ dimensions: {
2987
+ type: "array",
2988
+ items: { type: "string" },
2989
+ required: true
2990
+ },
2991
+ intensity: {
2992
+ type: "string",
2993
+ required: true
2994
+ },
2995
+ max_review_pass: {
2996
+ type: "integer",
2997
+ required: true
2998
+ }
2999
+ }
3000
+ },
3001
+ render: (_args, value) => [{
3002
+ type: "text",
3003
+ text: `Review ${value.review_id} opened (status ${value.status}, intensity ${value.intensity}). Dimensions: ${value.dimensions.join(", ")}. Re-review cap: ${value.max_review_pass}. Next: crossfire_attack to send in the red team.`
3004
+ }]
3005
+ },
3006
+ async execute(args, exec) {
3007
+ const caller = requireAgent(exec);
3008
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3009
+ const defaults = config.getDefaults();
3010
+ const content = text(args.content);
3011
+ if (content === "") throw new Error("content must not be empty — paste the thing under review");
3012
+ const title = text(args.title) || content.slice(0, 40);
3013
+ const intensity = parseIntensity(args.intensity) ?? defaults.intensity;
3014
+ const dimensions = resolveDimensions(stringArray(args.dimensions), defaults.customDimensions);
3015
+ const sourceParam = args.source;
3016
+ const source = sourceParam === "chat" || sourceParam === "file" ? sourceParam : "manual";
3017
+ const sourceRef = text(args.source_ref);
3018
+ const sourceMeeting = text(args.source_meeting);
3019
+ const reviewId = newReviewId();
3020
+ const record = newRecord({
3021
+ reviewId,
3022
+ title,
3023
+ content,
3024
+ source,
3025
+ ...sourceRef === "" ? {} : { sourceRef },
3026
+ dimensions,
3027
+ intensity,
3028
+ stanceText: text(args.stance),
3029
+ maxRounds: defaults.maxRounds,
3030
+ maxTokens: defaults.maxTokens,
3031
+ ...sourceMeeting === "" ? {} : { sourceMeeting },
3032
+ ownerSessionId: caller.id
3033
+ });
3034
+ await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
3035
+ await writeRecord(stateRoot, record);
3036
+ });
3037
+ return {
3038
+ review_id: reviewId,
3039
+ status: record.status,
3040
+ dimensions: record.dimensions,
3041
+ intensity: record.intensity,
3042
+ max_review_pass: record.maxReviewPass
3043
+ };
3044
+ }
3045
+ }));
3046
+ ctx.tools.register(defineTool({
3047
+ name: "crossfire_attack",
3048
+ description: "Send in the red team: spawn one read-only expert per chosen dimension (draft -> attacking). Experts are durable continuable subagents whose tool surface is a strict read-only allowlist (read/read_image/glob/grep + crossfire_report) — they can gather evidence but can never write, execute or fetch the network. Each expert submits its findings with crossfire_report. Use again on a recheck round: experts then only verify the previously endorsed defects.",
3049
+ parameters: { review_id: {
3050
+ type: "string",
3051
+ required: true,
3052
+ description: "The review to attack (from crossfire_open)."
3053
+ } },
3054
+ output: {
3055
+ schema: {
3056
+ type: "object",
3057
+ additionalProperties: false,
3058
+ properties: {
3059
+ spawned: {
3060
+ type: "integer",
3061
+ required: true
3062
+ },
3063
+ failed: {
3064
+ type: "integer",
3065
+ required: true
3066
+ },
3067
+ dimensions: {
3068
+ type: "array",
3069
+ items: { type: "string" },
3070
+ required: true
3071
+ },
3072
+ review_pass: {
3073
+ type: "integer",
3074
+ required: true
3075
+ },
3076
+ failures: {
3077
+ type: "array",
3078
+ items: { type: "string" },
3079
+ required: true,
3080
+ description: "Readable reason per expert that could not be started."
3081
+ }
3082
+ }
3083
+ },
3084
+ render: (_args, value) => [{
3085
+ type: "text",
3086
+ text: value.failed === 0 ? `Red team sent in: ${value.spawned} expert(s) over ${value.dimensions.length} dimension(s), pass ${value.review_pass}. They are attacking now; call crossfire_collect when they have reported.` : `Red team: ${value.spawned} expert(s) started, ${value.failed} failed. Reasons: ${value.failures.join(" | ")}`
3087
+ }]
3088
+ },
3089
+ async execute(args, exec) {
3090
+ const caller = requireAgent(exec);
3091
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3092
+ const reviewId = text(args.review_id);
3093
+ if (reviewId === "") throw new Error("review_id must not be empty");
3094
+ const defaults = config.getDefaults();
3095
+ const planned = await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
3096
+ const record = await requireRecord(stateRoot, reviewId);
3097
+ requireOwner(record, caller.id);
3098
+ assertBudget(record);
3099
+ requireStatus(record, ["draft", "attacking"]);
3100
+ const spec = INTENSITY_SPECS[record.intensity];
3101
+ const experts = [];
3102
+ for (const dimension of record.dimensions) {
3103
+ const count = Math.max(1, spec.expertsPerDimension);
3104
+ for (let index = 0; index < count; index += 1) experts.push({
3105
+ key: count === 1 ? dimension : `${dimension}-${index + 1}`,
3106
+ dimension,
3107
+ status: "pending",
3108
+ defectCount: 0
3109
+ });
3110
+ }
3111
+ const trimmed = experts.length > config.maxExperts ? experts.slice(0, config.maxExperts) : experts;
3112
+ if (record.reviewPass > 1 && record.history.length > 0 && record.defects.length === 0) record.defects = buildRecheckItems(record);
3113
+ record.experts = trimmed;
3114
+ record.status = "attacking";
3115
+ await writeRecord(stateRoot, record);
3116
+ return {
3117
+ record,
3118
+ experts: trimmed
3119
+ };
3120
+ });
3121
+ const failures = [];
3122
+ for (const expert of planned.experts) {
3123
+ const controller = new AbortController();
3124
+ try {
3125
+ const childId = await spawnExpert(ctx, memberConfig, planned.record, expert, caller, controller.signal, {
3126
+ maxTokens: defaults.expertMaxTokens,
3127
+ maxOpinions: defaults.expertMaxOpinions
3128
+ });
3129
+ expert.childId = childId;
3130
+ expert.status = "running";
3131
+ registerExpertChild(childId, {
3132
+ stateRoot,
3133
+ reviewId
3134
+ });
3135
+ } catch (error) {
3136
+ expert.status = "failed";
3137
+ expert.note = error instanceof Error ? error.message : String(error);
3138
+ failures.push(`${expert.key}: ${expert.note}`);
3139
+ }
3140
+ }
3141
+ await mutateRecord(stateRoot, reviewId, (record) => {
3142
+ record.experts = planned.experts;
3143
+ });
3144
+ return {
3145
+ spawned: planned.experts.filter((expert) => expert.status === "running").length,
3146
+ failed: failures.length,
3147
+ dimensions: planned.record.dimensions,
3148
+ review_pass: planned.record.reviewPass,
3149
+ failures
3150
+ };
3151
+ }
3152
+ }));
3153
+ ctx.tools.register(defineTool({
3154
+ name: "crossfire_collect",
3155
+ description: "Collect the red team's findings into adjudicable defects (attacking -> judging). Each utterance is split into 1-3 single-defect items (LLM first, deterministic local fallback), every dimension is normalized into the review's chosen set, and progress chatter is filtered out so the adjudication list stays clean. Idempotent per utterance. Defects you REJECTED in an earlier pass are not proposed again.",
3156
+ parameters: { review_id: {
3157
+ type: "string",
3158
+ required: true,
3159
+ description: "The review to collect for."
3160
+ } },
3161
+ output: {
3162
+ schema: {
3163
+ type: "object",
3164
+ additionalProperties: false,
3165
+ properties: {
3166
+ collected: {
3167
+ type: "integer",
3168
+ required: true
3169
+ },
3170
+ dropped_duplicates: {
3171
+ type: "integer",
3172
+ required: true
3173
+ },
3174
+ skipped_noise: {
3175
+ type: "integer",
3176
+ required: true
3177
+ },
3178
+ split_attempts: {
3179
+ type: "integer",
3180
+ required: true
3181
+ },
3182
+ malformed_lines: {
3183
+ type: "integer",
3184
+ required: true
3185
+ },
3186
+ total_defects: {
3187
+ type: "integer",
3188
+ required: true
3189
+ },
3190
+ status: {
3191
+ type: "string",
3192
+ required: true
3193
+ },
3194
+ recheck_round: {
3195
+ type: "boolean",
3196
+ required: true
3197
+ }
3198
+ }
3199
+ },
3200
+ render: (_args, value) => [{
3201
+ type: "text",
3202
+ text: value.recheck_round === true ? `Recheck round: no new defects collected (the recheck only verifies the previously endorsed ones). ${value.total_defects} item(s) await your verdict.` : `Collected ${value.collected} defect(s) into ${value.total_defects} total; dropped ${value.dropped_duplicates} duplicate(s) you had rejected, skipped ${value.skipped_noise} noise line(s). Status ${value.status} — the card now waits for your verdicts.`
3203
+ }]
3204
+ },
3205
+ async execute(args, exec) {
3206
+ const caller = requireAgent(exec);
3207
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3208
+ const reviewId = text(args.review_id);
3209
+ if (reviewId === "") throw new Error("review_id must not be empty");
3210
+ const record = await requireRecord(stateRoot, reviewId);
3211
+ requireOwner(record, caller.id);
3212
+ requireStatus(record, ["attacking", "judging"]);
3213
+ const transcript = await readTranscriptReport(stateRoot, reviewId);
3214
+ const llm = ctx.get("llm");
3215
+ const outcome = await collectDefects({
3216
+ record,
3217
+ transcript: transcript.items,
3218
+ malformed: transcript.malformed,
3219
+ llm,
3220
+ splitConfig: config.reviewSplit
3221
+ });
3222
+ const result = await mutateRecord(stateRoot, reviewId, (current) => {
3223
+ applyCollected(current, outcome.added);
3224
+ current.status = "judging";
3225
+ return {
3226
+ total: current.defects.length,
3227
+ status: current.status
3228
+ };
3229
+ });
3230
+ return {
3231
+ collected: outcome.added.length,
3232
+ dropped_duplicates: outcome.droppedDuplicates,
3233
+ skipped_noise: outcome.skippedNoise,
3234
+ split_attempts: outcome.splitAttempts,
3235
+ malformed_lines: outcome.malformedLines,
3236
+ total_defects: result?.total ?? record.defects.length,
3237
+ status: result?.status ?? "judging",
3238
+ recheck_round: record.reviewPass > 1 && record.history.length > 0
3239
+ };
3240
+ }
3241
+ }));
3242
+ ctx.tools.register(defineTool({
3243
+ name: "crossfire_finish",
3244
+ description: "Settle the review (judging -> settled): freeze the endorsed defects into the known-flaws checklist and archive this pass into history. Only call it after the user has adjudicated the defects on the card — Crossfire never decides for the human, and an unfinished list stays unfinished on purpose.",
3245
+ parameters: {
3246
+ review_id: {
3247
+ type: "string",
3248
+ required: true,
3249
+ description: "The review to settle."
3250
+ },
3251
+ revised_summary: {
3252
+ type: "string",
3253
+ description: "One line describing how the endorsed defects were addressed. Written into history so the NEXT pass can check exactly these items."
3254
+ }
3255
+ },
3256
+ output: {
3257
+ schema: {
3258
+ type: "object",
3259
+ additionalProperties: false,
3260
+ properties: {
3261
+ settled: {
3262
+ type: "boolean",
3263
+ required: true
3264
+ },
3265
+ endorsed: {
3266
+ type: "integer",
3267
+ required: true
3268
+ },
3269
+ rejected: {
3270
+ type: "integer",
3271
+ required: true
3272
+ },
3273
+ pending: {
3274
+ type: "integer",
3275
+ required: true
3276
+ },
3277
+ known_flaws: {
3278
+ type: "array",
3279
+ items: { type: "string" },
3280
+ required: true
3281
+ },
3282
+ review_pass: {
3283
+ type: "integer",
3284
+ required: true
3285
+ },
3286
+ max_review_pass: {
3287
+ type: "integer",
3288
+ required: true
3289
+ }
3290
+ }
3291
+ },
3292
+ render: (_args, value) => [{
3293
+ type: "text",
3294
+ text: `Review settled: ${value.endorsed} endorsed defect(s) frozen as the known-flaws checklist${value.pending > 0 ? ` (${value.pending} still unadjudicated)` : ""}. Pass ${value.review_pass}/${value.max_review_pass}. ` + (value.review_pass < value.max_review_pass ? "Use crossfire_recheck to verify the fixes in a new pass." : "Re-review cap reached — ask the user before opening another pass.")
3295
+ }]
3296
+ },
3297
+ async execute(args, exec) {
3298
+ const caller = requireAgent(exec);
3299
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3300
+ const reviewId = text(args.review_id);
3301
+ if (reviewId === "") throw new Error("review_id must not be empty");
3302
+ const record = await requireRecord(stateRoot, reviewId);
3303
+ requireOwner(record, caller.id);
3304
+ requireStatus(record, [
3305
+ "judging",
3306
+ "attacking",
3307
+ "settled"
3308
+ ]);
3309
+ const summary = text(args.revised_summary);
3310
+ const result = await mutateRecord(stateRoot, reviewId, (current) => {
3311
+ const counts = countDefects(current);
3312
+ current.history = [...current.history, {
3313
+ pass: current.reviewPass,
3314
+ subject: current.subject.content,
3315
+ stanceText: current.stance.text,
3316
+ dimensions: [...current.dimensions],
3317
+ intensity: current.intensity,
3318
+ defects: current.defects.map(cloneDefect),
3319
+ finishedAt: Date.now(),
3320
+ ...summary === "" ? {} : { revisedSummary: summary }
3321
+ }];
3322
+ current.status = "settled";
3323
+ current.settledAt = Date.now();
3324
+ return {
3325
+ counts,
3326
+ flaws: knownFlaws(current).map((defect) => `${defect.dimension}|${defect.content}`),
3327
+ pass: current.reviewPass,
3328
+ maxPass: current.maxReviewPass
3329
+ };
3330
+ });
3331
+ if (result === void 0) throw new Error(`no review "${reviewId}"`);
3332
+ return {
3333
+ settled: true,
3334
+ endorsed: result.counts.endorsed,
3335
+ rejected: result.counts.rejected,
3336
+ pending: result.counts.pending,
3337
+ known_flaws: result.flaws,
3338
+ review_pass: result.pass,
3339
+ max_review_pass: result.maxPass
3340
+ };
3341
+ }
3342
+ }));
3343
+ ctx.tools.register(defineTool({
3344
+ name: "crossfire_recheck",
3345
+ description: "Open the next review pass (settled -> attacking) that ONLY verifies the defects endorsed in the previous pass. No new fronts: the defect list becomes the previous pass's endorsed items, each defaulting to \"unfixed\", and collecting is skipped for this round. Capped by the intensity spec; beyond the cap the user must approve explicitly.",
3346
+ parameters: {
3347
+ review_id: {
3348
+ type: "string",
3349
+ required: true,
3350
+ description: "The settled review to re-check."
3351
+ },
3352
+ user_approved_extra_pass: {
3353
+ type: "boolean",
3354
+ description: "Set true ONLY when the user explicitly approved going beyond the re-review cap. Never set it on your own."
3355
+ }
3356
+ },
3357
+ output: {
3358
+ schema: {
3359
+ type: "object",
3360
+ additionalProperties: false,
3361
+ properties: {
3362
+ review_pass: {
3363
+ type: "integer",
3364
+ required: true
3365
+ },
3366
+ max_review_pass: {
3367
+ type: "integer",
3368
+ required: true
3369
+ },
3370
+ recheck_items: {
3371
+ type: "integer",
3372
+ required: true
3373
+ },
3374
+ status: {
3375
+ type: "string",
3376
+ required: true
3377
+ }
3378
+ }
3379
+ },
3380
+ render: (_args, value) => [{
3381
+ type: "text",
3382
+ text: `Recheck pass ${value.review_pass}/${value.max_review_pass} opened with ${value.recheck_items} item(s) to verify (status ${value.status}). Next: crossfire_attack — the red team now verifies whether each endorsed defect was actually fixed.`
3383
+ }]
3384
+ },
3385
+ async execute(args, exec) {
3386
+ const caller = requireAgent(exec);
3387
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3388
+ const reviewId = text(args.review_id);
3389
+ if (reviewId === "") throw new Error("review_id must not be empty");
3390
+ const record = await requireRecord(stateRoot, reviewId);
3391
+ requireOwner(record, caller.id);
3392
+ requireStatus(record, ["settled"]);
3393
+ const nextPass = record.reviewPass + 1;
3394
+ if (nextPass > record.maxReviewPass && args.user_approved_extra_pass !== true) throw new Error(`re-review cap reached (pass ${nextPass} > max ${record.maxReviewPass}) — ask the user to approve continuing, then retry with user_approved_extra_pass=true, or export and stop here`);
3395
+ const result = await mutateRecord(stateRoot, reviewId, (current) => {
3396
+ const items = buildRecheckItems(current);
3397
+ current.reviewPass = beginPass(current);
3398
+ current.maxReviewPass = args.user_approved_extra_pass === true ? Math.max(current.maxReviewPass, current.reviewPass) : current.maxReviewPass;
3399
+ current.defects = items;
3400
+ current.experts = [];
3401
+ current.status = "attacking";
3402
+ current.settledAt = void 0;
3403
+ return {
3404
+ pass: current.reviewPass,
3405
+ maxPass: current.maxReviewPass,
3406
+ items: items.length,
3407
+ status: current.status
3408
+ };
3409
+ });
3410
+ if (result === void 0) throw new Error(`no review "${reviewId}"`);
3411
+ return {
3412
+ review_pass: result.pass,
3413
+ max_review_pass: result.maxPass,
3414
+ recheck_items: result.items,
3415
+ status: result.status
3416
+ };
3417
+ }
3418
+ }));
3419
+ ctx.tools.register(defineTool({
3420
+ name: "crossfire_export",
3421
+ description: "Export the review as a Markdown deliverable (issue-style header, the subject, your stance, endorsed defects first, rejected viewpoints listed separately, red-team roster and pass history). Written to the review directory as export.md and returned in full.",
3422
+ parameters: { review_id: {
3423
+ type: "string",
3424
+ required: true,
3425
+ description: "The review to export."
3426
+ } },
3427
+ output: {
3428
+ schema: {
3429
+ type: "object",
3430
+ additionalProperties: false,
3431
+ properties: {
3432
+ path: {
3433
+ type: "string",
3434
+ required: true
3435
+ },
3436
+ bytes: {
3437
+ type: "integer",
3438
+ required: true
3439
+ },
3440
+ markdown: {
3441
+ type: "string",
3442
+ required: true
3443
+ }
3444
+ }
3445
+ },
3446
+ render: (_args, value) => [{
3447
+ type: "text",
3448
+ text: `Exported to ${value.path} (${value.bytes} bytes).`
3449
+ }]
3450
+ },
3451
+ async execute(args, exec) {
3452
+ const caller = requireAgent(exec);
3453
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3454
+ const reviewId = text(args.review_id);
3455
+ if (reviewId === "") throw new Error("review_id must not be empty");
3456
+ const record = await requireRecord(stateRoot, reviewId);
3457
+ requireOwner(record, caller.id);
3458
+ assertBudget(record, { allowMuted: true });
3459
+ const markdown = renderReviewMarkdown(record, countDefects(record));
3460
+ const path = exportFile(stateRoot, reviewId);
3461
+ const { writeTextAtomic: writeTextAtomic$1 } = await import("./state-CXNvzj_T.js");
3462
+ await writeTextAtomic$1(path, markdown);
3463
+ return {
3464
+ path,
3465
+ bytes: Buffer.byteLength(markdown, "utf8"),
3466
+ markdown
3467
+ };
3468
+ }
3469
+ }));
3470
+ ctx.tools.register(defineTool({
3471
+ name: "crossfire_stance",
3472
+ description: "Record the position and reasons of the person whose work is under review. This is what turns a neutral review into 针锋相对: once a stance exists, every red-team defect must answer one of its reasons and mark whether that reason was refuted (strikes_stance). The user can edit it on the card at any time. If they have not written one, distil it from the conversation and pass distilled=true — but never invent a position they did not express; a fabricated stance gives the red team a fake target.",
3473
+ parameters: {
3474
+ review_id: {
3475
+ type: "string",
3476
+ required: true,
3477
+ description: "The review to record the stance for."
3478
+ },
3479
+ text: {
3480
+ type: "string",
3481
+ required: true,
3482
+ description: "The position and its reasons in the user's own terms (≤2000 chars). Pass an empty string to clear it."
3483
+ },
3484
+ distilled: {
3485
+ type: "boolean",
3486
+ description: "Set true when YOU distilled this from the conversation rather than quoting the user verbatim."
3487
+ }
3488
+ },
3489
+ output: {
3490
+ schema: {
3491
+ type: "object",
3492
+ additionalProperties: false,
3493
+ properties: {
3494
+ saved: {
3495
+ type: "boolean",
3496
+ required: true
3497
+ },
3498
+ origin: {
3499
+ type: "string",
3500
+ required: true
3501
+ },
3502
+ length: {
3503
+ type: "integer",
3504
+ required: true
3505
+ }
3506
+ }
3507
+ },
3508
+ render: (_args, value) => [{
3509
+ type: "text",
3510
+ text: value.length === 0 ? "Stance cleared — the red team will attack by dimension only." : `Stance recorded (${value.length} chars, origin ${value.origin}). Each red-team reason must now be answered and marked refuted or not.`
3511
+ }]
3512
+ },
3513
+ async execute(args, exec) {
3514
+ const caller = requireAgent(exec);
3515
+ const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3516
+ const reviewId = text(args.review_id);
3517
+ if (reviewId === "") throw new Error("review_id must not be empty");
3518
+ requireOwner(await requireRecord(stateRoot, reviewId), caller.id);
3519
+ const stanceText = text(args.text).slice(0, 2e3);
3520
+ const distilled = args.distilled === true;
3521
+ const result = await mutateRecord(stateRoot, reviewId, (current) => {
3522
+ current.stance = {
3523
+ text: stanceText,
3524
+ origin: stanceText === "" ? "manual" : distilled ? "distilled" : "manual",
3525
+ ...distilled && stanceText !== "" ? { distilledAt: Date.now() } : {},
3526
+ updatedAt: Date.now()
3527
+ };
3528
+ return {
3529
+ origin: current.stance.origin,
3530
+ length: current.stance.text.length
3531
+ };
3532
+ });
3533
+ if (result === void 0) throw new Error(`no review "${reviewId}"`);
3534
+ return {
3535
+ saved: true,
3536
+ origin: result.origin,
3537
+ length: result.length
3538
+ };
3539
+ }
3540
+ }));
3541
+ ctx.tools.register(defineTool({
3542
+ name: "crossfire_report",
3543
+ description: "Submit your red-team findings for the review you belong to. Use the fixed \"观点 N(指向 X,维度Y):…\" structure — one defect per item — so each can be adjudicated separately by the human. Include graded evidence (repro steps for code/behaviour defects, an argument chain for design defects). Give no improvement suggestions: you only point out what does not hold.",
3544
+ parameters: { content: {
3545
+ type: "string",
3546
+ required: true,
3547
+ description: "Your findings: one item per defect, each starting with 观点 N(指向 …,维度 …):."
3548
+ } },
3549
+ output: {
3550
+ schema: {
3551
+ type: "object",
3552
+ additionalProperties: false,
3553
+ properties: {
3554
+ recorded: {
3555
+ type: "boolean",
3556
+ required: true
3557
+ },
3558
+ pass: {
3559
+ type: "integer",
3560
+ required: true
3561
+ },
3562
+ tokens_charged: {
3563
+ type: "integer",
3564
+ required: true
3565
+ },
3566
+ budget_remaining_tokens: {
3567
+ type: "integer",
3568
+ required: true
3569
+ }
3570
+ }
3571
+ },
3572
+ render: (_args, value) => [{
3573
+ type: "text",
3574
+ text: `Findings recorded (pass ${value.pass}). Token budget remaining: ${value.budget_remaining_tokens}.`
3575
+ }]
3576
+ },
3577
+ async execute(args, exec) {
3578
+ const caller = requireAgent(exec);
3579
+ const located = await locateExpertChildWithGrace(caller.id);
3580
+ if (located === void 0) throw new Error("crossfire_report is only for red-team experts of a running review (if you were just spawned, retry once — your review binding may still be settling)");
3581
+ const content = text(args.content);
3582
+ if (content === "") throw new Error("content must not be empty");
3583
+ const record = await readRecord(located.stateRoot, located.reviewId);
3584
+ if (record === void 0) throw new Error(`no review "${located.reviewId}"`);
3585
+ assertBudget(record);
3586
+ const expert = record.experts.find((candidate) => candidate.childId === caller.id);
3587
+ const expertKey = expert?.key ?? "unknown";
3588
+ const utterance = {
3589
+ id: randomUUID(),
3590
+ expertKey,
3591
+ kind: "speech",
3592
+ content: content.slice(0, 2e4),
3593
+ pass: record.reviewPass,
3594
+ ts: Date.now()
3595
+ };
3596
+ await appendTranscript(located.stateRoot, located.reviewId, utterance);
3597
+ const charged = estimateTokens(utterance.content);
3598
+ const remaining = await mutateRecord(located.stateRoot, located.reviewId, (current) => {
3599
+ chargeTokens(current, utterance.content);
3600
+ if (expert !== void 0) {
3601
+ const target = current.experts.find((candidate) => candidate.childId === caller.id);
3602
+ if (target !== void 0) {
3603
+ target.status = "done";
3604
+ target.reportCount = (target.reportCount ?? 0) + 1;
3605
+ }
3606
+ }
3607
+ return Math.max(0, current.budget.maxTokens - current.budget.usedTokens);
3608
+ });
3609
+ return {
3610
+ recorded: true,
3611
+ pass: utterance.pass,
3612
+ tokens_charged: charged,
3613
+ budget_remaining_tokens: remaining ?? 0
3614
+ };
3615
+ }
3616
+ }));
3617
+ }
3618
+ /**
3619
+ * 归属识别说明:`crossfire_report` 只认**专家索引**(expert-events 的进程内
3620
+ * childId → 评审位置)。专家是通过 `startContinuable` 拉起的子代理,唯一可靠的
3621
+ * 归属线索就是它的 childId。刻意不去"扫所有评审找 ownerSessionId 匹配" ——
3622
+ * 那样会让一个普通会话冒充专家提交发言。
3623
+ */
3624
+
3625
+ //#endregion
3626
+ //#region src/web-guard.ts
3627
+ /** Request-body cap for the plugin RPC route (1 MiB — same order as the host's own API). */
3628
+ const MAX_RPC_BODY_BYTES = 1048576;
3629
+ /** Pick the fence out of a possibly-absent service (never load-gating). */
3630
+ function connectionFenceOf(service) {
3631
+ if (service === null || service === void 0) return void 0;
3632
+ const candidate = service;
3633
+ if (typeof candidate.requestRejection !== "function") return void 0;
3634
+ return candidate;
3635
+ }
3636
+ /**
3637
+ * 建一个"每次调用都重新取栅栏"的探针。
3638
+ *
3639
+ * ## 为什么不是一次性解析(这是一条真实的安全修复)
3640
+ *
3641
+ * 插件路由只在第一次注册时创建,而注册带标记位、不会重来。若在**注册那一刻**把
3642
+ * `connectionFenceOf(ctx.get('connection'))` 的结果快照进闭包,那么:
3643
+ * - 那一刻 `connection` 还没挂载 → 快照到 `undefined` → 两条路由**永久**没有信任
3644
+ * 栅栏(DNS rebinding 后的同源页面、或免预检的 `text/plain` 简单 POST 都能命中);
3645
+ * - 宿主稍后才挂上 `connection` 也不会重新注册,缺口不会被补上。
3646
+ *
3647
+ * 这就是一条 fail-open 窗口。改成每次请求实时取之后,栅栏一旦可用就立即生效,代价
3648
+ * 只是一次属性探测。抽成函数是为了能被单测直接钉住(见 `test/web-guard.test.mjs`)。
3649
+ *
3650
+ * @param getService - 返回当前的 `connection` 服务(缺席时为 `undefined`)。
3651
+ * @param onAbsent - 只在**首次**发现栅栏缺席时调用一次(用于记一条降级日志)。
3652
+ */
3653
+ function fenceProbe(getService, onAbsent) {
3654
+ let warned = false;
3655
+ return () => {
3656
+ const fence = connectionFenceOf(getService());
3657
+ if (fence === void 0 && !warned) {
3658
+ warned = true;
3659
+ onAbsent?.();
3660
+ }
3661
+ return fence;
3662
+ };
3663
+ }
3664
+ /**
3665
+ * Decide whether one inbound request may proceed.
3666
+ *
3667
+ * @returns the HTTP status to reject with, or `undefined` to proceed.
3668
+ *
3669
+ * A composition without the Connection service has no fence to apply and is let
3670
+ * through — the plugin must stay usable in minimal profiles. A fence that
3671
+ * *throws* is treated as a rejection: this is a security boundary, and failing
3672
+ * open would silently undo it.
3673
+ */
3674
+ function rejectWebRequest(fence, headers) {
3675
+ if (fence === void 0) return void 0;
3676
+ try {
3677
+ return fence.requestRejection({ headers });
3678
+ } catch {
3679
+ return 403;
3680
+ }
3681
+ }
3682
+
3683
+ //#endregion
3684
+ //#region src/index.ts
3685
+ /** 插件名(cordis entry id / 日志前缀 / bundle patch 里的 id)。 */
3686
+ const name = "crossfire";
3687
+ /**
3688
+ * REQUIRED services only —— 这份清单是**加载门禁**,所以它必须短。
3689
+ *
3690
+ * cordis 没有"可选注入"这种形式(`Inject` 只有 `string[] | Record`,而 record
3691
+ * 形式是拦截配置),因此写在这里的每个名字都会让整个插件 fiber 保持 PENDING
3692
+ * 直到该服务存在:没有工具、没有 `/plugins/dsh-plugin-crossfire/*` 路由、没有
3693
+ * 卡片、也没有设置页。而缺一个**可选**能力却写在这里,在用户眼里就是"针锋整个
3694
+ * 不见了",且任何地方都不报错。
3695
+ *
3696
+ * 所以 `llm` / `webServer` / `workspaceRegistry` / `connection` 一律**不列**,
3697
+ * 全部经 `ctx.get(...)` 或 harness-compat 的探测层懒取:
3698
+ * - `llm` 缺席时,观点拆分退到本地启发式兜底(零 token、确定性);
3699
+ * - `webServer` 缺席时,只有设置页与卡片失联,host 侧与工具照常工作;
3700
+ * - `connection` 缺席时,RPC 只走插件自有路由。
3701
+ */
3702
+ const inject = [
3703
+ "tools",
3704
+ "subagents",
3705
+ "agents",
3706
+ "systemPrompt"
3707
+ ];
3708
+ /**
3709
+ * 配置 schema(schemastery)。
3710
+ *
3711
+ * 刻意**不**写 `: z<Config>` 注解:schemastery 的 `.default()` 会把字段推导成
3712
+ * `T | Volatile<T>`(例如 `string | Volatile<string>`),而手写接口写的是
3713
+ * `string | undefined`,二者不兼容会让 `tsc --emitDeclarationOnly` 以 TS2322
3714
+ * 失败 —— 注意 `--noEmit` **不报**这个错,所以它只在生成声明时暴露。
3715
+ * schema 自己就是配置形状的权威来源;下面的 `Config` 接口只用于 `apply` 的签名。
3716
+ */
3717
+ const Config = z.object({
3718
+ stateDir: z.string().default(".crossfire"),
3719
+ memberProvider: z.string().default("spawn"),
3720
+ maxExperts: z.natural().min(1).default(7),
3721
+ defaultIntensity: z.union([
3722
+ "quick",
3723
+ "standard",
3724
+ "deep"
3725
+ ]).default("standard"),
3726
+ memberMaxDepth: z.natural().default(1),
3727
+ promptSectionOrder: z.natural().default(117)
3728
+ });
3729
+ function apply(ctx, config) {
3730
+ const resolved = {
3731
+ stateDir: config.stateDir ?? ".crossfire",
3732
+ memberProvider: config.memberProvider ?? "spawn",
3733
+ maxExperts: config.maxExperts ?? 7,
3734
+ defaultIntensity: config.defaultIntensity ?? "standard",
3735
+ memberMaxDepth: config.memberMaxDepth ?? 1
3736
+ };
3737
+ ctx.systemPrompt.section({
3738
+ name: "crossfire:usage",
3739
+ order: config.promptSectionOrder ?? 117,
3740
+ text: usageSectionText()
3741
+ });
3742
+ const prefsStore = createPreferenceStore({
3743
+ base: defaultPreferences(resolved.defaultIntensity),
3744
+ log: (message, error) => {
3745
+ if (error === void 0) ctx.logger.warn(message);
3746
+ else ctx.logger.warn(message, error);
3747
+ }
3748
+ });
3749
+ const runtime = {
3750
+ prefs: prefsStore,
3751
+ tools: {
3752
+ stateDir: resolved.stateDir,
3753
+ memberProvider: resolved.memberProvider,
3754
+ maxExperts: resolved.maxExperts,
3755
+ ...resolved.memberMaxDepth === void 0 ? {} : { memberMaxDepth: resolved.memberMaxDepth },
3756
+ reviewSplit: {
3757
+ provider: "deepseek-official",
3758
+ model: "deepseek-v4-flash",
3759
+ maxOpinions: 3
3760
+ },
3761
+ getDefaults: () => {
3762
+ const prefs = prefsStore.get();
3763
+ return {
3764
+ intensity: prefs.defaultIntensity,
3765
+ maxRounds: prefs.maxRounds,
3766
+ maxTokens: prefs.maxTokens,
3767
+ expertMaxTokens: prefs.expertMaxTokens,
3768
+ expertMaxOpinions: prefs.expertMaxOpinions,
3769
+ customDimensions: prefs.customDimensions
3770
+ };
3771
+ }
3772
+ }
3773
+ };
3774
+ const applyDefaultIntensity = (preference) => {
3775
+ if (preference.defaultIntensity === "quick" || preference.defaultIntensity === "standard" || preference.defaultIntensity === "deep") resolved.defaultIntensity = preference.defaultIntensity;
3776
+ };
3777
+ runtime.prefs.watch(applyDefaultIntensity);
3778
+ runtime.prefs.ready().then(() => {
3779
+ applyDefaultIntensity(runtime.prefs.get());
3780
+ }).catch(() => {});
3781
+ registerCrossfireTools(ctx, runtime.tools);
3782
+ attachExpertEvents(ctx);
3783
+ const dispatchRpc = registerRpc(ctx, runtime);
3784
+ if (typeof ctx.get("connection")?.rpc?.handle === "function") {
3785
+ if (!effectWithOptionalDisposer(ctx, ctx.get("connection").rpc.handle("/crossfire", (endpoint, payload) => dispatchRpc(endpoint, payload)), "crossfire: rpc channel")) ctx.logger.warn("crossfire: connection.rpc.handle returned no disposer — the fallback channel may leak");
3786
+ } else ctx.logger.warn("crossfire: connection.rpc.handle is unavailable — the card will only reach the host over its own web route");
3787
+ let rpcRouteRegistered = false;
3788
+ let stateRouteRegistered = false;
3789
+ const registerWebSurface = () => {
3790
+ const webServer = webRouteHostOf(ctx);
3791
+ if (webServer === void 0) {
3792
+ ctx.logger.warn("crossfire: no web route host available — the settings page will not reach the host");
3793
+ return;
3794
+ }
3795
+ const currentFence = fenceProbe(() => ctx.get("connection"), () => ctx.logger.warn("crossfire: connection.requestRejection is unavailable — plugin web routes run without the Host/Origin + browser-auth fence"));
3796
+ if (!rpcRouteRegistered) {
3797
+ rpcRouteRegistered = true;
3798
+ ctx.effect(() => webServer.register({
3799
+ kind: "exact",
3800
+ path: RPC_ROUTE,
3801
+ handler: async (req, res) => {
3802
+ const send = (status, body$1) => {
3803
+ res.writeHead(status, {
3804
+ "content-type": "application/json; charset=utf-8",
3805
+ "cache-control": "no-store"
3806
+ });
3807
+ res.end(JSON.stringify(body$1));
3808
+ };
3809
+ const rejection = rejectWebRequest(currentFence(), req.headers);
3810
+ if (rejection !== void 0) {
3811
+ send(rejection, {
3812
+ ok: false,
3813
+ error: {
3814
+ code: "forbidden",
3815
+ message: "connection trust check failed"
3816
+ }
3817
+ });
3818
+ return;
3819
+ }
3820
+ if ((req.method ?? "GET").toUpperCase() !== "POST") {
3821
+ send(405, {
3822
+ ok: false,
3823
+ error: {
3824
+ code: "internal",
3825
+ message: "this route accepts POST only"
3826
+ }
3827
+ });
3828
+ return;
3829
+ }
3830
+ let body;
3831
+ try {
3832
+ const chunks = [];
3833
+ let size = 0;
3834
+ for await (const chunk of req) {
3835
+ size += chunk.length;
3836
+ if (size > MAX_RPC_BODY_BYTES) {
3837
+ send(413, {
3838
+ ok: false,
3839
+ error: {
3840
+ code: "internal",
3841
+ message: "request body too large"
3842
+ }
3843
+ });
3844
+ return;
3845
+ }
3846
+ chunks.push(chunk);
3847
+ }
3848
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
3849
+ } catch {
3850
+ send(400, {
3851
+ ok: false,
3852
+ error: {
3853
+ code: "internal",
3854
+ message: "body must be JSON"
3855
+ }
3856
+ });
3857
+ return;
3858
+ }
3859
+ const message = body;
3860
+ if (message === null || typeof message !== "object" || typeof message.endpoint !== "string") {
3861
+ send(400, {
3862
+ ok: false,
3863
+ error: {
3864
+ code: "internal",
3865
+ message: "body must be { endpoint, payload? }"
3866
+ }
3867
+ });
3868
+ return;
3869
+ }
3870
+ send(200, await dispatchRpc(message.endpoint, message.payload));
3871
+ }
3872
+ }), "crossfire: rpc route");
3873
+ }
3874
+ if (!stateRouteRegistered) {
3875
+ stateRouteRegistered = true;
3876
+ ctx.effect(() => webServer.register({
3877
+ kind: "exact",
3878
+ path: STATE_ROUTE,
3879
+ handler: async (req, res) => {
3880
+ const send = (status, payload) => {
3881
+ res.writeHead(status, {
3882
+ "content-type": "application/json; charset=utf-8",
3883
+ "cache-control": "no-store"
3884
+ });
3885
+ res.end(JSON.stringify(payload));
3886
+ };
3887
+ const rejection = rejectWebRequest(currentFence(), req.headers);
3888
+ if (rejection !== void 0) {
3889
+ send(rejection, {
3890
+ reviews: [],
3891
+ note: "connection trust check failed"
3892
+ });
3893
+ return;
3894
+ }
3895
+ const session = new URL(req.url ?? "/", "http://x").searchParams.get("session") ?? "";
3896
+ const collected = await collectSnapshots(ctx, resolved.stateDir, session);
3897
+ sendJsonConditional(req.headers, res, collected);
3898
+ }
3899
+ }), "crossfire: snapshot route");
3900
+ }
3901
+ };
3902
+ registerWebSurface();
3903
+ ctx.on("internal/service", (serviceName) => {
3904
+ if (WEB_SERVER_KEYS.includes(serviceName) || WORKSPACE_KEYS.includes(serviceName)) registerWebSurface();
3905
+ });
3906
+ }
3907
+
3908
+ //#endregion
3909
+ export { Config, apply, inject, name };