md-zh-translation-skill 1.2.2 → 1.2.3

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.
@@ -13,6 +13,7 @@ export const INITIAL_TRANSLATION_PROMPT = `
13
13
  2. 译文段落数必须与原文完全一致。每个英文段落对应一个中文段落,不得合并、拆分、重排。
14
14
  3. 只有以下内容第一次出现时才必须中英文对照,优先使用“中文(英文)”格式:人名、机构名、公司名、产品名、论文/期刊/会议名、专有项目名,以及对理解文章确实关键且中文读者未必熟悉的专业术语。这里的“关键专业术语”包括两类:一是明显的领域术语;二是虽然是常见科学名词,但在本文中反复出现、承载核心发现或后文持续围绕其展开的概念,例如某种材料、器官部位、结构名称、实验过程或关键机制。文章标题、各级标题、列表项中的首次出现也算首次出现。若某一列表或并列结构中首次出现多个州名、地区名、风暴名或术语,要逐项补齐,不要只给列表整体补一次。注意:中英文对照只针对这些局部元素本身,不要把整条标题、导语句、小标题或列表项原句整句附上英文;像 Earth、reptiles、paleontologist 这类通用名词、职业称谓、类群名或常见科学词,不要为了凑双语而强行加英文。
15
15
  如果上文上下文里已经给出“前文已完成首现锚定的专名/术语”清单,则清单内条目及其明显简称都视为已经在全文前文完成首现双语锚定;它们在当前分块标题、各级标题、列表项或正文里再次出现时,不要重复补首次中英文对照。
16
+ 对没有成熟中文主译的产品名、系统名、工具名、命令名、框架名或操作系统名,如果需要建立首现锚定,可使用“英文原名(中文说明)”“中文说明(英文原名)”或其他自然的中英说明形式;严禁写成“Foo(Foo)”“Linux(Linux)”“Claude Code(Claude Code)”这种英文重复括注。
16
17
  4. 缩写第一次出现时,优先写成“中文全称(英文全称,缩写)”或最自然的中英文对照形式;若保留英文缩写并补中文解释,必须使用中文全角括号,例如“CNN(美国有线电视新闻网)”,不要写成“CNN (...)”。后文保持译法一致。
17
18
  5. 数字、年份、单位、比较关系、因果关系、条件关系必须准确,不得遗漏、增补或偷换。
18
19
  6. 如果原文使用英制数量和单位,请按以下规则处理:
@@ -63,6 +64,7 @@ export const GATE_AUDIT_PROMPT = `
63
64
  1. paragraph_match:段落数和段落顺序是否严格对应原文。
64
65
  2. first_mention_bilingual:人名、机构名、公司名、产品名、论文/期刊/会议名、专有项目名以及真正关键的专业术语第一次出现时是否完成中英文对照;标题、各级标题、列表项和正文中的第一次出现都要检查。这里的关键术语不仅包括明显的领域术语,也包括在本文中反复出现、承载核心发现或后文持续围绕其展开的科学名词。遇到州名、地区名、风暴名、并列术语或列表枚举时,要逐项检查。同时要判定对照范围是否过宽;如果把整条标题、导语句、小标题或列表项原句整句附上英文,或者给 Earth、reptiles、paleontologist 这类通用名词、职业称谓、类群名、常见科学词硬加英文,也应判为不通过。若同一核心概念存在多个英文变体,要检查是否在首次出现时就建立了稳定的双语对应,不能到后文某个变体出现时才第一次补英文。
65
66
  如果上文上下文里已经给出“前文已完成首现锚定的专名/术语”清单,则清单内条目及其明显简称一律视为已经在全文前文完成首现双语锚定;即使它们在当前分块标题、各级标题、列表项或正文里是本块第一次出现,也不得再按“首现缺少中英文对照”判错。
67
+ 对没有成熟中文主译的产品名、系统名、工具名、命令名、框架名或操作系统名,如果译文写成“Foo(Foo)”这类英文重复括注,仍应判为不通过;这类情况必须改成带中文说明的自然中英锚定形式。
66
68
  3. numbers_units_logic:数字、年份、单位、比较关系、逻辑关系是否没有明显错漏。
67
69
  4. chinese_punctuation:是否符合中文标点习惯;如果保留完整英文段落,该英文段落内部可保留英文标点,不单独判错。中文句内若保留英文缩写并补中文解释,括号必须是全角,例如“CNN(美国有线电视新闻网)”。
68
70
  5. unit_conversion_boundary:长度、重量、华氏温度、以英寸表示的累计降水量是否按规则补常见换算,其他单位是否没有被擅自换算。
@@ -112,6 +114,7 @@ export const BUNDLED_GATE_AUDIT_PROMPT = `
112
114
  1. paragraph_match:该 segment 的段落数和段落顺序是否严格对应原文。
113
115
  2. first_mention_bilingual:该 segment 中的人名、机构名、公司名、产品名、论文/期刊/会议名、专有项目名以及真正关键的专业术语第一次出现时是否完成中英文对照;标题、各级标题、列表项和正文中的第一次出现都要检查。这里的关键术语不仅包括明显的领域术语,也包括在本文中反复出现、承载核心发现或后文持续围绕其展开的科学名词。遇到州名、地区名、风暴名、并列术语或列表枚举时,要逐项检查。同时要判定对照范围是否过宽;如果把整条标题、导语句、小标题或列表项原句整句附上英文,或者给 Earth、reptiles、paleontologist 这类通用名词、职业称谓、类群名、常见科学词硬加英文,也应判为不通过。若同一核心概念存在多个英文变体,要检查是否在首次出现时就建立了稳定的双语对应,不能到后文某个变体出现时才第一次补英文。
114
116
  如果上文上下文里已经给出“前文已完成首现锚定的专名/术语”清单,则清单内条目及其明显简称一律视为已经在全文前文完成首现双语锚定;即使它们在当前分块标题、各级标题、列表项或正文里是本块第一次出现,也不得再按“首现缺少中英文对照”判错。
117
+ 对没有成熟中文主译的产品名、系统名、工具名、命令名、框架名或操作系统名,如果译文写成“Foo(Foo)”这类英文重复括注,仍应判为不通过;这类情况必须改成带中文说明的自然中英锚定形式。
115
118
  3. numbers_units_logic:数字、年份、单位、比较关系、逻辑关系是否没有明显错漏。
116
119
  4. chinese_punctuation:是否符合中文标点习惯;如果保留完整英文段落,该英文段落内部可保留英文标点,不单独判错。中文句内若保留英文缩写并补中文解释,括号必须是全角,例如“CNN(美国有线电视新闻网)”。
117
120
  5. unit_conversion_boundary:长度、重量、华氏温度、以英寸表示的累计降水量是否按规则补常见换算,其他单位是否没有被擅自换算。
@@ -143,6 +146,7 @@ export const REPAIR_PROMPT = `
143
146
  5. 没有列入 must_fix 的段落尽量不改;如果某段原本已经有正确的中英文对照、数字、单位换算或中文标点,修复时不得删除、改丢或简化。
