dowafu 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +44 -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 +22 -14
  7. package/dist/audit.js +17 -1
  8. package/dist/cli-args.js +88 -38
  9. package/dist/cli.js +60 -43
  10. package/dist/dispatch-home.js +24 -6
  11. package/dist/gate.js +3 -2
  12. package/dist/mask.js +16 -3
  13. package/dist/messages.js +342 -0
  14. package/dist/output.js +60 -37
  15. package/dist/prompt.js +5 -3
  16. package/dist/providers.js +46 -34
  17. package/dist/raw-integrity.js +6 -5
  18. package/dist/report.js +76 -22
  19. package/dist/runner.js +21 -10
  20. package/dist/ticket.js +27 -25
  21. package/dist/validate.js +21 -20
  22. package/dist/whitelist.js +9 -1
  23. package/package.json +3 -2
  24. package/publish/en/.agents/skills/find-holes-external/SKILL.md +394 -0
  25. package/publish/en/.agents/skills/preflight/SKILL.md +120 -0
  26. package/publish/en/.agents/skills/wrap/SKILL.md +64 -0
  27. package/publish/en/.claude/agents/explore-haiku.md +8 -0
  28. package/publish/en/.claude/agents/hole-finder-cost.md +15 -0
  29. package/publish/en/.claude/agents/hole-finder-feasibility.md +15 -0
  30. package/publish/en/.claude/agents/hole-finder-safety.md +15 -0
  31. package/publish/en/.claude/agents/hole-finder.md +14 -0
  32. package/publish/en/.claude/skills/find-holes/SKILL.md +109 -0
  33. package/publish/en/.claude/skills/find-holes-external/SKILL.md +407 -0
  34. package/publish/en/.claude/skills/preflight/SKILL.md +179 -0
  35. package/publish/en/.claude/skills/wrap/SKILL.md +61 -0
  36. package/publish/en/README.md +106 -0
  37. package/publish/en/workflow_spec.md +71 -0
  38. package/publish/{.agents → zh-tw/.agents}/skills/find-holes-external/SKILL.md +109 -18
  39. package/publish/{.agents → zh-tw/.agents}/skills/preflight/SKILL.md +22 -5
  40. package/publish/{.claude → zh-tw/.claude}/skills/find-holes/SKILL.md +21 -4
  41. package/publish/{.claude → zh-tw/.claude}/skills/find-holes-external/SKILL.md +108 -17
  42. package/publish/{.claude → zh-tw/.claude}/skills/preflight/SKILL.md +21 -4
  43. package/publish/{README.md → zh-tw/README.md} +16 -0
  44. /package/publish/{.agents → zh-tw/.agents}/skills/wrap/SKILL.md +0 -0
  45. /package/publish/{.claude → zh-tw/.claude}/agents/explore-haiku.md +0 -0
  46. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-cost.md +0 -0
  47. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-feasibility.md +0 -0
  48. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-safety.md +0 -0
  49. /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder.md +0 -0
  50. /package/publish/{.claude → zh-tw/.claude}/skills/wrap/SKILL.md +0 -0
  51. /package/publish/{workflow_spec.md → zh-tw/workflow_spec.md} +0 -0
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");
package/dist/runner.js CHANGED
@@ -3,15 +3,23 @@
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { buildFinalizeUserText, buildFirstUserText, buildSystemPrompt } from "./prompt.js";
6
- import { ALLOWLIST_REJECT_MESSAGE, checkAllowlist } from "./whitelist.js";
6
+ import { allowlistRejectMessage, checkAllowlist } from "./whitelist.js";
7
7
  import { findUnknownUsageKeys, sumUsage, usageProviderKeyFor } from "./usage.js";
8
8
  import { estimateCostUsd } from "./cost.js";
9
9
  import { describeError, ProviderHttpError } from "./mask.js";
10
10
  import { parseRetryAfter } from "./rate-limit.js";
11
11
  import { RawIntegrityError } from "./raw-integrity.js";
12
12
  import { classifyError } from "./error-classify.js";
13
+ import { m } from "./messages.js";
13
14
  const MAX_FILE_BYTES = 200 * 1024; // §13:單檔 200KB 上限,防「單輪讀入巨檔」異常
14
15
  const MAX_ROUNDS = 60; // 安全上限,遠高於實務上的 --max-tool-calls,純防無窮迴圈 bug
16
+ // i18n_classification_t2.md §三之1:這兩則都成為 executeToolCall/runSpoke 的
17
+ // resultText,被 push 進 conv.turns 送往外部 API——消費者是 spoke 不是人類,歸 C 類,
18
+ // 不進 messages.ts,由 spoke.lang 直接選用(同 whitelist.ts 的 allowlistRejectMessage)。
19
+ const FILE_TRUNCATED_SUFFIX = "\n...(檔案超過 200KB 上限,內容已截斷)";
20
+ const FILE_TRUNCATED_SUFFIX_EN = "\n...(file exceeds the 200KB limit; content truncated)";
21
+ const TOOL_LIMIT_REACHED_MESSAGE = "已達 --max-tool-calls 上限,未執行";
22
+ const TOOL_LIMIT_REACHED_MESSAGE_EN = "The --max-tool-calls limit has been reached; not executed.";
15
23
  function sleep(ms) {
16
24
  return new Promise((resolve) => setTimeout(resolve, ms));
17
25
  }
