@y9931666/dsh-plugin-crossfire 0.1.0 → 0.1.1

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 CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { a as newReviewId, c as reviewDirOf, d as withCrossfireLock, f as writeJsonAtomic, i as listReviewIds, l as reviewLockKey, m as writeTextAtomicWithBom, n as appendJsonLine, o as readJsonFile, p as writeTextAtomic, s as readJsonLines, u as stateRootOf } from "./state-DvF_Jqcg.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { dirname, join } from "node:path";
@@ -157,6 +157,16 @@ function transcriptFile(stateRoot, reviewId) {
157
157
  function exportFile(stateRoot, reviewId) {
158
158
  return join(reviewDirOf(stateRoot, reviewId), "export.md");
159
159
  }
160
+ /**
161
+ * `<reviewDir>/export.txt` —— 同一份内容的**人读副本**(UTF-8 带 BOM)。
162
+ *
163
+ * 为什么导出两份:`.md` 交给 Markdown 渲染器,但 Windows 上双击 `.md` 常被别的程序
164
+ * 抢走,加上无 BOM 的 UTF-8 会被记事本误判成 ANSI —— 于是"导出物打不开"。`.txt` 带
165
+ * BOM 后双击直接进记事本,中文也不乱码(用户反馈的正是这一点)。
166
+ */
167
+ function exportTextFile(stateRoot, reviewId) {
168
+ return join(reviewDirOf(stateRoot, reviewId), "export.txt");
169
+ }
160
170
  /** 通用:取一个非空字符串,否则返回 undefined。 */
161
171
  function asText(value, max = 4096) {
162
172
  if (typeof value !== "string") return void 0;
@@ -250,6 +260,8 @@ function sanitizeExpert(value) {
250
260
  if (model !== void 0) expert.model = model;
251
261
  const role = asText(raw.role, 2e3);
252
262
  if (role !== void 0) expert.role = role;
263
+ const persona = asText(raw.persona, 32e3);
264
+ if (persona !== void 0) expert.persona = persona;
253
265
  const note = asText(raw.note, 500);
254
266
  if (note !== void 0) expert.note = note;
255
267
  return expert;
@@ -1478,6 +1490,29 @@ const TAIL_SECTION = /\[(?:核心产出|下一步建议)[^\]]*\]/;
1478
1490
  /** 括号里可能出现的维度引导词,去掉它们才剩下维度名。 */
1479
1491
  const DIMENSION_LABEL_WORD = /^(维度|方面|角度|类别|类型)/;
1480
1492
  /**
1493
+ * 专家报告里的「针对立场」标记(`redteam.ts` 的 `reportEnvelopeSpec` 规定每条缺陷里
1494
+ * 恰好有一行)。`未` 只允许出现在 `打穿` 之前 —— 见 {@link stanceMarkerStrikes}。
1495
+ */
1496
+ const STANCE_MARKER = /针对\s*立场[^\n]{0,20}?[::]\s*(未\s*)?打穿/;
1497
+ /**
1498
+ * 判定这段文字里专家自己写的「针对立场」结论是不是**打穿**。
1499
+ *
1500
+ * ## 为什么不能只写 `includes('打穿')`(这是真实存在的坑)
1501
+ *
1502
+ * `未打穿` 的后两个字就是 `打穿`,纯子串匹配会把"没打穿"读成"打穿了",方向整个反过来。
1503
+ * 所以这里匹配有界的标记(`针对立场…:打穿|未打穿`)并**显式取反**:只有 `未` 缺席时
1504
+ * 才算打穿;标记压根不存在(含 `undefined`)也一律算没打穿。
1505
+ *
1506
+ * 之所以要有这个本地判定:专家的信封格式是**固定**的,报告正文里本来就写着结论。
1507
+ * 模型拆分失败退到本地启发式({@link splitByMarkers})时不该把这个信号丢掉 ——
1508
+ * 那正是"打穿立场"标签永远显示不出来的原因之一。
1509
+ */
1510
+ function stanceMarkerStrikes(text$1) {
1511
+ if (text$1 === void 0) return false;
1512
+ const found = STANCE_MARKER.exec(text$1);
1513
+ return found !== null && found[1] === void 0;
1514
+ }
1515
+ /**
1481
1516
  * 从段首括号的文字里提取维度,并交给 `resolveDimension` 归一化。
1482
1517
  *
1483
1518
  * 上一版的兜底把括号里的圆圈数字(①)直接当成了维度名,产出 `维度②`
@@ -1527,10 +1562,12 @@ function splitByMarkers(content, allowed) {
1527
1562
  const text$1 = raw.replace(/^[\s*::]+/, "").replace(/\s+/g, " ").trim();
1528
1563
  if (text$1 === "") continue;
1529
1564
  const { dimension, fallback } = dimensionOfSegmentHead(mark.head, allowed);
1565
+ const strikes = stanceMarkerStrikes(mark.head) || stanceMarkerStrikes(text$1.slice(0, 160));
1530
1566
  lines.push({
1531
1567
  content: `观点 ${mark.label}(${mark.head}):${text$1}`,
1532
1568
  dimension,
1533
1569
  ...fallback ? { dimensionFallback: true } : {},
1570
+ ...strikes ? { strikesStance: true } : {},
1534
1571
  quote: void 0
1535
1572
  });
1536
1573
  }
@@ -1541,10 +1578,10 @@ function splitByMarkers(content, allowed) {
1541
1578
  * quote 与发言原文做规范化子串校验(防线③),不匹配即丢弃。
1542
1579
  * dimension 一律过 `resolveDimension`(落库值必须 ∈ allowed)。
1543
1580
  */
1544
- async function splitUtterance(llm, config, expertKey, content, allowed) {
1581
+ async function splitUtterance(llm, config, expertKey, content, allowed, stance) {
1545
1582
  const trimmed = content.replace(/\s+/g, " ").trim();
1546
1583
  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}`);
1584
+ 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用户在本次评审里写下的「立场与理由」:\n${stance.trim() === "" ? "(未填写立场)用户这次**没有**写下立场与理由。因此不得把任何一条缺陷标成 strikes_stance=true(没有靶子就没有打穿)。" : stance.trim()}\n\n专家 ${expertKey} 的发言:\n${trimmed}`);
1548
1585
  if (raw !== null) {
1549
1586
  let parsed;
1550
1587
  try {
@@ -1564,6 +1601,7 @@ async function splitUtterance(llm, config, expertKey, content, allowed) {
1564
1601
  const candidate = obj.quote.replace(/\s+/g, " ").trim();
1565
1602
  if (candidate !== "" && candidate.length <= 120 && trimmed.includes(candidate)) quote$1 = candidate;
1566
1603
  }
1604
+ const strikes = obj?.strikes_stance === true || stanceMarkerStrikes(line) || stanceMarkerStrikes(quote$1);
1567
1605
  const resolved = resolveDimension(obj?.dimension, allowed);
1568
1606
  let evidence;
1569
1607
  const ev = obj?.evidence;
@@ -1578,7 +1616,7 @@ async function splitUtterance(llm, config, expertKey, content, allowed) {
1578
1616
  content: line,
1579
1617
  quote: quote$1,
1580
1618
  dimension: resolved.dimension,
1581
- ...obj?.strikes_stance === true ? { strikesStance: true } : {},
1619
+ ...strikes ? { strikesStance: true } : {},
1582
1620
  ...resolved.fallback ? { dimensionFallback: true } : {},
1583
1621
  evidence
1584
1622
  });
@@ -1624,10 +1662,8 @@ function planExperts(record, maxExperts, roster = []) {
1624
1662
  };
1625
1663
  if (matched !== void 0) {
1626
1664
  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
- }
1665
+ if (matched.provider !== void 0 && matched.provider.trim() !== "") expert.provider = matched.provider.trim();
1666
+ if (matched.model !== void 0 && matched.model.trim() !== "") expert.model = matched.model.trim();
1631
1667
  }
1632
1668
  experts.push(expert);
1633
1669
  }
@@ -1713,7 +1749,7 @@ async function collectDefects(input) {
1713
1749
  };
1714
1750
  try {
1715
1751
  return {
1716
- lines: await splitUtterance(activeLlm, input.splitConfig, item.utterance.expertKey, item.content, allowed),
1752
+ lines: await splitUtterance(activeLlm, input.splitConfig, item.utterance.expertKey, item.content, allowed, record.stance.text),
1717
1753
  usedLlm: true
1718
1754
  };
1719
1755
  } catch {
@@ -1785,10 +1821,37 @@ function rosterFrom(input) {
1785
1821
  }
1786
1822
  /**
1787
1823
  * 把收集结果并入记录(原地修改;调用方负责落盘)。
1824
+ *
1825
+ * ## 幂等(A2)
1826
+ *
1827
+ * 此前是纯拼接(`[...record.defects, ...added]`),于是**同一条发言被收集两次**
1828
+ * 就在裁决面上出现两条一模一样的缺陷:重试、双击、两个并发请求都能触发它。
1829
+ * 用户面对的是一个"必须点两次驳回、理由还得写两遍"的列表。
1830
+ *
1831
+ * 现在按 `defect.id` 去重(`id` 由 `collectDefects` 从 `utteranceId` + 序号生成,
1832
+ * 因此同一批输入两次得到同一批 id):**保留先到的那一条**,后来的同 id 项直接丢弃。
1833
+ * 保留先到的而不是新的,是因为"用户已经开始表态"的那一条不能被后来的副本顶掉
1834
+ * (缺陷的三态与驳回理由挂在 id 上,换掉实例等于丢掉已做出的判断)。
1835
+ *
1836
+ * `collectDefects` 里的 `utteranceId` 幂等键是第一道闸,这里的是第二道:前者防的是
1837
+ * "转录又被读了一遍",后者防的是"合并写回被施加了同一批结果" —— 两道都需要,
1838
+ * 因为并发请求可以在第一道闸之后、第二道闸之前交错。
1839
+ *
1840
+ * @returns **实际追加的条数**(被去重丢弃的不算)。调用方必须如实上报这个数字,
1841
+ * 而不是 `added.length` —— 那会把"请求成功了但什么都没新增"报成
1842
+ * "新增了 N 条",用户看到条数却对不上列表。
1788
1843
  */
1789
1844
  function applyCollected(record, added) {
1790
- record.defects = [...record.defects, ...added];
1845
+ const known = new Set(record.defects.map((defect) => defect.id));
1846
+ const fresh = [];
1847
+ for (const defect of added) {
1848
+ if (known.has(defect.id)) continue;
1849
+ known.add(defect.id);
1850
+ fresh.push(defect);
1851
+ }
1852
+ record.defects = [...record.defects, ...fresh];
1791
1853
  for (const expert of record.experts) expert.defectCount = record.defects.filter((defect) => defect.expertKey === expert.key).length;
1854
+ return fresh.length;
1792
1855
  }
1793
1856
 
1794
1857
  //#endregion
@@ -2042,6 +2105,64 @@ function expertWelcome(input) {
2042
2105
  //#region src/members.ts
2043
2106
  /** 专家子代理的 label 前缀(持久化为该子会话的创建标签,便于在宿主侧辨认)。 */
2044
2107
  const EXPERT_LABEL_PREFIX = "crossfire-expert:";
2108
+ /** 取一个"真的写了东西"的字符串;空串与纯空白一律视为**缺席**。 */
2109
+ function nonEmpty(value) {
2110
+ if (value === void 0) return void 0;
2111
+ const trimmed = value.trim();
2112
+ return trimmed === "" ? void 0 : trimmed;
2113
+ }
2114
+ /**
2115
+ * 把"专家显式指定的路由"与"宿主默认路由"收敛成一份确定的路由决定(A5 + B3)。
2116
+ *
2117
+ * ## 一条铁律
2118
+ *
2119
+ * **写回记录的值 = 真正下发的值。** 记录与下发必须由**同一次决定**产生,否则界面
2120
+ * 会显示一条并非事实的路由(红队抓出的原缺陷:只填了 provider 的成员,记录里却被
2121
+ * 写成 `defaultProvider` + `defaultModel` —— 界面说跑 A,实际跑 B)。
2122
+ *
2123
+ * ## 四种组合的行为(全都是确定的,没有"看情况")
2124
+ *
2125
+ * | 专家显式指定 | agentOptions(下发) | 写回记录 |
2126
+ * |---|---|---|
2127
+ * | provider + model | `{provider, model}` | provider、model |
2128
+ * | 只有 provider | `{provider}` | provider(**不补 model**) |
2129
+ * | 只有 model | `{defaultProvider?, model}` | provider(补上的那个)、model |
2130
+ * | 都没有(继承) | `{}`(不干预) | defaultProvider、defaultModel |
2131
+ *
2132
+ * 逐条理由:
2133
+ * - **只有 provider 时刻意不补 model**:`provider` 自己会选它的默认模型,我们没有
2134
+ * 任何依据知道那会是哪一个;拿 `config.defaultModel` 去顶替就是编造(那正是原
2135
+ * 缺陷的写法)。记录的 `model` 缺席是诚实的"不知道"。
2136
+ * - **只有 model 时补 `defaultProvider`**:一个不给 provider 的 model id 在多数路由上
2137
+ * 无法解析,而"只写 model、不写 provider"在用户眼里就是"用默认 provider 跑这个
2138
+ * 模型"。补上的值同时写回记录 —— 于是界面显示的正是真正下发的。
2139
+ * - **都没有时 agentOptions 为空**:这一种情形下我们**不替宿主做决定**,真正的继承
2140
+ * 必须保持是继承。记录里那份 `defaultProvider`/`defaultModel` 是配置里声明的
2141
+ * "继承主持人时**实际生效**的模型路由"(见 index.ts / ToolsConfig 的同一段注释),
2142
+ * 写它只为把"继承"画成真实品牌块(否则头像永远是中性灰 `?`,那是用户报过的缺陷)。
2143
+ * 这是**唯一**一处记录值不等于 agentOptions 的字段 —— 而它们仍然同源:记录里出现的
2144
+ * 每一个字段,要么是我们显式下发的,要么是宿主默认路由本身。
2145
+ * - **取不到生效值的字段一律不写**(保持"缺失 = 继承"的诚实语义),也不写空串。
2146
+ *
2147
+ * 纯函数:没有 I/O、没有时间、不改入参,所以四种组合都能被单测直接钉住
2148
+ * (见 `test/members.test.mjs`)。
2149
+ */
2150
+ function resolveExpertRoute(expert, config) {
2151
+ const explicitProvider = nonEmpty(expert.provider);
2152
+ const explicitModel = nonEmpty(expert.model);
2153
+ const fallbackProvider = nonEmpty(config.defaultProvider);
2154
+ const fallbackModel = nonEmpty(config.defaultModel);
2155
+ const agentOptions = {};
2156
+ if (explicitProvider !== void 0) agentOptions.provider = explicitProvider;
2157
+ else if (explicitModel !== void 0 && fallbackProvider !== void 0) agentOptions.provider = fallbackProvider;
2158
+ if (explicitModel !== void 0) agentOptions.model = explicitModel;
2159
+ const route = { agentOptions };
2160
+ if (agentOptions.provider !== void 0) route.provider = agentOptions.provider;
2161
+ else if (explicitProvider === void 0 && explicitModel === void 0 && fallbackProvider !== void 0) route.provider = fallbackProvider;
2162
+ if (agentOptions.model !== void 0) route.model = agentOptions.model;
2163
+ else if (explicitProvider === void 0 && explicitModel === void 0 && fallbackModel !== void 0) route.model = fallbackModel;
2164
+ return route;
2165
+ }
2045
2166
  /**
2046
2167
  * 拉起一位红队专家,返回它的子会话 id。
2047
2168
  *
@@ -2064,12 +2185,14 @@ async function spawnExpert(ctx, config, record, expert, parent, signal, limits =
2064
2185
  maxOpinions: limits.maxOpinions ?? 0,
2065
2186
  ...expert.role === void 0 ? {} : { role: expert.role }
2066
2187
  };
2067
- const agentOptions = {};
2068
- if (expert.provider !== void 0 && expert.model !== void 0) {
2069
- agentOptions.provider = expert.provider;
2070
- agentOptions.model = expert.model;
2071
- }
2188
+ const route = resolveExpertRoute(expert, config);
2189
+ const agentOptions = { ...route.agentOptions };
2072
2190
  if (limits.maxTokens !== void 0 && limits.maxTokens > 0) agentOptions.maxTokens = limits.maxTokens;
2191
+ if (route.provider === void 0) delete expert.provider;
2192
+ else expert.provider = route.provider;
2193
+ if (route.model === void 0) delete expert.model;
2194
+ else expert.model = route.model;
2195
+ const persona = expertPersona(input);
2073
2196
  const started = await ctx.subagents.startContinuable({
2074
2197
  provider: config.provider,
2075
2198
  label: `${EXPERT_LABEL_PREFIX}${record.reviewId}:${expert.key}`,
@@ -2079,13 +2202,14 @@ async function spawnExpert(ctx, config, record, expert, parent, signal, limits =
2079
2202
  text: expertWelcome(input)
2080
2203
  }],
2081
2204
  parent,
2082
- persona: expertPersona(input),
2205
+ persona,
2083
2206
  toolFilter: expertToolRestriction(),
2084
2207
  ...Object.keys(agentOptions).length > 0 ? { agentOptions } : {},
2085
2208
  ...config.maxDepth === void 0 ? {} : { maxDepth: config.maxDepth }
2086
2209
  },
2087
2210
  signal
2088
2211
  });
2212
+ expert.persona = persona;
2089
2213
  return String(started.childId);
2090
2214
  }
2091
2215
 
@@ -2102,7 +2226,7 @@ async function spawnExpert(ctx, config, record, expert, parent, signal, limits =
2102
2226
  * @module dsh-plugin-crossfire/version
2103
2227
  */
2104
2228
  /** 当前插件版本(必须与 package.json 的 version 逐字一致)。 */
2105
- const PLUGIN_VERSION = "0.1.0";
2229
+ const PLUGIN_VERSION = "0.1.1";
2106
2230
  /** 导出物头部使用的插件标识行。 */
2107
2231
  const PLUGIN_ID = `@y9931666/dsh-plugin-crossfire v${PLUGIN_VERSION}`;
2108
2232
  /** DeepSeek Harness 版本区间(导出头部展示用)。
@@ -2594,6 +2718,35 @@ function registerRpc(ctx, runtime) {
2594
2718
  status
2595
2719
  });
2596
2720
  }
2721
+ case "crossfire/expert.prompt": {
2722
+ const expertKey = typeof body.expertKey === "string" ? body.expertKey : "";
2723
+ if (expertKey === "") return fail("payload must be { reviewId, expertKey }");
2724
+ const located = await load(reviewId, session, preferredRoot);
2725
+ const expert = located.record.experts.find((item) => item.key === expertKey);
2726
+ if (expert === void 0) return fail(`no expert "${expertKey}" in this review`);
2727
+ if (expert.persona !== void 0 && expert.persona !== "") return ok({
2728
+ expertKey,
2729
+ dimension: expert.dimension,
2730
+ persona: expert.persona,
2731
+ rebuilt: false
2732
+ });
2733
+ const persona = expertPersona({
2734
+ reviewId: located.record.reviewId,
2735
+ dimension: expert.dimension,
2736
+ title: located.record.subject.title,
2737
+ subject: located.record.subject.content,
2738
+ stance: located.record.stance.text,
2739
+ dimensions: located.record.dimensions,
2740
+ maxOpinions: runtime.tools.getDefaults().expertMaxOpinions,
2741
+ ...expert.role === void 0 ? {} : { role: expert.role }
2742
+ });
2743
+ return ok({
2744
+ expertKey,
2745
+ dimension: expert.dimension,
2746
+ persona,
2747
+ rebuilt: true
2748
+ });
2749
+ }
2597
2750
  case "crossfire/expert.setRole": {
2598
2751
  const expertKey = typeof body.expertKey === "string" ? body.expertKey : "";
2599
2752
  if (expertKey === "") return fail("payload must be { reviewId, expertKey, role }");
@@ -2654,8 +2807,16 @@ function registerRpc(ctx, runtime) {
2654
2807
  const markdown = renderReviewMarkdown(located.record, countDefects(located.record));
2655
2808
  const path = exportFile(located.stateRoot, reviewId);
2656
2809
  await writeTextAtomic(path, markdown);
2810
+ let textPath;
2811
+ try {
2812
+ textPath = exportTextFile(located.stateRoot, reviewId);
2813
+ await writeTextAtomicWithBom(textPath, markdown);
2814
+ } catch {
2815
+ textPath = "";
2816
+ }
2657
2817
  return ok({
2658
2818
  path,
2819
+ textPath,
2659
2820
  bytes: Buffer.byteLength(markdown, "utf8"),
2660
2821
  markdown
2661
2822
  });
@@ -2685,13 +2846,14 @@ async function advance(ctx, runtime, located, step, body, session) {
2685
2846
  const reviewId = located.record.reviewId;
2686
2847
  const defaults = runtime.tools.getDefaults();
2687
2848
  if (step === "attack") {
2688
- if (await readRecord(stateRoot, reviewId) === void 0) return fail(`no review "${reviewId}"`);
2689
2849
  const agent = agentOfSession(ctx, session);
2690
2850
  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 () => {
2851
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2692
2852
  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)`);
2853
+ if (current === void 0) return fail(`no review "${reviewId}"`);
2854
+ if (current.status !== "draft" && current.status !== "attacking") return fail(`review "${reviewId}" is "${current.status}" — cannot send in the red team now (call crossfire_recheck to open a new pass on a settled review)`);
2855
+ const running = current.experts.filter((expert) => expert.status === "running");
2856
+ if (running.length > 0) return fail(`experts are already running for this review (${running.map((expert) => expert.key).join(", ")}) — wait for them to report, or collect what they have already submitted`);
2695
2857
  const prefs = runtime.prefs.get();
2696
2858
  const roster = rosterFrom({
2697
2859
  squads: prefs.squads.map((squad) => ({
@@ -2715,99 +2877,101 @@ async function advance(ctx, runtime, located, step, body, session) {
2715
2877
  current.experts = experts;
2716
2878
  current.status = "attacking";
2717
2879
  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}`);
2880
+ const failures = [];
2881
+ for (const expert of experts) {
2882
+ const controller = new AbortController();
2883
+ try {
2884
+ const childId = await spawnExpert(ctx, {
2885
+ provider: runtime.tools.memberProvider,
2886
+ ...runtime.tools.memberMaxDepth === void 0 ? {} : { maxDepth: runtime.tools.memberMaxDepth },
2887
+ ...runtime.tools.defaultProvider === void 0 ? {} : { defaultProvider: runtime.tools.defaultProvider },
2888
+ ...runtime.tools.defaultModel === void 0 ? {} : { defaultModel: runtime.tools.defaultModel }
2889
+ }, current, expert, agent, controller.signal, {
2890
+ maxTokens: defaults.expertMaxTokens,
2891
+ maxOpinions: defaults.expertMaxOpinions
2892
+ });
2893
+ expert.childId = childId;
2894
+ expert.status = "running";
2895
+ registerExpertChild(childId, {
2896
+ stateRoot,
2897
+ reviewId
2898
+ });
2899
+ } catch (error) {
2900
+ expert.status = "failed";
2901
+ expert.note = error instanceof Error ? error.message : String(error);
2902
+ failures.push(`${expert.key}: ${expert.note}`);
2903
+ }
2744
2904
  }
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
2905
+ current.experts = experts;
2906
+ await writeRecord(stateRoot, current);
2907
+ return ok({
2908
+ spawned: experts.filter((expert) => expert.status === "running").length,
2909
+ failed: failures.length,
2910
+ failures
2911
+ });
2753
2912
  });
2754
2913
  }
2755
2914
  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
2915
  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);
2916
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2917
+ const current = await readRecord(stateRoot, reviewId);
2918
+ if (current === void 0) return fail(`no review "${reviewId}"`);
2919
+ if (current.status !== "attacking" && current.status !== "judging") return fail(`review "${reviewId}" is "${current.status}" — cannot collect now`);
2920
+ const transcript = await readTranscriptReport(stateRoot, reviewId);
2921
+ const outcome = await collectDefects({
2922
+ record: current,
2923
+ transcript: transcript.items,
2924
+ malformed: transcript.malformed,
2925
+ llm,
2926
+ splitConfig: runtime.tools.reviewSplit
2927
+ });
2928
+ const appended = applyCollected(current, outcome.added);
2770
2929
  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
2930
+ await writeRecord(stateRoot, current);
2931
+ return ok({
2932
+ collected: appended,
2933
+ droppedDuplicates: outcome.droppedDuplicates,
2934
+ skippedNoise: outcome.skippedNoise,
2935
+ splitAttempts: outcome.splitAttempts,
2936
+ recheckRound: current.reviewPass > 1 && current.history.length > 0
2937
+ });
2778
2938
  });
2779
2939
  }
2780
2940
  if (step === "finish") {
2781
2941
  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)
2942
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2943
+ const current = await readRecord(stateRoot, reviewId);
2944
+ if (current === void 0) return fail(`no review "${reviewId}"`);
2945
+ if (current.status !== "judging" && current.status !== "attacking" && current.status !== "settled") return fail(`review "${reviewId}" is "${current.status}" — cannot settle now`);
2946
+ const snapshot = {
2947
+ pass: current.reviewPass,
2948
+ subject: current.subject.content,
2949
+ stanceText: current.stance.text,
2950
+ dimensions: [...current.dimensions],
2951
+ intensity: current.intensity,
2952
+ defects: current.defects.map((defect) => ({ ...defect })),
2953
+ finishedAt: Date.now(),
2954
+ ...summary === "" ? {} : { revisedSummary: summary }
2955
+ };
2956
+ const existing = current.history.findIndex((item) => item.pass === current.reviewPass);
2957
+ if (existing >= 0) current.history[existing] = snapshot;
2958
+ else current.history = [...current.history, snapshot];
2959
+ current.status = "settled";
2960
+ current.settledAt = Date.now();
2961
+ await writeRecord(stateRoot, current);
2962
+ return ok({
2963
+ settled: true,
2964
+ counts: countDefects(current)
2965
+ });
2802
2966
  });
2803
2967
  }
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
2968
  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) => {
2969
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
2970
+ const current = await readRecord(stateRoot, reviewId);
2971
+ if (current === void 0) return fail(`no review "${reviewId}"`);
2972
+ if (current.status !== "settled") return fail(`review "${reviewId}" is "${current.status}" — only a settled review can be re-checked`);
2973
+ const nextPass = current.reviewPass + 1;
2974
+ if (nextPass > current.maxReviewPass && !approved) return fail(`re-review cap reached (pass ${nextPass} > max ${current.maxReviewPass}) — the user must approve continuing`, "cap-reached");
2811
2975
  const items = buildRecheckItems(current);
2812
2976
  current.reviewPass = beginPass(current);
2813
2977
  if (approved) current.maxReviewPass = Math.max(current.maxReviewPass, current.reviewPass);
@@ -2815,14 +2979,11 @@ async function advance(ctx, runtime, located, step, body, session) {
2815
2979
  current.experts = [];
2816
2980
  current.status = "attacking";
2817
2981
  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
2982
+ await writeRecord(stateRoot, current);
2983
+ return ok({
2984
+ reviewPass: current.reviewPass,
2985
+ recheckItems: items.length
2986
+ });
2826
2987
  });
2827
2988
  }
2828
2989
  /**
@@ -2918,7 +3079,9 @@ function cloneDefect(defect) {
2918
3079
  function registerCrossfireTools(ctx, config) {
2919
3080
  const memberConfig = {
2920
3081
  provider: config.memberProvider,
2921
- ...config.memberMaxDepth === void 0 ? {} : { maxDepth: config.memberMaxDepth }
3082
+ ...config.memberMaxDepth === void 0 ? {} : { maxDepth: config.memberMaxDepth },
3083
+ ...config.defaultProvider === void 0 ? {} : { defaultProvider: config.defaultProvider },
3084
+ ...config.defaultModel === void 0 ? {} : { defaultModel: config.defaultModel }
2922
3085
  };
2923
3086
  ctx.tools.register(defineTool({
2924
3087
  name: "crossfire_open",
@@ -3097,6 +3260,8 @@ function registerCrossfireTools(ctx, config) {
3097
3260
  requireOwner(record, caller.id);
3098
3261
  assertBudget(record);
3099
3262
  requireStatus(record, ["draft", "attacking"]);
3263
+ const running = record.experts.filter((expert) => expert.status === "running");
3264
+ if (running.length > 0) throw new Error(`experts are already running for review "${reviewId}" (${running.map((expert) => expert.key).join(", ")}) — wait for them to report, or collect what they have already submitted`);
3100
3265
  const spec = INTENSITY_SPECS[record.intensity];
3101
3266
  const experts = [];
3102
3267
  for (const dimension of record.dimensions) {
@@ -3113,40 +3278,40 @@ function registerCrossfireTools(ctx, config) {
3113
3278
  record.experts = trimmed;
3114
3279
  record.status = "attacking";
3115
3280
  await writeRecord(stateRoot, record);
3281
+ const failures = [];
3282
+ for (const expert of trimmed) {
3283
+ const controller = new AbortController();
3284
+ try {
3285
+ const childId = await spawnExpert(ctx, memberConfig, record, expert, caller, controller.signal, {
3286
+ maxTokens: defaults.expertMaxTokens,
3287
+ maxOpinions: defaults.expertMaxOpinions
3288
+ });
3289
+ expert.childId = childId;
3290
+ expert.status = "running";
3291
+ registerExpertChild(childId, {
3292
+ stateRoot,
3293
+ reviewId
3294
+ });
3295
+ } catch (error) {
3296
+ expert.status = "failed";
3297
+ expert.note = error instanceof Error ? error.message : String(error);
3298
+ failures.push(`${expert.key}: ${expert.note}`);
3299
+ }
3300
+ }
3301
+ record.experts = trimmed;
3302
+ await writeRecord(stateRoot, record);
3116
3303
  return {
3117
3304
  record,
3118
- experts: trimmed
3305
+ experts: trimmed,
3306
+ failures
3119
3307
  };
3120
3308
  });
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
3309
  return {
3145
3310
  spawned: planned.experts.filter((expert) => expert.status === "running").length,
3146
- failed: failures.length,
3311
+ failed: planned.failures.length,
3147
3312
  dimensions: planned.record.dimensions,
3148
3313
  review_pass: planned.record.reviewPass,
3149
- failures
3314
+ failures: planned.failures
3150
3315
  };
3151
3316
  }
3152
3317
  }));
@@ -3207,36 +3372,33 @@ function registerCrossfireTools(ctx, config) {
3207
3372
  const stateRoot = stateRootOf(workspaceOf(caller), config.stateDir);
3208
3373
  const reviewId = text(args.review_id);
3209
3374
  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";
3375
+ return await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
3376
+ const record = await requireRecord(stateRoot, reviewId);
3377
+ requireOwner(record, caller.id);
3378
+ requireStatus(record, ["attacking", "judging"]);
3379
+ const transcript = await readTranscriptReport(stateRoot, reviewId);
3380
+ const llm = ctx.get("llm");
3381
+ const outcome = await collectDefects({
3382
+ record,
3383
+ transcript: transcript.items,
3384
+ malformed: transcript.malformed,
3385
+ llm,
3386
+ splitConfig: config.reviewSplit
3387
+ });
3388
+ const appended = applyCollected(record, outcome.added);
3389
+ record.status = "judging";
3390
+ await writeRecord(stateRoot, record);
3225
3391
  return {
3226
- total: current.defects.length,
3227
- status: current.status
3392
+ collected: appended,
3393
+ dropped_duplicates: outcome.droppedDuplicates,
3394
+ skipped_noise: outcome.skippedNoise,
3395
+ split_attempts: outcome.splitAttempts,
3396
+ malformed_lines: outcome.malformedLines,
3397
+ total_defects: record.defects.length,
3398
+ status: record.status,
3399
+ recheck_round: record.reviewPass > 1 && record.history.length > 0
3228
3400
  };
3229
3401
  });
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
3402
  }
3241
3403
  }));
3242
3404
  ctx.tools.register(defineTool({
@@ -3458,10 +3620,16 @@ function registerCrossfireTools(ctx, config) {
3458
3620
  assertBudget(record, { allowMuted: true });
3459
3621
  const markdown = renderReviewMarkdown(record, countDefects(record));
3460
3622
  const path = exportFile(stateRoot, reviewId);
3461
- const { writeTextAtomic: writeTextAtomic$1 } = await import("./state-CXNvzj_T.js");
3623
+ const { writeTextAtomic: writeTextAtomic$1, writeTextAtomicWithBom: writeTextAtomicWithBom$1 } = await import("./state-B2anbn4l.js");
3462
3624
  await writeTextAtomic$1(path, markdown);
3625
+ let textPath = "";
3626
+ try {
3627
+ textPath = exportTextFile(stateRoot, reviewId);
3628
+ await writeTextAtomicWithBom$1(textPath, markdown);
3629
+ } catch {}
3463
3630
  return {
3464
3631
  path,
3632
+ textPath,
3465
3633
  bytes: Buffer.byteLength(markdown, "utf8"),
3466
3634
  markdown
3467
3635
  };
@@ -3724,6 +3892,8 @@ const Config = z.object({
3724
3892
  "deep"
3725
3893
  ]).default("standard"),
3726
3894
  memberMaxDepth: z.natural().default(1),
3895
+ defaultProvider: z.string().default("deepseek-official"),
3896
+ defaultModel: z.string().default("deepseek-flash"),
3727
3897
  promptSectionOrder: z.natural().default(117)
3728
3898
  });
3729
3899
  function apply(ctx, config) {
@@ -3732,7 +3902,9 @@ function apply(ctx, config) {
3732
3902
  memberProvider: config.memberProvider ?? "spawn",
3733
3903
  maxExperts: config.maxExperts ?? 7,
3734
3904
  defaultIntensity: config.defaultIntensity ?? "standard",
3735
- memberMaxDepth: config.memberMaxDepth ?? 1
3905
+ memberMaxDepth: config.memberMaxDepth ?? 1,
3906
+ defaultProvider: config.defaultProvider ?? "deepseek-official",
3907
+ defaultModel: config.defaultModel ?? "deepseek-flash"
3736
3908
  };
3737
3909
  ctx.systemPrompt.section({
3738
3910
  name: "crossfire:usage",
@@ -3752,6 +3924,8 @@ function apply(ctx, config) {
3752
3924
  stateDir: resolved.stateDir,
3753
3925
  memberProvider: resolved.memberProvider,
3754
3926
  maxExperts: resolved.maxExperts,
3927
+ defaultProvider: resolved.defaultProvider,
3928
+ defaultModel: resolved.defaultModel,
3755
3929
  ...resolved.memberMaxDepth === void 0 ? {} : { memberMaxDepth: resolved.memberMaxDepth },
3756
3930
  reviewSplit: {
3757
3931
  provider: "deepseek-official",