144
147
  6. 对“首次出现中英文对照”问题,修复后的首次出现必须直接写成“中文(英文)”或等价的自然双语形式;标题、各级标题、列表项也适用,但只补局部专名或真正关键的专业术语,不得把整条标题、导语句、小标题或列表项原句整句附上英文,也不得给通用名词、职业称谓、类群名或常见科学词硬加英文。若某个科学名词在本文中反复出现、承载核心发现或后文持续围绕其展开,即使它本身是常见名词,也应按关键术语处理。若 must_fix 指出的是同一核心概念家族前后不一致,修复时要统一主译法和双语锚点。
145
148
  如果上文上下文里已经给出“前文已完成首现锚定的专名/术语”清单,则清单内条目及其明显简称都视为已经在全文前文完成首现双语锚定;修复时不要因为当前分块标题、各级标题、列表项或正文里再次出现这些条目,就重复补首现中英文对照。
149
+ 对没有成熟中文主译的产品名、系统名、工具名、命令名、框架名或操作系统名,不要修成“Foo(Foo)”这类英文重复括注;应改成“英文原名(中文说明)”“中文说明(英文原名)”或其他自然的中英说明形式。
146
150
  7. 如果 must_fix 同时要求“保持原文名”和“补中文对照”,目标形式应是“中文(英文)”,不要只保留英文或只保留中文。
147
151
  8. 如果同一段里有多条 must_fix,允许一次性补齐,但修完后要保留这段原有的其他正确信息,不得因为补一个术语而删掉另一个已正确的对照或换算。
148
152
  9. 输出前按以下顺序自检:段落对应、首现中英对照、数字单位逻辑、中文标点、单位换算边界,确认 must_fix 中列出的每一项都已经修掉,并确认原本已正确的中英文对照和单位换算没有被修丢。
@@ -45,14 +45,8 @@ export function protectMarkdownSpans(body) {
45
45
  return { protectedBody, spans };
46
46
  }
47
47
  export function protectSegmentFormattingSpans(body, startIndex = 1) {
48
- const spans = [];
49
- const register = (kind, raw) => {
50
- const id = createPlaceholder(kind, startIndex + spans.length);
51
- spans.push({ id, kind, raw });
52
- return id;
53
- };
54
- const protectedBody = mapOutsideInlineCode(body, (text) => protectInlineMarkdownLinks(text, register));
55
- return { protectedBody, spans };
48
+ void startIndex;
49
+ return { protectedBody: body, spans: [] };
56
50
  }