@@ -21,7 +29,7 @@ async function executeToolCall(call, spoke, repoRoot) {
21
29
  const check = checkAllowlist(spoke.allowSet, requestedPath, repoRoot);
22
30
  if (!check.allowed) {
23
31
  return {
24
- resultText: ALLOWLIST_REJECT_MESSAGE,
32
+ resultText: allowlistRejectMessage(spoke.lang),
25
33
  log: { path: requestedPath, allowed: false, reason: check.reason, startedAt, durationMs: Date.now() - startedAt },
26
34
  };
27
35
  }
@@ -29,7 +37,8 @@ async function executeToolCall(call, spoke, repoRoot) {
29
37
  const buf = await readFile(check.realPath);
30
38
  let content = buf.toString("utf8");
31
39
  if (buf.byteLength > MAX_FILE_BYTES) {
32
- content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + "\n...(檔案超過 200KB 上限,內容已截斷)";
40
+ const suffix = spoke.lang === "en" ? FILE_TRUNCATED_SUFFIX_EN : FILE_TRUNCATED_SUFFIX;
41
+ content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + suffix;
33
42
  }
34
43
  return {
35
44
  resultText: content,
@@ -38,7 +47,7 @@ async function executeToolCall(call, spoke, repoRoot) {
38
47
  }
39
48
  catch {
40
49
  return {
41
- resultText: ALLOWLIST_REJECT_MESSAGE,
50
+ resultText: allowlistRejectMessage(spoke.lang),
42
51
  log: { path: requestedPath, allowed: false, reason: "not_found", startedAt, durationMs: Date.now() - startedAt },
43
52
  };
44
53
  }
@@ -68,19 +77,19 @@ async function sendWithResilience(adapter, conv, sendOpts, cfg, ctx) {
68
77
  // §8:raw 完整性違反是實作缺陷,不是 API 錯誤——重試只會確定性地再次觸發同一個
69
78
  // bug(下一輪的 conv 結構仍帶著同樣的漏洞),不消耗任何網路呼叫也不可能成功。
70
79
  // 立即判定失敗,不進入一般失敗的重試計數。
71
- recordError(err, `raw 完整性檢查失敗(實作缺陷,不重試):${err.message}`, null);
80
+ recordError(err, m(ctx.lang, "rawIntegrityCheckFailed", err.message), null);
72
81
  return { ok: false, kind: "failed" };
73
82
  }
74
83
  const info = describeError(err);
75
84
  if (info.is429) {
76
85
  rateLimitAttempt++;
77
86
  if (rateLimitAttempt > cfg.rateLimitRetries) {
78
- recordError(err, `429 撞牆次數超過 --rate-limit-retries (${cfg.rateLimitRetries})`, info.status ?? 429, info.errorBody);
87
+ recordError(err, m(ctx.lang, "rateLimitRetriesExceeded", cfg.rateLimitRetries), info.status ?? 429, info.errorBody);
79
88
  return { ok: false, kind: "rate_limited" };
80
89
  }
81
90
  const { seconds, source } = parseRetryAfter(info.retryAfterHeader, info.message, rateLimitAttempt - 1);
82
91
  if (seconds > cfg.maxRateWaitSec) {
83
- recordError(err, `429 要求等待 ${seconds}s,超過 --max-rate-wait ${cfg.maxRateWaitSec}s`, info.status ?? 429, info.errorBody);
92
+ recordError(err, m(ctx.lang, "rateLimitWaitExceeded", seconds, cfg.maxRateWaitSec), info.status ?? 429, info.errorBody);
84
93
  return { ok: false, kind: "rate_limited" };
85
94
  }
86
95
  ctx.rateLimitHits.push({ at: Date.now(), waitSeconds: seconds, source });
@@ -157,6 +166,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
157
166
  addWaitedMs: (ms) => {
158
167
  waitedMs += ms;
159
168
  },
169
+ lang: spoke.lang,
160
170
  });
161
171
  if (!outcome.ok) {
162
172
  const hasContent = rawResponses.length > 0;
@@ -206,7 +216,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
206
216
  conv.turns.push(result.turn);
207
217
  if (!result.usage.available) {
208
218
  status = "truncated:usage_unavailable";
209
- errors.push(`round ${round}: usage 不可用(usageMissing),保守收束`);
219
+ errors.push(m(spoke.lang, "usageUnavailableRound", round));
210
220
  finalText = result.meta.text ?? finalText;
211
221
  if (toolCallsThisRound.length === 0 || finalizeMode)
212
222
  break;
@@ -220,7 +230,7 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
220
230
  }
221
231
  if (finalizeMode) {
222
232
  // 收束呼叫理論上不帶 tool,仍收到 tool call 屬異常,防禦性丟棄不執行
223
- errors.push(`round ${round}: 收束呼叫仍回傳 tool call,忽略`);
233
+ errors.push(m(spoke.lang, "finalizeToolCallIgnored", round));
224
234
  finalText = result.meta.text;
225
235
  break;
226
236
  }
@@ -267,7 +277,8 @@ export async function runSpoke(spoke, adapter, ticketDir, options) {
267
277
  };
268
278
  toolCalls.push(log);
269
279
  options.onEvent({ type: "tool_call", agent: spoke.agent, path: log.path, allowed: false, reason: log.reason });
270
- conv.turns.push({ role: "tool", callId: call.id, result: "已達 --max-tool-calls 上限,未執行" });
280
+ const limitMessage = spoke.lang === "en" ? TOOL_LIMIT_REACHED_MESSAGE_EN : TOOL_LIMIT_REACHED_MESSAGE;
281
+ conv.turns.push({ role: "tool", callId: call.id, result: limitMessage });
271
282
  continue;
272
283
  }
273
284
  const { resultText, log } = await executeToolCall(call, spoke, options.repoRoot);