dowafu 0.1.0 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +58 -25
  2. package/README_zh-tw.md +197 -0
  3. package/dist/adapters/anthropic-messages.js +18 -11
  4. package/dist/adapters/gemini-native.js +19 -16
  5. package/dist/adapters/read-file-tool-description.js +5 -0
  6. package/dist/adapters/responses.js +26 -14
  7. package/dist/audit.js +17 -1
  8. package/dist/cli-args.js +103 -38
  9. package/dist/cli.js +78 -43
  10. package/dist/dispatch-home.js +24 -6
  11. package/dist/doctor.js +91 -0
  12. package/dist/error-classify.js +13 -4
  13. package/dist/gate.js +3 -2
  14. package/dist/mask.js +16 -3
  15. package/dist/messages.js +396 -0
  16. package/dist/output.js +60 -37
  17. package/dist/prompt.js +5 -3
  18. package/dist/providers.js +46 -34
  19. package/dist/raw-integrity.js +6 -5
  20. package/dist/report.js +76 -22
  21. package/dist/runner.js +21 -10
  22. package/dist/ticket.js +35 -25
  23. package/dist/validate.js +21 -20
  24. package/dist/whitelist.js +9 -1
  25. package/package.json +3 -2
  26. package/providers.json +8 -3
  27. package/publish/en/.agents/skills/find-holes-external/SKILL.md +450 -0
  28. package/publish/en/.agents/skills/preflight/SKILL.md +137 -0
  29. package/publish/en/.agents/skills/wrap/SKILL.md +64 -0
  30. package/publish/en/.claude/agents/explore-haiku.md +8 -0
  31. package/publish/en/.claude/agents/hole-finder-cost.md +15 -0
  32. package/publish/en/.claude/agents/hole-finder-feasibility.md +15 -0
  33. package/publish/en/.claude/agents/hole-finder-safety.md +15 -0
  34. package/publish/en/.claude/agents/hole-finder.md +14 -0
  35. package/publish/en/.claude/skills/find-holes/SKILL.md +114 -0
  36. package/publish/en/.claude/skills/find-holes-external/SKILL.md +463 -0
  37. package/publish/en/.claude/skills/preflight/SKILL.md +198 -0
  38. package/publish/en/.claude/skills/wrap/SKILL.md +61 -0
  39. package/publish/en/README.md +106 -0
  40. package/publish/en/workflow_spec.md +71 -0
  41. package/publish/{.agents → zh-tw/.agents}/skills/find-holes-external/SKILL.md +195 -20
  42. package/publish/{.agents → zh-tw/.agents}/skills/preflight/SKILL.md +49 -7
  43. package/publish/{.claude → zh-tw/.claude}/skills/find-holes/SKILL.md +32 -4
  44. package/publish/{.claude → zh-tw/.claude}/skills/find-holes-external/SKILL.md +194 -19
  45. package/publish/{.claude → zh-tw/.claude}/skills/preflight/SKILL.md +51 -6
  46. package/publish/{README.md → zh-tw/README.md} +16 -0
  47. /package/publish/{.agents → zh-tw/.agents}/skills/wrap/SKILL.md +0 -0
  48. /package/publish/{.claude → zh-tw/.claude}/agents/explore-haiku.md +0 -0
  49. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-cost.md +0 -0
  50. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-feasibility.md +0 -0
  51. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-safety.md +0 -0
  52. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder.md +0 -0
  53. /package/publish/{.claude → zh-tw/.claude}/skills/wrap/SKILL.md +0 -0
  54. /package/publish/{workflow_spec.md → zh-tw/workflow_spec.md} +0 -0
package/dist/output.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // plan_dispatch_v1.4.md §12/§16:落檔與 run.jsonl。寫入序列化——前一次 appendFile 未
2
2
  // resolve 前不發下一次;呼叫平行,寫入序列。所有落檔內容在寫入前經 maskDeep 遮蔽(§12)。