57
51
  function protectFencedCodeBlocks(input, register) {
58
52
  const lines = input.split(/(?<=\n)/);
@@ -13,6 +13,7 @@ export type TranslateOptions = {
13
13
  cwd?: string;
14
14
  sourcePathHint?: string;
15
15
  model?: string;
16
+ postDraftModel?: string;
16
17
  executor?: CodexExecutor;
17
18
  formatter?: typeof formatTranslatedBody;
18
19
  onProgress?: (message: string, stage: TranslateProgress) => void;
@@ -6,6 +6,7 @@ import { planMarkdownChunks } from "./markdown-chunks.js";
6
6
  import { extractFrontmatter, protectMarkdownSpans, protectSegmentFormattingSpans, reprotectMarkdownSpans, restoreMarkdownSpans } from "./markdown-protection.js";
7
7
  const DEFAULT_MODEL = "gpt-5.4-mini";
8
8
  const MAX_REPAIR_CYCLES = 2;
9
+ const MAX_MUST_FIX_PER_REPAIR_CALL = 1;
9
10
  const DRAFT_REASONING_EFFORT = "medium";
10
11
  const AUDIT_REASONING_EFFORT = "medium";
11
12
  const REPAIR_REASONING_EFFORT = "low";
@@ -191,7 +192,11 @@ function report(options, stage, message) {
191
192
  export async function translateMarkdownArticle(source, options = {}) {
192
193
  const executor = options.executor ?? new DefaultCodexExecutor();
193
194
  const formatter = options.formatter ?? formatTranslatedBody;
194
- const model = options.model ?? (process.env.TRANSLATION_MODEL?.trim() || DEFAULT_MODEL);
195
+ const draftModel = options.model ?? (process.env.TRANSLATION_MODEL?.trim() || DEFAULT_MODEL);
196
+ const postDraftModel = options.postDraftModel ?? (process.env.POST_DRAFT_MODEL?.trim() || draftModel);
197
+ const postDraftReasoningEffort = process.env.POST_DRAFT_REASONING_EFFORT?.trim()
198
+ ? process.env.POST_DRAFT_REASONING_EFFORT.trim()
199
+ : undefined;
195
200
  const cwd = options.cwd ?? process.cwd();
196
201
  const sourcePathHint = options.sourcePathHint ?? "article.md";
197
202
  const { frontmatter, body } = extractFrontmatter(source);
@@ -203,20 +208,26 @@ export async function translateMarkdownArticle(source, options = {}) {
203
208
  let repairCyclesUsed = 0;
204
209
  let styleApplied = false;
205
210
  let establishedTerms = [];
211
+ let nextLocalSpanIndex = spanIndex.size + 1;
206
212
  for (const chunk of chunkPlan.chunks) {
207
213
  const chunkResult = await translateProtectedChunk(chunk, chunkPlan, {
208
214
  cwd,
209
215
  executor,
210
- model,
216
+ draftModel,
217
+ postDraftModel,
211
218
  options,
212
219
  sourcePathHint,
213
220
  spanIndex,
214
- establishedTerms
221
+ establishedTerms,
222
+ nextLocalSpanIndex,
223
+ draftReasoningEffort: DRAFT_REASONING_EFFORT,
224
+ postDraftReasoningEffort
215
225
  });
216
226
  restoredChunks.push(chunkResult.body + chunk.separatorAfter);
217
227
  gateAudits.push(chunkResult.gateAudit);
218
228
  repairCyclesUsed += chunkResult.repairCyclesUsed;
219
229
  styleApplied = styleApplied || chunkResult.styleApplied;
230
+ nextLocalSpanIndex = chunkResult.nextLocalSpanIndex;
220
231
  establishedTerms = mergeEstablishedTerms(establishedTerms, collectEstablishedTerms(chunk.source, chunkResult.body));
221
232
  }
222
233
  report(options, "format", "Formatting translated Markdown.");
@@ -225,7 +236,7 @@ export async function translateMarkdownArticle(source, options = {}) {
225
236
  const markdown = reconstructMarkdown(frontmatter, formattedBody);
226
237
  return {
227
238
  markdown,
228
- model,
239
+ model: draftModel,
229
240
  repairCyclesUsed,
230
241
  styleApplied,
231
242
  gateAudit: mergeGateAudits(gateAudits),
@@ -264,7 +275,7 @@ async function translateProtectedChunk(chunk, plan, context) {
264
275
  const draftedSegments = [];
265
276
  const fixedSegments = [];
266
277
  let repairCyclesUsed = 0;
267
- let nextLocalSpanIndex = context.spanIndex.size + 1;
278
+ let nextLocalSpanIndex = context.nextLocalSpanIndex;
268
279
  for (const segment of segments) {
269
280
  if (segment.kind === "fixed") {
270
281
  fixedSegments.push(segment);
@@ -289,6 +300,7 @@ async function translateProtectedChunk(chunk, plan, context) {
289
300
  repairCyclesUsed += 1;
290
301
  const failedSegmentCount = bundledAudit.segments.filter((audit) => !isHardPass(audit)).length;
291
302
  report(context.options, "repair", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: repair cycle ${repairCyclesUsed} of ${MAX_REPAIR_CYCLES} for ${failedSegmentCount} failed segment(s).`);
303
+ const repairedSegmentIndices = new Set();
292
304
  for (const segmentAudit of bundledAudit.segments) {
293
305
  if (isHardPass(segmentAudit) || segmentAudit.must_fix.length === 0) {
294
306
  continue;
@@ -298,8 +310,9 @@ async function translateProtectedChunk(chunk, plan, context) {
298
310
  throw new HardGateError(`Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: unknown segment ${segmentAudit.segment_index} in bundled audit.`);
299
311
  }
300
312
  await repairDraftedSegment(draftedSegment, segmentAudit.must_fix, plan, context, chunkLabel);
313
+ repairedSegmentIndices.add(draftedSegment.segment.index + 1);
301
314
  }
302
- bundledAudit = await runBundledGateAudit(draftedSegments, plan, context, chunkPromptContext, chunkLabel);
315
+ bundledAudit = await runPostRepairGateAudit(draftedSegments, bundledAudit, repairedSegmentIndices, plan, context, chunkPromptContext, chunkLabel);
303
316
  }
304
317
  if (!isBundledHardPass(bundledAudit)) {
305
318
  const remaining = bundledAudit.segments
@@ -324,13 +337,20 @@ async function translateProtectedChunk(chunk, plan, context) {
324
337
  report(context.options, "style", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: applying style polish after hard gate pass.`);
325
338
  const styleResult = await context.executor.execute(withChunkContext(buildStylePolishPrompt(hardPassProtectedSource, hardPassProtectedChunk), chunkStylePromptContext), {
326
339
  cwd: context.cwd,
327
- model: context.model,
328
- reasoningEffort: STYLE_REASONING_EFFORT,
340
+ model: context.postDraftModel,
341
+ reasoningEffort: context.postDraftReasoningEffort ?? STYLE_REASONING_EFFORT,
329
342
  onStderr: (stderrChunk) => reportChunkProgress(context.options, "style", chunkPromptContext.chunkIndex - 1, plan, chunkLabel, stderrChunk)
330
343
  });
331
344
  try {
332
- restoredChunkBody = restoreMarkdownSpans(styleResult.text, chunkSpans);
333
- styleApplied = true;
345
+ const normalizedStyleText = stripAddedInlineCodeFromPlainPaths(hardPassProtectedSource, styleResult.text);
346
+ restoredChunkBody = restoreMarkdownSpans(normalizedStyleText, chunkSpans);
347
+ if (looksLikeMetaTaskResponse(restoredChunkBody)) {
348
+ report(context.options, "style", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: style polish returned task-management or refusal text; falling back to the hard-pass translation.`);
349
+ restoredChunkBody = hardPassBody;
350
+ }
351
+ else {
352
+ styleApplied = true;
353
+ }
334
354
  }
335
355
  catch (error) {
336
356
  if (!(error instanceof HardGateError)) {
@@ -343,24 +363,74 @@ async function translateProtectedChunk(chunk, plan, context) {
343
363
  body: restoredChunkBody,
344
364
  repairCyclesUsed,
345
365
  styleApplied,
346
- gateAudit: mergeGateAudits(bundledAudit.segments)
366
+ gateAudit: mergeGateAudits(bundledAudit.segments),
367
+ nextLocalSpanIndex
347
368
  };
348
369
  }
370
+ function looksLikeMetaTaskResponse(text) {
371
+ const trimmed = text.trim();
372
+ if (!trimmed) {
373
+ return false;
374
+ }
375
+ const patterns = [
376
+ /当前任务未提供.*issue/i,
377
+ /缺少\s*GitLab\s*项目与\s*issue\s*信息/i,
378
+ /缺少\s*GitLab\s*项目(?:信息)?/i,
379
+ /任务必须先绑定.*issue/i,
380
+ /按仓库内.*AGENTS\.md.*规则/i,
381
+ /请先提供.*issue/i,
382
+ /提供对应的项目链接和 issue 编号/i,
383
+ /请先提供.*项目链接/i,
384
+ /请提供对应项目链接/i,
385
+ /无法访问\s*GitLab/i,
386
+ /无法创建或访问项目/i,
387
+ /回复精确短语\s*`?NO_REPO`?/i,
388
+ /请明确回复\s*`?NO_REPO`?/i,
389
+ /未提供所属\s*GitLab\s*项目/i,
390
+ /Project override active/i
391
+ ];
392
+ return patterns.some((pattern) => pattern.test(trimmed));
393
+ }
394
+ function stripAddedInlineCodeFromPlainPaths(source, translated) {
395
+ const sourceInlineCodeTokens = new Set();
396
+ for (const match of source.matchAll(/`([^`\n]+)`/g)) {
397
+ const token = match[1]?.trim();
398
+ if (token) {
399
+ sourceInlineCodeTokens.add(token);
400
+ }
401
+ }
402
+ const sourceWithoutInlineCode = source.replace(/`[^`\n]+`/g, " ");
403
+ const plainPathTokens = new Set();
404
+ const pathPattern = /(^|[\s((\[-])((?:~\/|\.{1,2}\/|\/(?!\/))[A-Za-z0-9._~/-]*[A-Za-z0-9_~/-])(?=$|[\s),,。;:!?\])-])/gm;
405
+ for (const match of sourceWithoutInlineCode.matchAll(pathPattern)) {
406
+ const token = match[2]?.trim();
407
+ if (token && !sourceInlineCodeTokens.has(token)) {
408
+ plainPathTokens.add(token);
409
+ }
410
+ }
411
+ let normalized = translated;
412
+ for (const token of plainPathTokens) {
413
+ const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
414
+ normalized = normalized.replace(new RegExp("`" + escapedToken + "`", "g"), token);
415
+ }
416
+ return normalized;
417
+ }
349
418
  async function translateProtectedSegment(segment, plan, context, chunkPromptContext, chunkLabel, localSpanStartIndex) {
350
419
  let threadId;
351
420
  const localFormatting = protectSegmentFormattingSpans(segment.source, localSpanStartIndex);
352
421
  const protectedSource = localFormatting.protectedBody;
353
422
  const combinedSpans = [...localFormatting.spans, ...segment.spans];
354
- report(context.options, "draft", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: starting translation with model ${context.model}.`);
423
+ report(context.options, "draft", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: starting translation with model ${context.draftModel}.`);
355
424
  const draftResult = await context.executor.execute(withChunkContext(buildInitialPrompt(protectedSource), chunkPromptContext), {
356
425
  cwd: context.cwd,
357
- model: context.model,
358
- reasoningEffort: DRAFT_REASONING_EFFORT,
426
+ model: context.draftModel,
427
+ reasoningEffort: context.draftReasoningEffort,
359
428
  reuseSession: true,
360
429
  onStderr: (stderrChunk) => reportChunkProgress(context.options, "draft", chunkPromptContext.chunkIndex - 1, plan, chunkLabel, stderrChunk)
361
430
  });
362
431
  threadId = draftResult.threadId;
363
- const canonicalProtectedBody = reprotectMarkdownSpans(draftResult.text, combinedSpans);
432
+ const normalizedDraftText = stripAddedInlineCodeFromPlainPaths(protectedSource, draftResult.text);
433
+ const canonicalProtectedBody = reprotectMarkdownSpans(normalizedDraftText, combinedSpans);
364
434
  const restoredBody = restoreMarkdownSpans(canonicalProtectedBody, combinedSpans);
365
435
  return {
366
436
  segment,
@@ -373,19 +443,176 @@ async function translateProtectedSegment(segment, plan, context, chunkPromptCont
373
443
  };
374
444
  }
375
445
  async function repairDraftedSegment(draftedSegment, mustFix, plan, context, chunkLabel) {
376
- report(context.options, "repair", `Chunk ${draftedSegment.promptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}, segment ${draftedSegment.segment.index + 1}: repairing failed segment.`);
377
- const repairResult = await context.executor.execute(withChunkContext(buildRepairPrompt(draftedSegment.protectedSource, draftedSegment.protectedBody, mustFix), draftedSegment.promptContext), {
378
- cwd: context.cwd,
379
- model: context.model,
380
- reasoningEffort: REPAIR_REASONING_EFFORT,
381
- ...(draftedSegment.threadId ? { threadId: draftedSegment.threadId } : { reuseSession: true }),
382
- onStderr: (stderrChunk) => reportChunkProgress(context.options, "repair", draftedSegment.promptContext.chunkIndex - 1, plan, `${chunkLabel}, segment ${draftedSegment.segment.index + 1}`, stderrChunk)
383
- });
384
- if (repairResult.threadId) {
385
- draftedSegment.threadId = repairResult.threadId;
446
+ const mustFixBatches = splitMustFixBatches(mustFix, MAX_MUST_FIX_PER_REPAIR_CALL);
447
+ for (const [batchIndex, mustFixBatch] of mustFixBatches.entries()) {
448
+ const repairPromptContext = buildRepairPromptContext(draftedSegment.promptContext, mustFixBatch);
449
+ const batchSuffix = mustFixBatches.length > 1 ? `,修复批次 ${batchIndex + 1}/${mustFixBatches.length}` : "";
450
+ report(context.options, "repair", `Chunk ${draftedSegment.promptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}, segment ${draftedSegment.segment.index + 1}: repairing failed segment${batchSuffix}.`);
451
+ const repairResult = await context.executor.execute(withChunkContext(buildRepairPrompt(draftedSegment.protectedSource, draftedSegment.protectedBody, mustFixBatch), repairPromptContext), {
452
+ cwd: context.cwd,
453
+ model: context.postDraftModel,
454
+ reasoningEffort: context.postDraftReasoningEffort ?? REPAIR_REASONING_EFFORT,
455
+ ...(draftedSegment.threadId ? { threadId: draftedSegment.threadId } : { reuseSession: true }),
456
+ onStderr: (stderrChunk) => reportChunkProgress(context.options, "repair", draftedSegment.promptContext.chunkIndex - 1, plan, `${chunkLabel}, segment ${draftedSegment.segment.index + 1}${batchSuffix}`, stderrChunk)
457
+ });
458
+ if (repairResult.threadId) {
459
+ draftedSegment.threadId = repairResult.threadId;
460
+ }
461
+ const normalizedRepairText = stripAddedInlineCodeFromPlainPaths(draftedSegment.protectedSource, repairResult.text);
462
+ draftedSegment.protectedBody = reprotectMarkdownSpans(normalizedRepairText, draftedSegment.spans);
463
+ draftedSegment.restoredBody = restoreMarkdownSpans(draftedSegment.protectedBody, draftedSegment.spans);
464
+ }
465
+ }
466
+ function splitMustFixBatches(mustFix, batchSize) {
467
+ const normalizedBatchSize = Math.max(1, batchSize);
468
+ const batches = [];
469
+ let index = 0;
470
+ while (index < mustFix.length) {
471
+ const batch = [mustFix[index]];
472
+ let batchTargets = extractExplicitEnglishTargetsFromMustFix(batch);
473
+ index += 1;
474
+ while (index < mustFix.length) {
475
+ const nextItem = mustFix[index];
476
+ const nextTargets = extractExplicitEnglishTargetsFromMustFix([nextItem]);
477
+ const withinBatchLimit = batch.length < normalizedBatchSize;
478
+ const relatedToBatch = batchTargets.length > 0 &&
479
+ nextTargets.length > 0 &&
480
+ nextTargets.some((candidate) => batchTargets.some((existing) => belongToSameConceptFamily(existing, candidate)));
481
+ if (!withinBatchLimit && !relatedToBatch) {
482
+ break;
483
+ }
484
+ batch.push(nextItem);
485
+ batchTargets = [...new Set([...batchTargets, ...nextTargets])];
486
+ index += 1;
487
+ }
488
+ batches.push(batch);
489
+ }
490
+ return batches;
491
+ }
492
+ function extractExplicitEnglishTargetsFromMustFix(mustFix) {
493
+ const targets = new Set();
494
+ for (const item of mustFix) {
495
+ for (const match of item.matchAll(/[“"`']([A-Za-z][A-Za-z0-9./+&:_ -]{0,79})[”"`']/g)) {
496
+ const candidate = match[1]?.trim();
497
+ if (!candidate) {
498
+ continue;
499
+ }
500
+ if (!/[A-Za-z]/.test(candidate)) {
501
+ continue;
502
+ }
503
+ targets.add(candidate);
504
+ }
505
+ for (const match of item.matchAll(/(?:核心术语|英文目标|英文词|英文原名|产品名|工具名|项目名|模型名|CLI 名称|命令名|框架名|平台名|机制名|概念)\s+([A-Za-z][A-Za-z0-9./+&:_ -]{0,79}?)(?=\s*(?:首次|首现|在|需|应|未|缺少|没有|作为|并|,|。|;|:|$))/g)) {
506
+ const candidate = match[1]?.trim();
507
+ if (!candidate) {
508
+ continue;
509
+ }
510
+ if (!/[A-Za-z]/.test(candidate)) {
511
+ continue;
512
+ }
513
+ targets.add(candidate);
514
+ }
515
+ }
516
+ return [...targets];
517
+ }
518
+ function extractConceptFamilyTargets(targets) {
519
+ const normalized = [...new Set(targets.map((item) => item.trim()).filter(Boolean))];
520
+ const families = [];
521
+ const seen = new Set();
522
+ for (const base of normalized) {
523
+ if (seen.has(base)) {
524
+ continue;
525
+ }
526
+ const related = normalized.filter((candidate) => {
527
+ if (candidate === base) {
528
+ return true;
529
+ }
530
+ return belongToSameConceptFamily(base, candidate);
531
+ });
532
+ if (related.length < 2) {
533
+ continue;
534
+ }
535
+ related.forEach((item) => seen.add(item));
536
+ families.push(related);
537
+ }
538
+ return families;
539
+ }
540
+ function belongToSameConceptFamily(left, right) {
541
+ const normalizedLeft = left.trim().toLowerCase();
542
+ const normalizedRight = right.trim().toLowerCase();
543
+ if (!normalizedLeft || !normalizedRight) {
544
+ return false;
545
+ }
546
+ return (normalizedLeft === normalizedRight ||
547
+ normalizedLeft.startsWith(normalizedRight + " ") ||
548
+ normalizedRight.startsWith(normalizedLeft + " "));
549
+ }
550
+ function buildRepairPromptContext(promptContext, mustFix) {
551
+ const extraNotes = [...promptContext.specialNotes];
552
+ const explicitEnglishTargets = extractExplicitEnglishTargetsFromMustFix(mustFix);
553
+ const conceptFamilyTargets = extractConceptFamilyTargets(explicitEnglishTargets);
554
+ const targetsHeadingLikeAnchor = mustFix.some((item) => item.includes("标题") || item.includes("首次出现") || item.includes("中英对照"));
555
+ if (promptContext.segmentHeadings.length > 0 &&
556
+ promptContext.specialNotes.some((item) => item.includes("当前分段包含标题或加粗标题")) &&
557
+ targetsHeadingLikeAnchor) {
558
+ extraNotes.push(`本次 must_fix 明确指向标题。必须直接修改以下标题文本本身:${promptContext.segmentHeadings.join(" | ")}。`, "不要把标题里的首现双语修复转移到正文其他句子;标题缺什么,就在标题里补什么。", "如果标题里的目标是英文产品名、工具名、项目名、模型名、CLI 名称,或以英文表达的核心概念性标题术语,而常见中文主译并不稳定,修复时优先保留英文原名,并在标题本身补最小必要的中文说明或类属锚定;不要只把标题其他部分翻成中文,却让这个英文专名或核心概念继续裸露未锚定。");
559
+ if (promptContext.segmentHeadings.some((heading) => /[//]/.test(heading))) {
560
+ extraNotes.push("如果标题里有用 / 连接的并列平台名、系统名、工具名或范围限定语,修复时必须在标题本身完整保留这组并列结构,不要删掉任何一侧,也不要把其中一侧挪到正文。", "这类并列标签若需要补首现双语,应在标题里为整组并列范围补自然的中文说明或锚定,不要只补其中一个英文项,也不要把说明转移到标题后面的段落。", "对 `A/B` 这类并列英文标签,优先保留整组英文原名,再在整组后面补一个整体中文说明词,例如“平台”“系统”“工具”或等价表达;不要把它改成英文重复括注,也不要拆成两处分别补。");
561
+ }
562
+ if (promptContext.specialNotes.some((item) => item.includes("当前分段包含列表前的说明句"))) {
563
+ extraNotes.push("如果当前分段的结构是“冒号引导句或说明句 + 下一行加粗标题/标题 + 后续列表”,而 must_fix 指向的是该标题中的首现双语缺失,必须直接在这个标题本身补齐锚定;不要把修复转移到前面的引导句,也不要只在后面的列表项里补一次。", "对这类结构里的核心概念性英文标题,例如分类名、能力名、隔离/限制/保护等机制名称,修复目标应是标题本身的最小自然双语形式,例如“中文标题(English Term)”或等价表达;不要只保留中文标题。");
564
+ }
565
+ }
566
+ if (promptContext.specialNotes.some((item) => item.includes("当前分段包含列表项")) &&
567
+ mustFix.some((item) => item.includes("条目") || item.includes("项目符号") || item.includes("列表项"))) {
568
+ extraNotes.push("本次 must_fix 明确指向列表项或项目符号。必须直接修改对应的列表项文本本身,不要把缺失的首现双语转移到列表前后的说明段落里。", "如果 must_fix 指向多个列表项,要逐条在各自的列表项里补齐;不要只在列表标题、段首总结句或其他项目符号里补一次。", "如果 must_fix 点名的是某个列表项里的核心英文概念、术语或英文短语,就必须在该列表项本身保留这个英文原名并补自然中文锚定;不要只保留同一列表项括号里的另一个英文专名、品牌名、缩写或解释来冒充“已修复”。", "对“概念名(解释)”“中文概念(英文原名)”或带括号说明的列表项,修复时要分清主锚定对象和括号说明:被 must_fix 点名的核心概念必须在这一条列表项里直接补齐,不能因为括号里还有别的英文词就省略它。");
569
+ }
570
+ if (promptContext.specialNotes.some((item) => item.includes("当前分段包含列表前的说明句")) &&
571
+ mustFix.some((item) => item.includes("中文说明") || item.includes("英文缩写") || item.includes("首次出现"))) {
572
+ extraNotes.push("本次 must_fix 明确指向列表前的说明句、导语句或冒号引导句。必须直接修改对应引导句本身,不要把缺失的首现双语或中文说明转移到后面的列表项里。", "如果 must_fix 指向引导句中的英文缩写、包名、命令名、产品名或术语,优先在同一句里补自然的中文说明,并保持这一句仍然是后续列表的引导句。");
573
+ }
574
+ if (mustFix.some((item) => item.includes("当前句") || item.includes("该句")) &&
575
+ mustFix.some((item) => item.includes("首次出现") || item.includes("中英对照") || item.includes("中文说明"))) {
576
+ extraNotes.push("本次 must_fix 明确指向当前句或该句的正文说明。必须直接在这同一句本身补齐缺失的首现中英文对照或中文说明,不要把修复转移到同一分段的前一句、后一句、标题、列表项或总结句里。", "如果目标术语、缩写、包名、命令名、产品名或概念出现在这句正文里,应在保持原句论证关系和语气的前提下就地补自然的中文锚定,不要只修同段别处。");
577
+ }
578
+ if (mustFix.some((item) => /第\d+段/.test(item)) &&
579
+ mustFix.some((item) => item.includes("首次出现") || item.includes("中英文") || item.includes("中英对照"))) {
580
+ extraNotes.push("本次 must_fix 明确点名了某一具体段落。必须直接在被点名的那一段本身补齐缺失的首现中英文对照或中文说明,不要把锚定转移到同分段的其他段、标题、引用外说明、列表项或后续小节里。", "如果 must_fix 已经写明“第N段”或直接摘录了该段原句,修复时应把该段视为唯一有效落点:被点名的英文术语、产品名、概念名或机制名,必须在这段对应中文词处就地补齐英文原名或中文说明。");
581
+ }
582
+ if (promptContext.specialNotes.some((item) => item.includes("当前分段包含引用段落")) &&
583
+ mustFix.some((item) => item.includes("引用段")) &&
584
+ mustFix.some((item) => item.includes("首次出现") || item.includes("中英文") || item.includes("中英对照"))) {
585
+ extraNotes.push("本次 must_fix 明确指向引用段中的句子。必须直接在对应引用句本身补齐缺失的首现中英文对照或中文说明,不要把锚定转移到引用外的标题、正文、列表项或后续小节里。", "如果 must_fix 点名了引用段中的英文术语、机制名、产品名或概念,例如 Sandbox、Prompt injection、Supply chain attacks 等,修复时必须在该引用句里的对应中文词处就地补齐英文原名;不要把英文锚点延后到后文标题或下一段第一次出现的位置。");
386
586
  }
387
- draftedSegment.protectedBody = reprotectMarkdownSpans(repairResult.text, draftedSegment.spans);
388
- draftedSegment.restoredBody = restoreMarkdownSpans(draftedSegment.protectedBody, draftedSegment.spans);
587
+ if (explicitEnglishTargets.length > 0) {
588
+ extraNotes.push(`本次 must_fix 明确点名了这些英文目标:${explicitEnglishTargets.join(" / ")}。`, "只要 must_fix 已经点名某个英文词、命令名、语言名、包名、平台名或术语,即使它看起来是常见技术词,也必须严格按 must_fix 要求修复,不能因为“太常见”就省略首现锚定。", "修复时必须在对应的标题、当前句、列表项或被点名位置本身保留这个英文原名,并补最小必要的中文说明;不要只译成中文,也不要把锚定转移到别处。");
589
+ }
590
+ if (conceptFamilyTargets.length > 0) {
591
+ extraNotes.push(`本次 must_fix 里存在同一概念家族的多个英文目标:${conceptFamilyTargets
592
+ .map((family) => family.join(" / "))
593
+ .join(" ; ")}。`, "对同一概念家族里的 base term 和 extended term,必须把它们视为两个独立锚点分别修复;不能因为已经补了较短词组,就省略较长词组,反之亦然。", "如果 must_fix 同时点名了引用句里的短概念和说明句/引导句里的扩展概念,修复时要在各自被点名的位置分别补齐,不要把其中一个锚点挪去充当另一个。");
594
+ }
595
+ if (mustFix.some((item) => item.includes("重复回括") ||
596
+ item.includes("重复括注") ||
597
+ item.includes("重复回注") ||
598
+ item.includes("重复同一英文"))) {
599
+ extraNotes.push("本次 must_fix 明确指出英文原名出现了重复回括或重复括注。修复时同一个英文原名在同一个首现锚点里只能保留一次,不要再生成“中文说明(同一英文原名)”或等价的重复回括格式。", "如果要为英文原名补中文说明,优先使用自然的单次锚定形式,例如“English(中文说明)”“English + 中文说明”或其他只保留一次英文原名的写法;不要把同一个英文词先写进正文,又在括号里重复一次。");
600
+ }
601
+ if (mustFix.some((item) => item.includes("双层括号") ||
602
+ item.includes("嵌套格式") ||
603
+ item.includes("单层括注") ||
604
+ item.includes("不嵌套"))) {
605
+ extraNotes.push("本次 must_fix 明确指出当前写法出现了双层括号或嵌套括注。修复时如果原句、列表项或标题里本来就已经有一层括注说明,必须在这一层括注内部完成中英锚定,不要再额外套第二层括号。", "对这类已有括注的首现锚定,优先改成单层括注里的并列说明,例如“(中文说明,English)”“(English,中文说明)”或等价的单层形式;不要生成“(中文(English))”或任何双层括号格式。");
606
+ }
607
+ if (mustFix.some((item) => item.includes("inline code") ||
608
+ item.includes("反引号") ||
609
+ item.includes("Markdown 结构"))) {
610
+ extraNotes.push("本次 must_fix 明确指出当前译文擅自把原文普通文本改成了 inline code。修复时如果原文中的路径、目录名、文件名、URL 片段或命令样式文本本来没有反引号,就必须保持普通文本结构,不要新增反引号或把它们包成 inline code。", "对列表项里的 `~/.ssh/`、`~/.aws/`、`~/.config/` 这类路径,如果原文只是普通列表文本加括注说明,修复时应继续保持普通列表文本,只调整双语说明或中文解释;不要把路径本身改成代码样式。");
611
+ }
612
+ return {
613
+ ...promptContext,
614
+ specialNotes: extraNotes
615
+ };
389
616
  }
390
617
  async function runBundledGateAudit(draftedSegments, plan, context, chunkPromptContext, chunkLabel) {
391
618
  const segmentIndices = draftedSegments.map((segment) => segment.segment.index + 1);
@@ -396,8 +623,8 @@ async function runBundledGateAudit(draftedSegments, plan, context, chunkPromptCo
396
623
  const prompt = withChunkContextAt(buildBundledGateAuditPrompt(formatBundledAuditSegments(draftedSegments)), chunkPromptContext, "【分段审校输入】");
397
624
  const auditResult = await context.executor.execute(prompt, {
398
625
  cwd: context.cwd,
399
- model: context.model,
400
- reasoningEffort: AUDIT_REASONING_EFFORT,
626
+ model: context.postDraftModel,
627
+ reasoningEffort: context.postDraftReasoningEffort ?? AUDIT_REASONING_EFFORT,
401
628
  outputSchema: BUNDLED_GATE_AUDIT_SCHEMA,
402
629
  reuseSession: true,
403
630
  onStderr: (stderrChunk) => reportChunkProgress(context.options, "audit", chunkPromptContext.chunkIndex - 1, plan, chunkLabel, stderrChunk)
@@ -419,6 +646,18 @@ async function runBundledGateAudit(draftedSegments, plan, context, chunkPromptCo
419
646
  }
420
647
  return bundledAudit;
421
648
  }
649
+ async function runPostRepairGateAudit(draftedSegments, previousAudit, repairedSegmentIndices, plan, context, chunkPromptContext, chunkLabel) {
650
+ report(context.options, "audit", `Chunk ${chunkPromptContext.chunkIndex}/${plan.chunks.length}${chunkLabel}: re-running per-segment hard gate audit after repair.`);
651
+ const repairedSegments = draftedSegments.filter((segment) => repairedSegmentIndices.has(segment.segment.index + 1));
652
+ if (repairedSegments.length === 0) {
653
+ return previousAudit;
654
+ }
655
+ const updatedAudit = await runFallbackSegmentAudits(repairedSegments, plan, context, chunkPromptContext, chunkLabel);
656
+ const updatedByIndex = new Map(updatedAudit.segments.map((segmentAudit) => [segmentAudit.segment_index, segmentAudit]));
657
+ return {
658
+ segments: previousAudit.segments.map((segmentAudit) => updatedByIndex.get(segmentAudit.segment_index) ?? segmentAudit)
659
+ };
660
+ }
422
661
  async function runFallbackSegmentAudits(draftedSegments, plan, context, chunkPromptContext, chunkLabel) {
423
662
  const segments = [];
424
663
  for (const draftedSegment of draftedSegments) {
@@ -427,8 +666,8 @@ async function runFallbackSegmentAudits(draftedSegments, plan, context, chunkPro
427
666
  : chunkLabel;
428
667
  const auditResult = await context.executor.execute(withChunkContext(buildGateAuditPrompt(draftedSegment.protectedSource, draftedSegment.protectedBody), draftedSegment.promptContext), {
429
668
  cwd: context.cwd,
430
- model: context.model,
431
- reasoningEffort: AUDIT_REASONING_EFFORT,
669
+ model: context.postDraftModel,
670
+ reasoningEffort: context.postDraftReasoningEffort ?? AUDIT_REASONING_EFFORT,
432
671
  outputSchema: GATE_AUDIT_SCHEMA,
433
672
  reuseSession: true,
434
673
  onStderr: (stderrChunk) => reportChunkProgress(context.options, "audit", chunkPromptContext.chunkIndex - 1, plan, segmentLabel, stderrChunk)
@@ -572,7 +811,7 @@ function isHeadingLikeBlock(content) {
572
811
  if (/^#{1,6}[ \t]+.+$/.test(trimmed)) {
573
812
  return true;
574
813
  }
575
- return /^\*\*[^*\n].+\*\*$/.test(trimmed);
814
+ return /^\*\*[^*\n].+\*\*$/.test(trimmed) || /^\*\*[^*\n]+\*\*\s*(?:—|-|:).+$/.test(trimmed);
576
815
  }
577
816
  function splitRawBlocks(source) {
578
817
  if (source.length === 0) {
@@ -606,15 +845,27 @@ function measureRawBlocks(blocks) {
606
845
  }
607
846
  function collectChunkSpans(source, spanIndex, extraSpans = []) {
608
847
  const placeholderPattern = /@@MDZH_[A-Z_]+_\d{4,}@@/g;
609
- const spanIds = [...new Set(source.match(placeholderPattern) ?? [])];
610
848
  const localSpanIndex = new Map(extraSpans.map((span) => [span.id, span]));
611
- return spanIds.map((spanId) => {
849
+ const collected = [];
850
+ const seen = new Set();
851
+ const addSpan = (spanId) => {
852
+ if (seen.has(spanId)) {
853
+ return;
854
+ }
612
855
  const span = localSpanIndex.get(spanId) ?? spanIndex.get(spanId);
613
856
  if (!span) {
614
857
  throw new HardGateError(`Protected span integrity failed: unknown placeholder ${spanId}.`);
615
858
  }
616
- return span;
617
- });
859
+ seen.add(spanId);
860
+ collected.push(span);
861
+ for (const nestedSpanId of span.raw.match(placeholderPattern) ?? []) {
862
+ addSpan(nestedSpanId);
863
+ }
864
+ };
865
+ for (const spanId of [...new Set(source.match(placeholderPattern) ?? [])]) {
866
+ addSpan(spanId);
867
+ }
868
+ return collected;
618
869
  }
619
870
  function extractSegmentHeadingHints(source) {
620
871
  const hints = [];
@@ -631,18 +882,35 @@ function extractSegmentHeadingHints(source) {
631
882
  const boldMatch = trimmed.match(/^\*\*(.+)\*\*$/);
632
883
  if (boldMatch?.[1]) {
633
884
  hints.push(boldMatch[1].trim());
885
+ continue;
886
+ }
887
+ const boldLeadMatch = trimmed.match(/^\*\*([^*\n]+)\*\*\s*(?:—|-|:)\s*(.+)$/);
888
+ if (boldLeadMatch?.[1]) {
889
+ hints.push(boldLeadMatch[1].trim());
634
890
  }
635
891
  }
636
892
  return hints;
637
893
  }
638
894
  function extractSegmentSpecialNotes(source) {
639
895
  const notes = [];
896
+ if (containsHeadingLikeBlock(source)) {
897
+ notes.push("当前分段包含标题或加粗标题。若标题中的关键术语、产品名、项目名或专业概念是全文首次出现,必须直接在标题本身补齐中英文对照;不要把这类修复转移到正文其他句子里。", "修复标题首现双语时,只补局部术语或专名本身,不要把整条标题原句附上英文,也不要只润色中文标题却遗漏必须补齐的英文锚点。");
898
+ }
640
899
  if (containsAttributionLikeBlock(source)) {
641
900
  notes.push("当前分段包含图注、署名、来源、配图说明或出品归属类文本。对这类归属说明里的公司名、机构名、品牌名、作者名或媒体名,如果原文本身以英文原名、署名格式或 credit/byline 形式呈现,不要为了满足首现双语而强行创造中文主译。", "这类归属说明优先保留原文归属格式,可做最小必要的中文化,但不要把 `Anthropic(Anthropic)` 这类同文重复括注当作正确修复目标,也不要因为缺少中文主译就判为必须修复。");
642
901
  }
643
902
  if (containsToolNameExplanationBlock(source)) {
644
903
  notes.push("当前分段包含工具名、命令名、包名、CLI 名称或产品名的列表项说明。对这类以英文原名作为标签的说明条目,允许保留英文原名,并在后面直接接中文解释;不要为了满足首现双语而强行改写成“中文(英文)”主译格式。", "对于 `kubectl - Kubernetes cluster access`、`docker - ...`、`npm install -g ...` 这类工具/命令/产品说明,只要英文原名保留且中文解释清楚,就可视为合格的首现锚定;不要把“英文名(中文解释)”误判为必须修复。");
645
904
  }
905
+ if (containsListLikeBlock(source)) {
906
+ notes.push("当前分段包含列表项或项目符号。若列表项中的术语、产品名、命令名或其他关键专名需要补首现双语,必须直接在对应列表项本身补齐,不要把修复转移到列表前后的正文说明里。", "如果同一列表里有多个条目各自首次出现不同术语,要逐条补齐,不要只在列表标题、总结句或某一个项目符号里补一次。");
907
+ }
908
+ if (containsListLeadInBlock(source)) {
909
+ notes.push("当前分段包含列表前的说明句、导语句或冒号引导句。若这类引导句本身首次出现术语、缩写、产品名、包名、命令名或其他关键专名,必须直接在该说明句本身补齐中英文对照或中文说明,不要把修复转移到后面的列表项里。", "这类引导句通常以冒号结束,用来引出后续列表。修复时应保留原有引导结构,只在该句内部补最小必要的首现锚定,不要改写成列表项标题,也不要把解释拆到下一行列表中。");
910
+ }
911
+ if (containsBlockquoteBlock(source)) {
912
+ notes.push("当前分段包含引用段落或 `>` 引用句。若引用段中的术语、产品名、机制名或其他关键专名需要补首现中英文对照,必须直接在对应引用句本身补齐,不要把修复转移到引用前后的正文、标题或后续小节里。", "修复引用句时,应保留引用结构和原句判断关系,只在引用句内部补最小必要的英文锚点或中文说明;不要把被点名的英文术语延后到后文标题、列表项或总结句。");
913
+ }
646
914
  if (containsTranslatableMarkdownStructure(source)) {
647
915
  notes.push("当前分段包含可翻译的 Markdown 强调结构或命令/flag 写法。翻译时必须保留等价结构:原文中的 **加粗**、*斜体* 等强调,不得无故去掉;像 --dangerously-skip-permissions 这类命令参数或 flag,应保留原始写法,不要改成代码块、标题、列表标签或其他 Markdown 结构。", "如果强调结构里的正文需要翻译,请翻译内容本身,但保留强调标记;如果命令、flag、配置键名或 CLI 参数本身是英文原名,请保留原名,只翻译周围解释。");
648
916
  }
@@ -651,6 +919,9 @@ function extractSegmentSpecialNotes(source) {
651
919
  function containsAttributionLikeBlock(source) {
652
920
  return splitRawBlocks(source).some((block) => isAttributionLikeBlock(block.content));
653
921
  }
922
+ function containsHeadingLikeBlock(source) {
923
+ return splitRawBlocks(source).some((block) => isHeadingLikeBlock(block.content));
924
+ }
654
925
  function isAttributionLikeBlock(content) {
655
926
  const trimmed = content.trim();
656
927
  if (trimmed.length === 0 || trimmed.includes("\n")) {
@@ -665,9 +936,43 @@ function isAttributionLikeBlock(content) {
665
936
  function containsToolNameExplanationBlock(source) {
666
937
  return splitRawBlocks(source).some((block) => isToolNameExplanationBlock(block.content));
667
938
  }
939
+ function containsListLikeBlock(source) {
940
+ return splitRawBlocks(source).some((block) => isListLikeBlock(block.content));
941
+ }
942
+ function containsListLeadInBlock(source) {
943
+ const blocks = splitRawBlocks(source);
944
+ for (let index = 0; index < blocks.length - 1; index += 1) {
945
+ const current = blocks[index]?.content.trim() ?? "";
946
+ const next = blocks[index + 1]?.content ?? "";
947
+ const nextNext = blocks[index + 2]?.content ?? "";
948
+ if (current.length === 0 ||
949
+ isHeadingLikeBlock(current) ||
950
+ isListLikeBlock(current) ||
951
+ !/[::]\s*$/.test(current)) {
952
+ continue;
953
+ }
954
+ if (isListLikeBlock(next)) {
955
+ return true;
956
+ }
957
+ if (isHeadingLikeBlock(next) && isListLikeBlock(nextNext)) {
958
+ return true;
959
+ }
960
+ }
961
+ return false;
962
+ }
963
+ function containsBlockquoteBlock(source) {
964
+ return splitRawBlocks(source).some((block) => block.content
965
+ .split(/\r?\n/)
966
+ .some((line) => line.trimStart().startsWith(">")));
967
+ }
668
968
  function isToolNameExplanationBlock(content) {
669
969
  return content.split(/\r?\n/).some((line) => isToolNameExplanationLine(line));
670
970
  }
971
+ function isListLikeBlock(content) {
972
+ return content
973
+ .split(/\r?\n/)
974
+ .some((line) => /^(\s*)([-*+]|\d+\.)\s+/.test(line.trimStart()));
975
+ }
671
976
  function isToolNameExplanationLine(line) {
672
977
  const trimmed = line.trim();
673
978
  if (!trimmed.startsWith("- ")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-zh-translation-skill",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "CLI skill for translating English Markdown articles into polished Chinese Markdown with a hidden gated pipeline.",
5
5
  "type": "module",
6
6
  "bin": {