3
- import { appendFile, mkdir, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readdir, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { maskDeep, maskString } from "./mask.js";
6
+ import { m } from "./messages.js";
6
7
  export class RunLogWriter {
7
8
  filePath;
9
+ lang;
8
10
  queue = Promise.resolve();
9
- constructor(filePath) {
11
+ constructor(filePath, lang) {
10
12
  this.filePath = filePath;
13
+ this.lang = lang;
11
14
  }
12
15
  // plan_dispatch_v2.4.md §12(一):ts 語意為「append 被呼叫的當下」,即事件發生時刻,
13
16
  // 故在此(方法本體)取時間,不得延後到 queue 的 then callback 內——落盤時間會被檔案
@@ -21,7 +24,7 @@ export class RunLogWriter {
21
24
  // append 都不會執行,且 flush() 也會 rejected,等於一次瞬時寫入失敗讓該次派工
22
25
  // 其餘所有事件全部遺失。失敗以 stderr 留痕,不靜默吞掉。
23
26
  this.queue = this.queue.then(() => appendFile(this.filePath, line, "utf8")).catch((err) => {
24
- console.error(`run.jsonl 寫入失敗:${maskString(String(err))}`);
27
+ console.error(m(this.lang, "runLogWriteFailed", maskString(String(err))));
25
28
  });
26
29
  }
27
30
  async flush() {
@@ -37,6 +40,20 @@ export async function ensureOutDir(outDir) {
37
40
  // (issue_log_v2.0.md 2026-08-07:hub 驗收時據此數錯 9/22 vs 實際 7/20)。
38
41
  await writeFile(path.join(outDir, "run.jsonl"), "", "utf8");
39
42
  }
43
+ // 上面 §13 那條「重跑會覆蓋同名輸出目錄」的前提是**重跑的人知道自己在覆蓋什麼**。
44
+ // 四格 host 對測(2026-08-13)打掉了那個前提:hub 會在派工前自己 rm -rf 掉輸出目錄,
45
+ // 沒有人被問過,而那底下是已經付過錢的產物。覆蓋本身不改(此處語意維持不變),改成
46
+ // 由 cli.ts 在**還沒呼叫任何 API 的地方**擋下來——目錄非空就中止,並要求換 ticket-id。
47
+ // 讀不到目錄(不存在、或權限不足)一律回 false:不存在本來就該放行,權限不足則交給
48
+ // 後面的 ensureOutDir 報 outDirNotWritable,兩者的訊息各自比這裡準確。
49
+ export async function outDirHasArtifacts(outDir) {
50
+ try {
51
+ return (await readdir(outDir)).length > 0;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
40
57
  // §13:raw 一律無條件覆寫。v2.4 實作期間曾加過「空 raw 不覆蓋磁碟上的非空內容」守衛
41
58
  // (由 v2.4 §20 的一條測試要求逼出來),驗收時撤除:它破壞了 §13「同一工單重跑會覆蓋
42
59
  // 同名輸出目錄」——重跑時該 spoke 若零 raw,上一次的內容會留著冒充本次證據,與
@@ -53,22 +70,25 @@ export async function writeRawFiles(outDir, agent, result) {
53
70
  }
54
71
  // §13:「原文產出」欄——failed 無產出;其餘(含 truncated:*)皆落檔,即使不完整,
55
72
  // 因為那是已經付過錢的內容。
56
- export async function writeSpokeText(outDir, agent, result) {
73
+ export async function writeSpokeText(outDir, agent, result, lang) {
57
74
  if (result.status === "failed")
58
75
  return;
59
- const content = result.finalText ?? `(無法取得完整回報,執行狀態:${result.status})`;
76
+ // issue_log_v2.5.md 待修 #7:本函式一度是唯一沒有遮蔽的落檔路徑——`raw/` 與 `run.jsonl`
77
+ // 都經 maskDeep,只有這裡直寫 finalText。spoke 讀的是被審專案的原始碼,那裡若有硬編金鑰,
78
+ // 它可以照抄進「原文」欄(§16 模板要求逐字複製),於是秘密落到一個不經遮蔽的檔案裡。
79
+ const content = maskString(result.finalText ?? m(lang, "noFullReportAvailable", result.status));
60
80
  await writeFile(path.join(outDir, `${agent}.md`), content, "utf8");
61
81
  }
62
82
  // plan_dispatch_v2.4.md §13(一):每支 spoke 完成即落檔的落地點。落檔例外不得外傳——
63
83
  // 落檔失敗不得使呼叫端的 promise rejected、不得改變該 spoke 的 status(落檔是產物持久化,
64
84
  // 不是執行結果),但必須留下痕跡:寫入 result.errors[] 並經 onError 回報。
65
85
  // writeSpokeText、writeRawFiles 各自包住例外,其一失敗不影響另一個執行。
66
- export async function persistSpokeResult(outDir, result, onError) {
86
+ export async function persistSpokeResult(outDir, result, onError, lang) {
67
87
  try {
68
- await writeSpokeText(outDir, result.agent, result);
88
+ await writeSpokeText(outDir, result.agent, result, lang);
69
89
  }
70
90
  catch (err) {
71
- const msg = `落檔失敗(${result.agent}.md):${maskString(String(err))}`;
91
+ const msg = m(lang, "persistTextFailed", result.agent, maskString(String(err)));
72
92
  result.errors.push(msg);
73
93
  onError(msg);
74
94
  }
@@ -76,7 +96,7 @@ export async function persistSpokeResult(outDir, result, onError) {
76
96
  await writeRawFiles(outDir, result.agent, result);
77
97
  }
78
98
  catch (err) {
79
- const msg = `落檔失敗(${result.agent} raw/):${maskString(String(err))}`;
99
+ const msg = m(lang, "persistRawFailed", result.agent, maskString(String(err)));
80
100
  result.errors.push(msg);
81
101
  onError(msg);
82
102
  }
@@ -84,35 +104,40 @@ export async function persistSpokeResult(outDir, result, onError) {
84
104
  function formatStoreCell(store) {
85
105
  return store === "false" ? "false" : store;
86
106
  }
87
- const BUDGET_TRIGGER_LABEL = {
88
- total: "總量",
89
- reasoning: "推理累積",
90
- reasoning_round: "推理單輪尖峰",
91
- };
92
107
  // §14:truncated:budget 需顯示觸發來源;reasoning_round 是「模型在某一輪卡住」的異常訊號,
93
108
  // 與正常的總量/累積推理超支後續動作不同(前者查 prompt 或換 effort,後者調門檻),
94
109
  // 須顯眼區分,不能跟其他 truncated:budget 混在一起看起來像同一種情況。
95
- function formatStatusCell(r) {
110
+ function formatStatusCell(r, lang) {
96
111
  if (r.status !== "truncated:budget" || !r.budgetTrigger)
97
112
  return r.status;
98
- const label = BUDGET_TRIGGER_LABEL[r.budgetTrigger];
99
- const flag = r.budgetTrigger === "reasoning_round" ? "⚠ 異常尖峰・" : "";
113
+ const label = m(lang, "budgetTriggerLabel", r.budgetTrigger);
114
+ const flag = r.budgetTrigger === "reasoning_round" ? m(lang, "anomalySpikeFlag") : "";
100
115
  return `${r.status}(${flag}${label})`;
101
116
  }
102
117
  // plan_dispatch_v2.0.md §15(二):清單外引用附出現章節與疑似縮寫來源,讓讀者不必自己去猜
103
118
  // (issue_log_v2.0.md 2026-08-07:曾有 hub 因為裸字串誤判成稽核器的 bug,推錯了方向)。
104
- function formatOutsideAllowlistCell(detail) {
119
+ function formatOutsideAllowlistCell(detail, lang) {
105
120
  if (detail.length === 0)
106
- return "無";
121
+ return m(lang, "noneLabel");
107
122
  return detail
108
123
  .map((d) => {
109
- const sectionPart = d.section ? `「${d.section}」節` : "章節外";
110
- const suffixPart = d.suffixOf ? `;疑似 ${d.suffixOf} 的縮寫` : "";
111
- return `${d.path}(${sectionPart}${suffixPart})`;
124
+ const sectionPart = d.section ? m(lang, "outsideAllowlistSection", d.section) : m(lang, "outsideAllowlistNoSection");
125
+ const suffixPart = d.suffixOf ? m(lang, "outsideAllowlistSuffixNote", d.suffixOf) : "";
126
+ return m(lang, "outsideAllowlistEntry", d.path, `${sectionPart}${suffixPart}`);
112
127
  })
113
128
  .join(",");
114
129
  }
115
- export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits) {
130
+ // plan_i18n_v1.2.md §4.1:SUSPECT_PHRASES 中英兩套並存比對,summary.md 分開標示是哪一套
131
+ // 命中——日後調整這份清單時才有資料可依據,不必再猜一次。
132
+ function formatSuspectPhrasesDetail(a, lang) {
133
+ const parts = [];
134
+ if (a.suspectPhrasesZh.length > 0)
135
+ parts.push(`zh:${a.suspectPhrasesZh.join(",")}`);
136
+ if (a.suspectPhrasesEn.length > 0)
137
+ parts.push(`en:${a.suspectPhrasesEn.join(",")}`);
138
+ return parts.length > 0 ? parts.join(" / ") : m(lang, "noneLabel");
139
+ }
140
+ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang) {
116
141
  const rows = results.map((r) => {
117
142
  const a = audits.get(r.agent);
118
143
  const t = toolCallAudits.get(r.agent);
@@ -120,33 +145,31 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits)
120
145
  // plan_dispatch_v2.6.md §26 規格四:hub 會讀的地方才有用(「順序放大量」印進乾跑報表
121
146
  // 後 hub 才真的重排清單,第 10 次派工,省 52%),放最前面顯眼標示。
122
147
  if (r.unknownUsageKeys.length > 0) {
123
- cells.push(`⚠ 未知 usage 欄位:${r.provider} ${r.unknownUsageKeys.join(", ")}`);
148
+ cells.push(m(lang, "unknownUsageKeysWarning", r.provider, r.unknownUsageKeys.join(", ")));
124
149
  }
125
150
  // §15(一):沒看程式碼就作答的簽名,放最前面顯眼標示——見 tool-call-audit.ts。
126
151
  if (t?.zeroSourceRead) {
127
- cells.push(`⚠ 零原始碼讀取(允許 ${t.allowedReadsCount} 檔)`);
152
+ cells.push(m(lang, "zeroSourceReadWarning", t.allowedReadsCount));
128
153
  }
129
154
  if (t) {
130
- cells.push(`工具呼叫:${t.total}(允許 ${t.allowed}/拒絕 ${t.rejected})`);
155
+ cells.push(m(lang, "toolCallStats", t.total, t.allowed, t.rejected));
131
156
  }
132
157
  if (a) {
133
- cells.push(`收尾句:${a.finalLinePass ? "pass" : "fail"}`, `觀察:${a.observationCount ?? "無法計數"}`, // v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
134
- `清單外引用:${formatOutsideAllowlistCell(a.citedPathsOutsideAllowlistDetail)}`, `無法驗證欄:${a.cannotVerifySectionPresent ? "pass" : "fail"}`, `疑似禁止內容:${a.suspectPhrases.length > 0 ? a.suspectPhrases.join(",") : "無"}`);
158
+ cells.push(m(lang, "closingLineCell", a.finalLinePass ? "pass" : "fail"),
159
+ // v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
160
+ m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "outsideAllowlistCell", formatOutsideAllowlistCell(a.citedPathsOutsideAllowlistDetail, lang)), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"), m(lang, "suspectPhrasesCell", formatSuspectPhrasesDetail(a, lang)));
135
161
  }
136
- const auditCell = cells.length > 0 ? cells.join(" / ") : "(無法稽核)";
162
+ const auditCell = cells.length > 0 ? cells.join(" / ") : m(lang, "auditUnavailable");
137
163
  // plan_fixes_v1.0.md §4:無價目資料須與「估出來是 $0」區分,不能印成空白或 0——
138
164
  // 兩者對讀者的意義完全不同(沒資料 vs 免費)。
139
- const costCell = r.costUsd === null ? "無價目資料" : `$${r.costUsd.toFixed(4)}`;
140
- return `| ${r.agent} | ${r.provider} | ${r.api} | ${r.modelRequested} | ${r.modelReturned ?? "—"} | ${r.effort ?? "—"} | ${formatStoreCell(r.store)} | ${formatStatusCell(r)} | ${r.latencyMs}ms | ${r.usage.totalTokens} | ${costCell} | ${auditCell} |`;
165
+ const costCell = r.costUsd === null ? m(lang, "noPricingData") : `$${r.costUsd.toFixed(4)}`;
166
+ return `| ${r.agent} | ${r.provider} | ${r.api} | ${r.modelRequested} | ${r.modelReturned ?? "—"} | ${r.effort ?? "—"} | ${formatStoreCell(r.store)} | ${formatStatusCell(r, lang)} | ${r.latencyMs}ms | ${r.usage.totalTokens} | ${costCell} | ${auditCell} |`;
141
167
  });
142
- return `# dispatch summary — ${ticketId}
143
-
144
- | agent | provider | api | model(請求) | model(回傳) | effort | store | status | 耗時 | token | 估算成本 | 稽核 |
145
- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
168
+ return `${m(lang, "summaryHeader", ticketId)}
146
169
  ${rows.join("\n")}
147
170
  `;
148
171
  }
149
- export async function writeSummary(outDir, ticketId, results, audits, toolCallAudits) {
150
- const md = buildSummaryMarkdown(ticketId, results, audits, toolCallAudits);
172
+ export async function writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang) {
173
+ const md = buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang);
151
174
  await writeFile(path.join(outDir, "summary.md"), md, "utf8");
152
175
  }
package/dist/prompt.js CHANGED
@@ -39,7 +39,9 @@ const TOOL_NOTE_EN = "You have one tool, `read_file(path)`. Neither the ticket n
39
39
  // plan_dispatch_v2.1.md §8(二):組裝順序改為 agent body → 工具說明 → 回報模板。原順序
40
40
  // (agent body → 回報模板 → 工具說明)讓工具說明掉在回報模板的固定收尾句之後,在結構上
41
41
  // 像附註——而那正是唯一說明「你有工具」的段落(provider log system-1.txt 全文證實)。
42
- export function buildSystemPrompt(agentBody, lang = "zh") {
42
+ // plan_i18n_v1.3.md §一之2:不設預設值——不是安全網(九個既有呼叫端都已顯式傳值,
43
+ // 一個都不會紅),是防未來新增的呼叫端靜默拿到與產品預設(en)相反的值。
44
+ export function buildSystemPrompt(agentBody, lang) {
43
45
  const toolNote = lang === "en" ? TOOL_NOTE_EN : TOOL_NOTE;
44
46
  const template = lang === "en" ? REPORT_TEMPLATE_EN : REPORT_TEMPLATE;
45
47
  return [agentBody.trim(), toolNote, template].join("\n\n");
@@ -76,7 +78,7 @@ function displayTicketDir(ticketDir, repoRoot) {
76
78
  return ticketDir;
77
79
  return rel;
78
80
  }
79
- export function buildFirstUserText(ticketDir, agent, allowedReadsRelative, repoRoot, lang = "zh") {
81
+ export function buildFirstUserText(ticketDir, agent, allowedReadsRelative, repoRoot, lang) {
80
82
  const dir = displayTicketDir(ticketDir, repoRoot);
81
83
  if (lang === "en") {
82
84
  return `Do these in order:
@@ -98,7 +100,7 @@ ${buildStep3(allowedReadsRelative, lang)}
98
100
  「允許讀取」清單內的路徑相對於 repo 根目錄,不是相對於工單目錄。
99
101
  清單外的檔案會被拒絕。被拒時不要重試,在「無法驗證」欄記下缺什麼。`;
100
102
  }
101
- export function buildFinalizeUserText(lang = "zh") {
103
+ export function buildFinalizeUserText(lang) {
102
104
  return lang === "en"
103
105
  ? "You have reached the execution limit. Produce your report now from what you already have; do not call any more tools."
104
106
  : "已達執行上限,請依現有資訊直接產出目前的回報,不要再呼叫工具。";
package/dist/providers.js CHANGED
@@ -1,15 +1,16 @@
1
1
  // plan_dispatch_v1.4.md §5:providers.json 載入與驗證。fail closed——api 欄缺失或
2
2
  // store:true 即中止(exit 2),不套用預設值繼續跑(§10 步驟 3)。
3
3
  import { readFile } from "node:fs/promises";
4
- import { DispatchError } from "./types.js";
5
- function parseReasoning(raw, providerName) {
4
+ import { DispatchError, } from "./types.js";
5
+ import { m } from "./messages.js";
6
+ function parseReasoning(raw, providerName, lang) {
6
7
  if (!raw || typeof raw !== "object") {
7
8
  return { style: null, allowed: [] };
8
9
  }
9
10
  const r = raw;
10
11
  const style = r.style;
11
12
  if (style !== undefined && style !== "openai" && style !== "deepseek" && style !== "gemini" && style !== "anthropic") {
12
- throw new DispatchError(`providers.json: ${providerName}.reasoning.style 值不合法:"${String(style)}"`, 2);
13
+ throw new DispatchError(m(lang, "reasoningStyleInvalid", providerName, String(style)), 2);
13
14
  }
14
15
  const allowed = Array.isArray(r.allowed) ? r.allowed.filter((v) => typeof v === "string") : [];
15
16
  const modelOverrides = r.modelOverrides && typeof r.modelOverrides === "object"
@@ -21,75 +22,86 @@ function parseReasoning(raw, providerName) {
21
22
  let def;
22
23
  if (r.default !== undefined) {
23
24
  if (typeof r.default !== "string") {
24
- throw new DispatchError(`providers.json: ${providerName}.reasoning.default 須為字串`, 2);
25
+ throw new DispatchError(m(lang, "reasoningDefaultNotString", providerName), 2);
25
26
  }
26
27
  def = r.default;
27
28
  }
28
29
  if (allowed.length > 0 && def === undefined) {
29
- throw new DispatchError(`providers.json: ${providerName}.reasoning.default 缺失——allowed 非空時必填,不得回退到「不送參數」`, 2);
30
+ throw new DispatchError(m(lang, "reasoningDefaultMissing", providerName), 2);
30
31
  }
31
32
  if (def !== undefined && !allowed.includes(def)) {
32
- throw new DispatchError(`providers.json: ${providerName}.reasoning.default "${def}" 不在 allowed 內(${allowed.length > 0 ? allowed.join(", ") : "(空)"})`, 2);
33
+ const list = allowed.length > 0 ? allowed.join(", ") : m(lang, "emptyList");
34
+ throw new DispatchError(m(lang, "reasoningDefaultNotAllowed", providerName, def, list), 2);
33
35
  }
34
36
  return { style: style ?? null, allowed, default: def, modelOverrides };
35
37
  }
36
38
  // plan_fixes_v1.0.md §4:pricing 為選填——缺席的 provider/模型無法估算成本(cost.ts
37
39
  // 回傳 null,不是 0),不強制每個 provider 都要有價目。有填就驗完整,格式錯即中止
38
40
  // (fail closed,與本檔其餘欄位一致),不悄悄忽略壞掉的價目導致成本估算安靜地錯。
39
- function parsePositiveNumber(v, label) {
41
+ function parsePositiveNumber(v, label, lang) {
40
42
  if (typeof v !== "number" || !(v > 0)) {
41
- throw new DispatchError(`providers.json: ${label} 須為正數,實際為 ${JSON.stringify(v)}`, 2);
43
+ throw new DispatchError(m(lang, "positiveNumberRequired", label, JSON.stringify(v)), 2);
42
44
  }
43
45
  return v;
44
46
  }
45
- function parsePricing(raw, providerName) {
47
+ function parsePricing(raw, providerName, lang) {
46
48
  if (raw === undefined)
47
49
  return undefined;
48
50
  if (!raw || typeof raw !== "object") {
49
- throw new DispatchError(`providers.json: ${providerName}.pricing 不是物件`, 2);
51
+ throw new DispatchError(m(lang, "pricingNotObject", providerName), 2);
50
52
  }
51
53
  const out = {};
52
54
  for (const [model, entry] of Object.entries(raw)) {
53
55
  if (!entry || typeof entry !== "object") {
54
- throw new DispatchError(`providers.json: ${providerName}.pricing.${model} 不是物件`, 2);
56
+ throw new DispatchError(m(lang, "pricingModelNotObject", providerName, model), 2);
55
57
  }
56
58
  const e = entry;
57
59
  out[model] = {
58
- inputPerM: parsePositiveNumber(e.inputPerM, `${providerName}.pricing.${model}.inputPerM`),
59
- outputPerM: parsePositiveNumber(e.outputPerM, `${providerName}.pricing.${model}.outputPerM`),
60
+ inputPerM: parsePositiveNumber(e.inputPerM, `${providerName}.pricing.${model}.inputPerM`, lang),
61
+ outputPerM: parsePositiveNumber(e.outputPerM, `${providerName}.pricing.${model}.outputPerM`, lang),
60
62
  ...(e.cachedInputPerM !== undefined
61
- ? { cachedInputPerM: parsePositiveNumber(e.cachedInputPerM, `${providerName}.pricing.${model}.cachedInputPerM`) }
63
+ ? { cachedInputPerM: parsePositiveNumber(e.cachedInputPerM, `${providerName}.pricing.${model}.cachedInputPerM`, lang) }
62
64
  : {}),
63
65
  ...(e.cacheWritePerM !== undefined
64
- ? { cacheWritePerM: parsePositiveNumber(e.cacheWritePerM, `${providerName}.pricing.${model}.cacheWritePerM`) }
66
+ ? { cacheWritePerM: parsePositiveNumber(e.cacheWritePerM, `${providerName}.pricing.${model}.cacheWritePerM`, lang) }
65
67
  : {}),
66
68
  };
67
69
  }
68
70
  return out;
69
71
  }
70
- function parseProviderConfig(name, raw) {
72
+ // v0.2.0:只取 `pricingSource.asOf`,而且**刻意寬鬆**——與上面的 `parsePricing` 相反。
73
+ // 理由是兩者性質不同:`pricing` 是計費輸入,錯了成本會安靜地算錯,所以 fail closed;
74
+ // `asOf` 只是報表上的一個查證日期,壞掉的後果是「少印一個日期」。對它 fail closed 會讓
75
+ // 一份價目完全正確的 `--providers` 檔,因為 metadata 打錯字而整個跑不起來。
76
+ function parsePricingAsOf(raw) {
77
+ if (!raw || typeof raw !== "object")
78
+ return undefined;
79
+ const asOf = raw.asOf;
80
+ return typeof asOf === "string" && asOf.trim().length > 0 ? asOf.trim() : undefined;
81
+ }
82
+ function parseProviderConfig(name, raw, lang) {
71
83
  if (!raw || typeof raw !== "object") {
72
- throw new DispatchError(`providers.json: provider "${name}" 的設定不是物件`, 2);
84
+ throw new DispatchError(m(lang, "providerConfigNotObject", name), 2);
73
85
  }
74
86
  const r = raw;
75
87
  // §5:api 欄無預設值,介面選擇不容猜測——缺失即中止。
76
88
  if (r.api !== "responses" && r.api !== "gemini-native" && r.api !== "anthropic-messages") {
77
- throw new DispatchError(`providers.json: ${name}.api 缺失或不合法(須為 "responses"、"gemini-native" 或 "anthropic-messages"),實際為 ${JSON.stringify(r.api)}`, 2);
89
+ throw new DispatchError(m(lang, "providerApiInvalid", name, JSON.stringify(r.api)), 2);
78
90
  }
79
91
  // §5:store 不可為 true——違反設計原則 6(零留存)即中止。
80
92
  if (r.store === true) {
81
- throw new DispatchError(`providers.json: ${name}.store 為 true,違反設計原則 6(零留存)。載入即中止,不得依賴伺服器端狀態。`, 2);
93
+ throw new DispatchError(m(lang, "providerStoreTrue", name), 2);
82
94
  }
83
95
  const store = r.store === null ? null : false; // 未填視為 false
84
96
  if (typeof r.baseURL !== "string" || r.baseURL.length === 0) {
85
- throw new DispatchError(`providers.json: ${name}.baseURL 缺失`, 2);
97
+ throw new DispatchError(m(lang, "providerBaseUrlMissing", name), 2);
86
98
  }
87
99
  // §5:models 白名單。未填(或非陣列)視為空陣列 = 不做型號檢查。
88
100
  const models = Array.isArray(r.models) ? r.models.filter((v) => typeof v === "string") : [];
89
101
  // §5:charsPerToken。未填時用 CLI 全域值——保留 null,不在此處套 1.0(CLI 端才知道
90
102
  // 使用者傳入的 --chars-per-token 是多少)。
91
103
  if (r.charsPerToken !== undefined && (typeof r.charsPerToken !== "number" || !(r.charsPerToken > 0))) {
92
- throw new DispatchError(`providers.json: ${name}.charsPerToken 須為正數`, 2);
104
+ throw new DispatchError(m(lang, "providerCharsPerTokenInvalid", name), 2);
93
105
  }
94
106
  const charsPerToken = typeof r.charsPerToken === "number" ? r.charsPerToken : null;
95
107
  return {
@@ -97,12 +109,13 @@ function parseProviderConfig(name, raw) {
97
109
  api: r.api,
98
110
  store,
99
111
  toolCalling: r.toolCalling === true,
100
- reasoning: parseReasoning(r.reasoning, name),
112
+ reasoning: parseReasoning(r.reasoning, name, lang),
101
113
  models,
102
114
  charsPerToken,
103
115
  tpmLimit: typeof r.tpmLimit === "number" ? r.tpmLimit : null,
104
116
  maxSpokeTokens: typeof r.maxSpokeTokens === "number" ? r.maxSpokeTokens : null,
105
- pricing: parsePricing(r.pricing, name),
117
+ pricing: parsePricing(r.pricing, name, lang),
118
+ pricingAsOf: parsePricingAsOf(r.pricingSource),
106
119
  };
107
120
  }
108
121
  // plan_dispatch_v1.10.md §24.3:providers.json 隨工具出貨,不可覆寫(方案 D)。
@@ -110,42 +123,41 @@ function parseProviderConfig(name, raw) {
110
123
  // 在載入時做,不符即中止,防跨版本漂移(一份舊版 providers.json 寫著已證實不生效的
111
124
  // 參數路徑,可能回顯、不報錯,行為完全不受控,facts_dispatch.md 2026-08-06 已有實例)。
112
125
  export const PROVIDERS_FORMAT_VERSION = 1;
113
- export function parseProvidersFile(json) {
126
+ export function parseProvidersFile(json, lang) {
114
127
  if (!json || typeof json !== "object") {
115
- throw new DispatchError("providers.json 格式不是物件", 2);
128
+ throw new DispatchError(m(lang, "providersFileNotObject"), 2);
116
129
  }
117
130
  const { formatVersion, ...providerEntries } = json;
118
131
  if (formatVersion !== PROVIDERS_FORMAT_VERSION) {
119
- throw new DispatchError(`providers.json: formatVersion 不符(預期 ${PROVIDERS_FORMAT_VERSION},實際 ${JSON.stringify(formatVersion)})。` +
120
- `這通常代表 --providers 指向了舊版或不相容的檔案。`, 2);
132
+ throw new DispatchError(m(lang, "providersFormatVersionMismatch", PROVIDERS_FORMAT_VERSION, JSON.stringify(formatVersion)), 2);
121
133
  }
122
134
  const out = {};
123
135
  for (const [name, raw] of Object.entries(providerEntries)) {
124
- out[name] = parseProviderConfig(name, raw);
136
+ out[name] = parseProviderConfig(name, raw, lang);
125
137
  }
126
138
  return out;
127
139
  }
128
- export async function loadProviders(filePath) {
140
+ export async function loadProviders(filePath, lang) {
129
141
  let text;
130
142
  try {
131
143
  text = await readFile(filePath, "utf8");
132
144
  }
133
145
  catch {
134
- throw new DispatchError(`找不到 providers.json:${filePath}`, 2);
146
+ throw new DispatchError(m(lang, "missingProviders", filePath), 2);
135
147
  }
136
148
  let json;
137
149
  try {
138
150
  json = JSON.parse(text);
139
151
  }
140
152
  catch (err) {
141
- throw new DispatchError(`providers.json 不是合法 JSON:${err.message}`, 2);
153
+ throw new DispatchError(m(lang, "providersFileInvalidJson", err.message), 2);
142
154
  }
143
- return parseProvidersFile(json);
155
+ return parseProvidersFile(json, lang);
144
156
  }
145
- export function getProviderConfig(providers, name) {
157
+ export function getProviderConfig(providers, name, lang) {
146
158
  const config = providers[name];
147
159
  if (!config) {
148
- throw new DispatchError(`providers.json 未定義 provider "${name}"`, 2);
160
+ throw new DispatchError(m(lang, "providerUndefined", name), 2);
149
161
  }
150
162
  return config;
151
163
  }
@@ -17,33 +17,34 @@
17
17
  // 物件被傳過去」,而非「長得像的東西存在」,後者會掩蓋掉真正的 bug(例如重新建構出一個
18
18
  // 內容相似但簽章欄位被序列化過程改寫的複製品)。若未來 adapter 需要對 raw 做任何正規化,
19
19
  // 這個判準本身就需要重新設計——那是規格問題,不是這裡的實作可以自行決定的。
20
+ import { m } from "./messages.js";
20
21
  export class RawIntegrityError extends Error {
21
22
  constructor(message) {
22
23
  super(message);
23
24
  this.name = "RawIntegrityError";
24
25
  }
25
26
  }
26
- export function checkRawArrayIntegrity(conv, builtInput) {
27
+ export function checkRawArrayIntegrity(conv, builtInput, lang) {
27
28
  for (const turn of conv.turns) {
28
29
  if (turn.role !== "assistant")
29
30
  continue;
30
31
  if (!Array.isArray(turn.raw)) {
31
- throw new RawIntegrityError("raw 完整性檢查失敗:assistant turn 的 raw 不是陣列(responses adapter 預期 raw 為上一輪 response.output 陣列)");
32
+ throw new RawIntegrityError(m(lang, "rawIntegrityNotArray"));
32
33
  }
33
34
  for (const item of turn.raw) {
34
35
  if (!builtInput.includes(item)) {
35
36
  const type = item?.type ?? "?";
36
- throw new RawIntegrityError(`raw 完整性檢查失敗:assistant turn 有一個 item(type=${type})未以原樣出現在送出的請求中,疑似續接時被過濾掉`);
37
+ throw new RawIntegrityError(m(lang, "rawIntegrityItemNotFound", type));
37
38
  }
38
39
  }
39
40
  }
40
41
  }
41
- export function checkRawObjectIntegrity(conv, builtContents) {
42
+ export function checkRawObjectIntegrity(conv, builtContents, lang) {
42
43
  for (const turn of conv.turns) {
43
44
  if (turn.role !== "assistant")
44
45
  continue;
45
46
  if (!builtContents.includes(turn.raw)) {
46
- throw new RawIntegrityError("raw 完整性檢查失敗:assistant turn 的 raw 未以原樣出現在送出的 contents 中,疑似續接時被重建或漏帶");
47
+ throw new RawIntegrityError(m(lang, "rawIntegrityObjectNotFound"));
47
48
  }
48
49
  }
49
50
  }
package/dist/report.js CHANGED
@@ -1,16 +1,19 @@
1
1
  // plan_dispatch_v1.4.md §11:派工報表(呼叫前)。模型錯了、effort 不如預期、介面走錯、
2
2
  // 成本量級不對,在送出前就看得到。不使用 ✓/✗ 標記——TPM 是滑動窗,429 等待會動態改變
3
3
  // 實際並行數,靜態估算無法精確預測。
4
+ import { stripFrontmatter } from "./ticket.js";
5
+ import { FIXED_CLOSING_LINE, FIXED_CLOSING_LINE_EN } from "./audit.js";
6
+ import { m } from "./messages.js";
4
7
  export function effectiveCap(spoke, cli) {
5
8
  return spoke.providerConfig.maxSpokeTokens ?? cli.maxSpokeTokens;
6
9
  }
7
10
  export function effectiveCharsPerToken(spoke, cli) {
8
11
  return spoke.providerConfig.charsPerToken ?? cli.charsPerToken;
9
12
  }
10
- function formatProvidersSource(src) {
13
+ function formatProvidersSource(src, lang) {
11
14
  if (src.kind === "bundled")
12
- return `出貨(formatVersion ${src.formatVersion})`;
13
- return `外部檔 ${src.path}(formatVersion ${src.formatVersion})`;
15
+ return m(lang, "providersBundled", src.formatVersion);
16
+ return m(lang, "providersExplicit", src.path ?? "", src.formatVersion);
14
17
  }
15
18
  // v1.10 §10:三態不得合併——「未忽略」是需要處理的狀態,「無法判定」不是,措辭須區分。
16
19
  //
@@ -18,22 +21,64 @@ function formatProvidersSource(src) {
18
21
  // 已訂輸出目錄屬呼叫端 cwd,checkGitignore 檢查的也是 cwd 所屬的 repo——cwd 與
19
22
  // --repo-root 指的目標專案是兩回事(v1.11 §10 修的正是這個基準不一致)。「目標專案」
20
23
  // 這個詞在 cwd ≠ repoRoot 時會指錯對象,故改為不預設兩者相同的措辭。
21
- function formatGitignoreWarning(status, outDir) {
24
+ function formatGitignoreWarning(status, outDir, lang) {
22
25
  if (status === "ignored")
23
26
  return null;
24
27
  if (status === "not_ignored") {
25
- return ` ⚠ 輸出目錄 ${outDir} 未被輸出目錄所在的 git repo 忽略`;
28
+ return m(lang, "gitignoreNotIgnored", outDir);
26
29
  }
27
- return ` ℹ 無法判定輸出目錄 ${outDir} 是否被 .gitignore 涵蓋(非 git repo 或 git 不可用)`;
30
+ return m(lang, "gitignoreUnknown", outDir);
28
31
  }
29
- export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli, outDir, meta) {
32
+ // plan_i18n_v1.2.md §6.3:呼叫前提示 lens 收尾句落在哪一個 FIXED_CLOSING_LINE*——只提示不擋
33
+ // (同既有 gitignore 警告),讓 lens 檔案本身的收尾句指示(如「回報最後一行固定為:...」)
34
+ // 在真正付費呼叫之前先被人看到有沒有漂移。agentBody 已在 resolveSpokes 剝過 frontmatter,
35
+ // 這裡再跑一次 stripFrontmatter 是冪等的(不再以 --- 開頭,regex 不命中),不需重讀檔。
36
+ function lensClosingLineStatus(agentBody) {
37
+ const lines = stripFrontmatter(agentBody).split(/\r?\n/).map((l) => l.trim());
38
+ const lastNonEmpty = [...lines].reverse().find((l) => l.length > 0) ?? "";
39
+ if (lastNonEmpty.includes(FIXED_CLOSING_LINE))
40
+ return "zh";
41
+ if (lastNonEmpty.includes(FIXED_CLOSING_LINE_EN))
42
+ return "en";
43
+ return "none";
44
+ }
45
+ // 沒有收尾句的 lens(如 explore-haiku.md,不是 hole-finder 系列、不受這份收尾句約定拘束)
46
+ // 回傳 null、不印任何提示——只對「有收尾句、值得比對」的 lens 發聲,才不會對每一支
47
+ // 不相干的 lens 都噴一行「未偵測到」的常駐噪音。
48
+ function formatLensClosingLineNotice(spoke, lang) {
49
+ const status = lensClosingLineStatus(spoke.agentBody);
50
+ if (status === "zh")
51
+ return m(lang, "lensClosingLineZh", spoke.agent);
52
+ if (status === "en")
53
+ return m(lang, "lensClosingLineEn", spoke.agent);
54
+ return null;
55
+ }
56
+ // v0.2.0:把本次型號的單價印進乾跑報表。起因是 /publish-check 判斷清單第 4 項
57
+ // (「指向的機制在外部專案存在嗎」):skill 改成「價目查 providers.json,不要查官網」之後,
58
+ // 外部專案的 hub 其實找不到那個檔——它隨套件出貨,躺在 node_modules 或全域安裝目錄底下,
59
+ // 路徑帶版本號與相依雜湊,猜不到。找不到就會退回舊行為去查官網,等於那條規則只修了一半。
60
+ //
61
+ // **這一行讓那個檔不必被找到。** 順帶也解決 `--providers` 覆寫之後 hub 讀到錯的那一份。
62
+ // 缺價目時明講「無價目資料」而不是印 0——與 cost.ts 的 null 語意一致(§4:「無法估算」
63
+ // 與「估算出來是零」不是同一件事)。
64
+ function formatModelPricing(spoke, lang) {
65
+ const pricing = spoke.providerConfig.pricing?.[spoke.model];
66
+ if (!pricing)
67
+ return m(lang, "modelPricingMissing", spoke.model);
68
+ const cached = pricing.cachedInputPerM !== undefined ? m(lang, "modelPricingCachedSuffix", pricing.cachedInputPerM) : "";
69
+ const asOf = spoke.providerConfig.pricingAsOf
70
+ ? m(lang, "modelPricingAsOfSuffix", spoke.providerConfig.pricingAsOf)
71
+ : "";
72
+ return m(lang, "modelPricing", pricing.inputPerM, pricing.outputPerM, cached, asOf);
73
+ }
74
+ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli, outDir, meta, lang) {
30
75
  const estByAgent = new Map(estimates.map((e) => [e.agent, e.estimatedTokens]));
31
- const lines = [`即將派工 ${ticketId}:`];
76
+ const lines = [m(lang, "aboutToDispatch", ticketId)];
32
77
  // v1.10 §11:repoRoot 明列於報表,與 store 同性質的可見性保證——白名單是整套隔離的
33
78
  // 唯一支點(§7),其邊界由「從哪個目錄下指令」決定,這件事在報表上必須看得見,否則
34
79
  // 使用者無從發現自己在錯的目錄下跑。
35
80
  lines.push(` repoRoot ${meta.repoRoot}`);
36
- lines.push(` providers ${formatProvidersSource(meta.providersSource)}`);
81
+ lines.push(` providers ${formatProvidersSource(meta.providersSource, lang)}`);
37
82
  for (const spoke of spokes) {
38
83
  const est = estByAgent.get(spoke.agent) ?? 0;
39
84
  const cap = effectiveCap(spoke, cli);
@@ -41,6 +86,10 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
41
86
  `[${spoke.providerConfig.api}] effort=${spoke.effort ?? "—"} lang=${spoke.lang} ` +
42
87
  `store=${spoke.providerConfig.store === false ? "false" : "n/a"} ` +
43
88
  `est. ${est.toLocaleString()} cap ${cap.toLocaleString()}`);
89
+ lines.push(` ${formatModelPricing(spoke, lang)}`);
90
+ const closingLineNotice = formatLensClosingLineNotice(spoke, lang);
91
+ if (closingLineNotice)
92
+ lines.push(` ${closingLineNotice}`);
44
93
  }
45
94
  const totalEst = estimates.reduce((s, e) => s + e.estimatedTokens, 0);
46
95
  const worstTotal = spokes.reduce((s, spoke) => s + effectiveCap(spoke, cli), 0);
@@ -48,37 +97,42 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
48
97
  // 這個數字只涵蓋 system prompt + buildFirstUserText() 產出的第一則訊息,不含工單與
49
98
  // 允許清單本身(那些是 spoke 自己用 tool call 讀進去的)。實測差距 27 倍(2,593 vs
50
99
  // 71,482)。標籤明講範圍,並緊接印出允許清單總量,讓兩個數字一起出現、不必自己去找。
51
- lines.push(` 初始 prompt 估算 ${totalEst.toLocaleString()} tokens(僅 system prompt+首則訊息,不含工單與允許清單;本閘門的估算上限 ${cli.maxTokens.toLocaleString()})`);
100
+ lines.push(m(lang, "initialPromptEstimate", totalEst.toLocaleString(), cli.maxTokens.toLocaleString()));
52
101
  // §14:獨立於閘門一之外,只呈現不設閘門——閘門一不含允許清單與工單內容(issue_log_v2.0.md
53
102
  // 2026-08-07),此數字才是「若整個允許清單都被讀完」的量級參考,門檻待樣本累積後再定。
54
103
  const totalAllowlistTokens = allowlistEstimates.reduce((s, e) => s + e.estimatedTokens, 0);
55
104
  const totalAllowlistFiles = allowlistEstimates.reduce((s, e) => s + e.fileCount, 0);
56
- lines.push(` 允許清單總量估算 ${totalAllowlistTokens.toLocaleString()} tokens(${totalAllowlistFiles} 檔)`);
105
+ lines.push(m(lang, "allowlistTotalEstimate", totalAllowlistTokens.toLocaleString(), totalAllowlistFiles));
57
106
  // plan_dispatch_v2.4.md §14:口徑說明——避免此數字被誤讀為預期消耗。三件事:
58
107
  // (1) 字元數的上限估計,不是預期消耗;(2) 不去重,同一檔出現在多支清單會重複計入
59
108
  // (正確行為:兩支各讀一次就是兩份成本);(3) 實測程式碼素材約 3.5 字元/token,
60
109
  // 故實際消耗通常遠低於此數(charsPerToken 假設 1.0,係數本身刻意不動,見 §14)。
61
- lines.push(` └ 上限估計,不去重;實測程式碼素材約 3.5 字元/token,實際消耗通常遠低於此數`);
110
+ lines.push(m(lang, "allowlistEstimateCaveat"));
62
111
  // issue_log_v2.1.md(第 8、9 次):多數模型一輪只叫一個檔,每輪重送全部歷史,故清單靠前
63
112
  // 的檔會被重複計費多次。這一行是「順序造成的放大量」,也是唯一 hub 改得動的成本槓桿——
64
113
  // 只印排序能省的部分,不印預估總量:總量還受「批次讀 vs 逐個讀」影響(模型決定,我們
65
114
  // 控制不了),印出來會被當成預期值。省下的百分比則不受 charsPerToken 偏差影響(分子分母
66
115
  // 抵銷),是這裡唯一可信的絕對數字。
116
+ //
117
+ // real-run-same-lens §九之6 要求這一行標明「此為逐個讀假設下的上限」,訊息文字已補。
118
+ // real-run-i18n-lang(2026-08-12)另加一條:讀檔形態**不只隨型號變,也隨 prompt 語言變**
119
+ // ——同型號同 effort,只把語言從中文換成英文,luna 的 feasibility 就從 9 輪(逐個)
120
+ // 變 4 輪(批次)。所以「這個廠牌會不會批次」在乾跑階段更不可能預先判定。
67
121
  const seqAsListed = allowlistEstimates.reduce((s, e) => s + e.sequential.asListed, 0);
68
122
  const seqSorted = allowlistEstimates.reduce((s, e) => s + e.sequential.sorted, 0);
69
123
  if (seqAsListed > 0) {
70
124
  const savedPct = Math.round(((seqAsListed - seqSorted) / seqAsListed) * 100);
71
- lines.push(` 逐個讀的順序放大量 ${seqAsListed.toLocaleString()} tokens(清單內容被重送的總量)`);
125
+ lines.push(m(lang, "sequentialReadAmplification", seqAsListed.toLocaleString()));
72
126
  if (savedPct >= 5) {
73
- lines.push(` └ ⚠ 大檔排清單最後可降至 ${seqSorted.toLocaleString()}(本項省 ${savedPct}%)`);
127
+ lines.push(m(lang, "sequentialReadCanReduce", seqSorted.toLocaleString(), savedPct));
74
128
  }
75
129
  else {
76
- lines.push(` └ 目前順序已接近最佳(重排最多再省 ${savedPct}%)`);
130
+ lines.push(m(lang, "sequentialReadNearOptimal", savedPct));
77
131
  }
78
- lines.push(` 本項不含初始 prompt 與工單(不受排序影響),故總成本的節省比例低於此數`);
132
+ lines.push(m(lang, "sequentialReadCostNote"));
79
133
  }
80
- lines.push(` 最壞總消耗 ≈ ${worstTotal.toLocaleString()} tokens(各 spoke 之 cap 加總)`);
81
- lines.push(` 並行度 ${cli.concurrency}`);
134
+ lines.push(m(lang, "worstCaseTotal", worstTotal.toLocaleString()));
135
+ lines.push(m(lang, "concurrencyLine", cli.concurrency));
82
136
  const byProvider = new Map();
83
137
  for (const spoke of spokes) {
84
138
  const est = estByAgent.get(spoke.agent) ?? 0;
@@ -89,12 +143,12 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
89
143
  for (const [provider, { tpmLimit, peak }] of byProvider) {
90
144
  if (tpmLimit === null)
91
145
  continue;
92
- lines.push(` ${provider} tpmLimit ${tpmLimit.toLocaleString()},靜態估算峰值 ${peak.toLocaleString()}`);
93
- lines.push(` └ 僅為靜態指標,不預測執行中的 TPM 曲線(429 等待會改變實際並行數)`);
146
+ lines.push(m(lang, "tpmPeakLine", provider, tpmLimit.toLocaleString(), peak.toLocaleString()));
147
+ lines.push(m(lang, "tpmPeakCaveat"));
94
148
  }
95
149
  const allowedCount = spokes.reduce((s, spoke) => s + spoke.allowedReadsResolved.length, 0);
96
- lines.push(` 允許讀取 ${allowedCount} 個檔案,輸出至 ${outDir}/`);
97
- const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir);
150
+ lines.push(m(lang, "allowedReadsSummary", allowedCount, outDir));
151
+ const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir, lang);
98
152
  if (gitignoreWarning)
99
153
  lines.push(gitignoreWarning);
100
154
  return lines.join("\n");