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/cli-args.js CHANGED
@@ -7,27 +7,7 @@
7
7
  // 完整用法(原僅一行)。
8
8
  import { DispatchError } from "./types.js";
9
9
  import { getCommandName } from "./pkg-info.js";
10
- export const HELP_TEXT = `用法:${getCommandName()} <ticket-dir> [options]
11
-
12
- --repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
13
- --providers <path> 整檔取代出貨的 providers.json
14
- --json stdout 只印結果 JSON,其餘輸出改走 stderr
15
- --out <dir> 落檔目錄,預設 tmp/spoke/
16
- --concurrency <n> 同時執行的 spoke 數,預設 2
17
- --max-tokens <n> 呼叫前估算閘門(各 spoke 初始 prompt 總和),預設 200000
18
- --max-spoke-tokens <n> 單一 spoke 執行期累積上限(實際 usage),預設 400000
19
- --timeout <sec> 單次 API 呼叫逾時(不是整支 spoke),預設 600
20
- --retries <n> 單輪呼叫的重試次數(僅暫時性錯誤),預設 2
21
- --chars-per-token <n> 閘門一估算係數,預設 1.0(可由 providers.json 逐家覆寫)
22
- --max-spoke-reasoning-tokens <n> 單一 spoke 的推理 token 累積上限,預設 50000
23
- --max-round-reasoning-tokens <n> 單輪推理 token 上限,預設 null(不檢查)
24
- --rate-limit-retries <n> 429 專用重試次數,預設 5(不計入 --retries)
25
- --max-rate-wait <sec> 單次 429 等待上限,預設 30
26
- --max-tool-calls <n> 單一 spoke 的 read_file 呼叫上限,預設 30
27
- --dry-run 解析、驗證、估算、印報表,不呼叫 API
28
- --yes 略過派工確認。非互動環境(stdin 不是 TTY)沒帶就中止
29
- --help, -h 印本說明後結束(exit 0)
30
- --version, -V 印版本號後結束(exit 0)`;
10
+ import { m } from "./messages.js";
31
11
  const DEFAULTS = {
32
12
  out: "tmp/spoke",
33
13
  concurrency: 2,
@@ -50,7 +30,58 @@ const DEFAULTS = {
50
30
  dryRun: false,
51
31
  yes: false,
52
32
  };
53
- export function parseArgs(argv) {
33
+ // plan_i18n_v1.3.md §二之1:--lang 與 DISPATCH_LANG 走同一個判定函式,接受 en/zh-tw/zh,
34
+ // 大小寫不敏感、trim。不共用的話會出現「同一個值放旗標有效、放環境變數無效」這種依媒介
35
+ // 分裂的邊界,是最難查的那種不一致。
36
+ export function parseLang(value) {
37
+ const v = value.trim().toLowerCase();
38
+ if (v === "en")
39
+ return "en";
40
+ if (v === "zh-tw" || v === "zh")
41
+ return "zh";
42
+ return null;
43
+ }
44
+ // plan_i18n_impl_tickets T3:resolveLang 之前,還有一段訊息需要語言——parseArgs 自己的
45
+ // 解析期錯誤、以及 resolveLang 判定失敗時它自己要拋的那兩則訊息。這段時序裡還沒有
46
+ // 「權威語言」可用(--lang 可能就是壞的那個值,資格未定;resolveLang 本身還沒跑完,
47
+ // 不能拿它自己會拋例外的回傳值來決定拋例外訊息用什麼語言)。
48
+ //
49
+ // v1.2 §1.3/§3.2:這類訊息一律走「DISPATCH_LANG 有效就用它,否則用內建預設 en」——
50
+ // 不查 --lang(它在這個時間點的有效性正是問題本身),也不會拋例外。cli.ts 在
51
+ // registerSecrets/loadDispatchEnv 之後、parseArgs 之前算好這個值,一併傳給 parseArgs
52
+ // 與用於 --help 本身的輸出;resolveLang 內部對它自己的兩則錯誤訊息也重用同一函式。
53
+ export function resolveFallbackLang(envLangRaw) {
54
+ const trimmed = (envLangRaw ?? "").trim();
55
+ if (!trimmed)
56
+ return "en";
57
+ return parseLang(trimmed) ?? "en";
58
+ }
59
+ // plan_i18n_v1.3.md §二之2:--lang 與 DISPATCH_LANG 的完整組合表(九項)。
60
+ // 呼叫時機:parseArgs 之後、help/version 短路之後——在此之前不得驗證語言。
61
+ export function resolveLang(cliLangRaw, envLangRaw) {
62
+ const envTrimmed = (envLangRaw ?? "").trim();
63
+ const envIsSet = envTrimmed.length > 0; // #7:空字串/全空白視為「未設定」,不是無效
64
+ const envParsed = envIsSet ? parseLang(envTrimmed) : null;
65
+ const msgLang = resolveFallbackLang(envLangRaw);
66
+ if (cliLangRaw !== undefined) {
67
+ const cliParsed = parseLang(cliLangRaw);
68
+ if (cliParsed !== null)
69
+ return cliParsed; // #1:旗標有效,不驗 DISPATCH_LANG
70
+ // #4:旗標無效——一併指出 env 是否也無效,避免使用者修好旗標後才撞到 env 又是壞的。
71
+ const envNote = envIsSet && envParsed === null ? m(msgLang, "invalidEnvAlsoNote", envLangRaw) : "";
72
+ throw new DispatchError(m(msgLang, "invalidLangFlag", cliLangRaw, m(msgLang, "availableValuesSuffix", m(msgLang, "availableLangValues")), envNote), 2);
73
+ }
74
+ if (!envIsSet)
75
+ return "en"; // #7:未設定 → 內建預設
76
+ if (envParsed !== null)
77
+ return envParsed; // #2
78
+ // #3:DISPATCH_LANG 無效且沒有有效 --lang——訊息須指名是環境變數,不是旗標。
79
+ throw new DispatchError(m(msgLang, "invalidEnvLang", envLangRaw, m(msgLang, "availableValuesSuffix", m(msgLang, "availableLangValues"))), 2);
80
+ }
81
+ // plan_i18n_impl_tickets T3:lang 是「訊息語言」,即 resolveFallbackLang 算出的那個值
82
+ // (見上方說明)——parseArgs 這個階段權威語言尚未確定,不是 run-level lang。
83
+ export function parseArgs(argv, lang) {
84
+ const helpText = m(lang, "helpText", getCommandName());
54
85
  // --help/--version 可出現在任何位置,且優先於其他一切解析——不要求先有 ticketDir。
55
86
  if (argv.includes("--help") || argv.includes("-h"))
56
87
  return { mode: "help" };
@@ -58,11 +89,12 @@ export function parseArgs(argv) {
58
89
  return { mode: "version" };
59
90
  const options = { ...DEFAULTS };
60
91
  let ticketDir;
92
+ let doctorMode = false;
61
93
  const numFlag = (name, apply) => {
62
94
  flagHandlers[name] = (v) => {
63
95
  const n = Number(v);
64
96
  if (!Number.isFinite(n))
65
- throw new DispatchError(`--${name} 需要數字,收到:${v}`, 2);
97
+ throw new DispatchError(m(lang, "numberFlagInvalid", name, v), 2);
66
98
  apply(n);
67
99
  };
68
100
  };
@@ -70,6 +102,10 @@ export function parseArgs(argv) {
70
102
  flagHandlers["out"] = (v) => (options.out = v);
71
103
  flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
72
104
  flagHandlers["providers"] = (v) => (options.providersPath = v);
105
+ // 這裡只存原始字串,不驗證格式——格式與 DISPATCH_LANG 的組合判定需要 process.env,
106
+ // parseArgs 是純函式不碰它,交給 cli.ts 呼叫 resolveLang(見下)。重複帶 --lang 時
107
+ // 最後一次覆蓋前一次,與其他旗標一致(v1.3 §二之2 #8)。
108
+ flagHandlers["lang"] = (v) => (options.langRaw = v);
73
109
  numFlag("concurrency", (n) => (options.concurrency = n));
74
110
  numFlag("max-tokens", (n) => (options.maxTokens = n));
75
111
  numFlag("max-spoke-tokens", (n) => (options.maxSpokeTokens = n));
@@ -92,14 +128,23 @@ export function parseArgs(argv) {
92
128
  else if (arg === "--json") {
93
129
  options.json = true;
94
130
  }
131
+ else if (arg === "--doctor") {
132
+ // 工單 W1 §一之1:與 --help/--version 同一層短路,但要能分辨「多給了 ticket-dir」,
133
+ // 故不在頂部用 argv.includes() 直接短路,而是隨主迴圈掃完,迴圈後統一判定
134
+ // (見迴圈後方)——這樣 --doctor 與 --lang 之類的已知旗標仍可同時給、彼此不衝突。
135
+ doctorMode = true;
136
+ }
95
137
  else if (arg.startsWith("--")) {
96
138
  const name = arg.slice(2);
97
139
  const handler = flagHandlers[name];
98
140
  if (!handler)
99
- throw new DispatchError(`未知選項:${arg}\n\n${HELP_TEXT}`, 2);
141
+ throw new DispatchError(m(lang, "unknownOption", arg, helpText), 2);
100
142
  const value = argv[++i];
101
- if (value === undefined)
102
- throw new DispatchError(`--${name} 缺少值`, 2);
143
+ if (value === undefined) {
144
+ // v1.3 §二之2 #9:--lang 缺值時沿用既有的「缺少值」措辭,額外列出可用值。
145
+ const suffix = name === "lang" ? m(lang, "availableValuesSuffix", m(lang, "availableLangValues")) : "";
146
+ throw new DispatchError(m(lang, "missingFlagValue", name, suffix), 2);
147
+ }
103
148
  handler(value);
104
149
  }
105
150
  else if (ticketDir === undefined) {
@@ -108,31 +153,51 @@ export function parseArgs(argv) {
108
153
  else {
109
154
  // v1.10 §9:多餘的 positional 原本被靜默忽略(`hub-dispatch a b` 只跑 a),
110
155
  // 與設計原則 2(fail closed)不一致,改為中止。
111
- throw new DispatchError(`多餘的引數:${arg}(工單目錄已是 "${ticketDir}")\n\n${HELP_TEXT}`, 2);
156
+ throw new DispatchError(m(lang, "tooManyArgs", arg, ticketDir, helpText), 2);
157
+ }
158
+ }
159
+ if (doctorMode) {
160
+ // 工單 W1 §一之1:doctor 不接受也不需要 ticket-dir——多給了(任何 positional)
161
+ // 就照既有的 tooManyArgs 處理,不悄悄忽略。
162
+ if (ticketDir !== undefined) {
163
+ throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
112
164
  }
165
+ return { mode: "doctor" };
113
166
  }
114
167
  if (!ticketDir) {
115
- throw new DispatchError(HELP_TEXT, 2);
168
+ throw new DispatchError(helpText, 2);
116
169
  }
117
170
  return { mode: "run", ticketDir, options };
118
171
  }
119
- export function formatEvent(event) {
172
+ // plan_i18n_impl_tickets T3:跟 run-level lang,不是 env lang——這是執行中的即時輸出,
173
+ // 此時 resolveLang 早已成功、cli.ts 手上有權威值,直接傳進來即可,不必再猜語言。
174
+ export function formatEvent(event, lang) {
120
175
  switch (event.type) {
121
176
  case "spoke_start":
122
- return `[${event.agent}] 開始 → ${event.provider}/${event.model}`;
177
+ return m(lang, "eventSpokeStart", event.agent, event.provider, event.model);
123
178
  case "round":
179
+ // 此則純 ASCII(round/usage/toolCalls 皆非中文),兩語言逐字相同,不必經 messages.ts。
124
180
  return `[${event.agent}] round ${event.round} usage=${JSON.stringify(event.usage)} toolCalls=${event.hasToolCalls}`;
125
181
  case "unknown_usage_keys":
126
- return `[${event.agent}] ⚠ round ${event.round} 出現未知 usage 欄位:${event.keys.join(", ")}`;
182
+ return m(lang, "eventUnknownUsageKeys", event.agent, event.round, event.keys.join(", "));
127
183
  case "tool_call":
128
- return `[${event.agent}] read_file(${event.path}) ${event.allowed ? "允許" : `拒絕(${event.reason})`}`;
184
+ return m(lang, "eventToolCall", event.agent, event.path, event.allowed, event.reason);
129
185
  case "rate_limit_wait":
130
- return `[${event.agent}] 429,等待 ${event.seconds}s(來源:${event.source})`;
186
+ return m(lang, "eventRateLimitWait", event.agent, event.seconds, event.source);
131
187
  case "round_error":
132
- return `[${event.agent}] ⚠ round ${event.round} 錯誤 status=${event.status ?? "—"}:${event.message}`;
133
- case "spoke_end":
134
- return (`[${event.agent}] 結束 status=${event.status} latency=${event.latencyMs}ms totalTokens=${event.totalTokens}` +
135
- ` cost=${event.costUsd === null ? "無價目資料" : `$${event.costUsd.toFixed(4)}`}` +
136
- (event.budgetTrigger ? ` budgetTrigger=${event.budgetTrigger}` : ""));
188
+ return m(lang, "eventRoundError", event.agent, event.round, String(event.status ?? "—"), event.message);
189
+ case "spoke_end": {
190
+ const costLabel = event.costUsd === null ? m(lang, "noPricingData") : `$${event.costUsd.toFixed(4)}`;
191
+ const budgetSuffix = event.budgetTrigger ? ` budgetTrigger=${event.budgetTrigger}` : "";
192
+ return m(lang, "eventSpokeEnd", event.agent, event.status, event.latencyMs, event.totalTokens, costLabel, budgetSuffix);
193
+ }
137
194
  }
138
195
  }
196
+ // plan_i18n_impl_tickets T3b:moved out of cli.ts(原本是該檔的私有函式)。cli.ts 底部有
197
+ // `main().catch()` 的無條件呼叫,import 它會直接跑掉整支程式(見檔頭說明)——這支函式本身
198
+ // 純函式、不含 I/O,搬來這裡才有安全的方式可以單元測試,不必 spawn 子行程。
199
+ export function buildStdoutSummary(results, lang) {
200
+ return results
201
+ .map((r) => m(lang, "stdoutSummaryLine", r.agent, r.status, r.modelReturned ?? "—", r.usage.totalTokens, r.costUsd === null ? m(lang, "noPricingData") : `$${r.costUsd.toFixed(4)}`, r.latencyMs))
202
+ .join("\n");
203
+ }
package/dist/cli.js CHANGED
@@ -19,19 +19,22 @@ import { createGeminiAdapter } from "./adapters/gemini-native.js";
19
19
  import { createAnthropicAdapter } from "./adapters/anthropic-messages.js";
20
20
  import { auditSpoke } from "./audit.js";
21
21
  import { auditToolCalls } from "./tool-call-audit.js";
22
- import { ensureOutDir, persistSpokeResult, RunLogWriter, writeSummary } from "./output.js";
22
+ import { ensureOutDir, outDirHasArtifacts, persistSpokeResult, RunLogWriter, writeSummary } from "./output.js";
23
23
  import { registerSecrets, maskString, maskDeep } from "./mask.js";
24
24
  import { SECRET_ENV_VARS } from "./secret-env.js";
25
25
  import { resolveDispatchHome, loadDispatchEnv } from "./dispatch-home.js";
26
- import { bundledProvidersPath, getPackageVersion } from "./pkg-info.js";
26
+ import { buildDoctorReport } from "./doctor.js";
27
+ import { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
27
28
  import { checkGitignore } from "./gitignore-check.js";
28
29
  import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
29
- import { parseArgs, formatEvent, HELP_TEXT } from "./cli-args.js";
30
+ import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
31
+ import { m } from "./messages.js";
30
32
  function createAdapterFor(spoke) {
31
33
  const apiKey = process.env[`${spoke.provider.toUpperCase()}_API_KEY`];
32
34
  if (!apiKey) {
33
- // resolveSpokes 已檢查過,此處為型別窄化防禦
34
- throw new DispatchError(`內部錯誤:${spoke.provider} API key 遺失`, 2);
35
+ // resolveSpokes 已檢查過,此處為型別窄化防禦。用 spoke.lang 而非 langRaw/options.lang——
36
+ // 它是 ResolvedSpoke 上已經過 resolveLang 判定的權威值,與 run-level lang 同一個來源。
37
+ throw new DispatchError(m(spoke.lang, "apiKeyMissing", spoke.provider), 2);
35
38
  }
36
39
  // §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
37
40
  // 靜默走錯 adapter。switch 缺 case 時 TypeScript 因「不是每條路徑都回傳值」編譯失敗。
@@ -42,14 +45,16 @@ function createAdapterFor(spoke) {
42
45
  apiKey,
43
46
  store: spoke.providerConfig.store,
44
47
  reasoning: spoke.providerConfig.reasoning,
48
+ lang: spoke.lang,
45
49
  });
46
50
  case "gemini-native":
47
- return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL });
51
+ return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL, lang: spoke.lang });
48
52
  case "anthropic-messages":
49
53
  return createAnthropicAdapter({
50
54
  baseURL: spoke.providerConfig.baseURL,
51
55
  apiKey,
52
56
  reasoning: spoke.providerConfig.reasoning,
57
+ lang: spoke.lang,
53
58
  });
54
59
  }
55
60
  }
@@ -68,38 +73,69 @@ async function confirm(message, output) {
68
73
  return answer.trim().toLowerCase() === "y";
69
74
  }
70
75
  async function main() {
71
- const parsed = parseArgs(process.argv.slice(2));
76
+ // plan_i18n_v1.3.md §三:DISPATCH_HOME → .env → secrets 全部搬到 parseArgs 之前,
77
+ // 因為這段現在跑在 --help 之前,任何會拋的東西都會擋住求助路徑——resolveDispatchHome/
78
+ // loadDispatchEnv 內部已各自降級失敗為「沒有設定檔」。§24.4:明文禁止讀 cwd 的 .env,
79
+ // 故不用 `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
80
+ const dispatchHome = resolveDispatchHome();
81
+ if (dispatchHome !== null)
82
+ loadDispatchEnv(dispatchHome);
83
+ registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
84
+ // plan_i18n_impl_tickets T3:parseArgs 自己的解析期錯誤、以及 --help 本身的輸出,都發生在
85
+ // resolveLang 判定出權威語言之前——這裡只能用「DISPATCH_LANG 有效就用它,否則內建預設 en」
86
+ // 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
87
+ // 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
88
+ const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
89
+ const parsed = parseArgs(process.argv.slice(2), messageLang);
72
90
  if (parsed.mode === "help") {
73
- console.log(HELP_TEXT);
91
+ console.log(m(messageLang, "helpText", getCommandName()));
74
92
  return;
75
93
  }
76
94
  if (parsed.mode === "version") {
77
95
  console.log(getPackageVersion());
78
96
  return;
79
97
  }
98
+ if (parsed.mode === "doctor") {
99
+ // 工單 W1 §一之2:語言走 messageLang(resolveFallbackLang),不受 --lang 影響——
100
+ // --doctor 可能正是拿來診斷 --lang/DISPATCH_LANG 本身壞掉的工具。
101
+ // §一之6:型號白名單走既有的 loadProviders/bundledProvidersPath,載入失敗印失敗原因、
102
+ // 不中止(doctor 本身 exit 一律 0,見下方 buildDoctorReport 的呼叫沒有任何 throw 路徑)。
103
+ let providersResult;
104
+ try {
105
+ const providers = await loadProviders(bundledProvidersPath(), messageLang);
106
+ providersResult = { ok: true, providers };
107
+ }
108
+ catch (err) {
109
+ const reason = err instanceof DispatchError ? err.message : maskString(String(err));
110
+ providersResult = { ok: false, reason };
111
+ }
112
+ console.log(buildDoctorReport(messageLang, getCommandName(), providersResult));
113
+ return;
114
+ }
80
115
  const { ticketDir, options } = parsed;
81
116
  const log = makeLogger(options.json);
82
- // §10 步驟 0:設定解析——DISPATCH_HOME → .env providers 來源 formatVersion。
83
- // 全部在任何 API 呼叫之前,成本為零。§24.4:明文禁止讀 cwd 的 .env,故不用
84
- // `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
85
- const dispatchHome = resolveDispatchHome();
86
- loadDispatchEnv(dispatchHome);
87
- registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
117
+ // plan_i18n_v1.3.md §二:--lang > DISPATCH_LANG > 內建預設,含九項組合判定
118
+ // (fail closed,無效值 exit 2)。help/version 已在上面短路,不會走到這裡。
119
+ //
120
+ // T1 驗收帶出的第一條(T3 執行):語言的權威來源只有這行的回傳值。CLI 層訊息一律用
121
+ // 這個 lang,不得讀 options.langRaw——後者是未經 parseLang 驗證的原始字串,可能是
122
+ // undefined 或未正規化的 "ZH-TW",誤用會靜默走中文分支且 typecheck 不會紅。
123
+ const lang = resolveLang(options.langRaw, process.env.DISPATCH_LANG);
88
124
  // v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
89
125
  // 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
90
126
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
91
127
  const ticketId = path.basename(path.resolve(ticketDir));
92
128
  // §10 步驟 1–2:工單解析+允許清單存在性(loadTicket/resolveSpokes 內部 fail closed)
93
- const ticket = await loadTicket(ticketDir);
129
+ const ticket = await loadTicket(ticketDir, lang);
94
130
  // §10 步驟 3/v1.10 §24.3:providers.json 隨工具出貨、不可覆寫(方案 D);
95
131
  // --providers 是唯一逃生口,整檔取代,一樣過 formatVersion 檢查。
96
132
  const providersPath = options.providersPath ? path.resolve(options.providersPath) : bundledProvidersPath();
97
133
  const providersSource = options.providersPath
98
134
  ? { kind: "explicit", path: providersPath, formatVersion: PROVIDERS_FORMAT_VERSION }
99
135
  : { kind: "bundled", formatVersion: PROVIDERS_FORMAT_VERSION };
100
- const providers = await loadProviders(providersPath);
136
+ const providers = await loadProviders(providersPath, lang);
101
137
  // §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)
102
- const spokes = await resolveSpokes(ticket, providers, repoRoot);
138
+ const spokes = await resolveSpokes(ticket, providers, repoRoot, lang);
103
139
  // §10 步驟 6/§14 閘門一:呼叫前估算
104
140
  const estimates = spokes.map((spoke) => {
105
141
  const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
@@ -110,7 +146,7 @@ async function main() {
110
146
  estimatedTokens: estimateTokens(systemPrompt, charsPerToken) + estimateTokens(firstUserText, charsPerToken),
111
147
  };
112
148
  });
113
- checkGateOne(estimates, options.maxTokens);
149
+ checkGateOne(estimates, options.maxTokens, lang);
114
150
  // plan_dispatch_v2.0.md §14:允許清單總量估算,獨立於閘門一之外,只呈現不設閘門
115
151
  // (閘門一不含允許清單內容,見 gate.ts 的說明)。與 estimates 同一模式:算一次,
116
152
  // 報表與 --json plan 共用,不重複讀檔。
@@ -142,25 +178,30 @@ async function main() {
142
178
  exitCode,
143
179
  });
144
180
  // §10 步驟 7/§11:派工報表
145
- const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, {
146
- repoRoot,
147
- providersSource,
148
- gitignoreStatus,
149
- });
181
+ const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, { repoRoot, providersSource, gitignoreStatus }, lang);
150
182
  log.info(report);
183
+ // 護欄的預告:乾跑不受影響(它不寫任何東西),但先講,免得實跑才發現被擋。
184
+ const outDirDirty = await outDirHasArtifacts(outDir);
151
185
  if (options.dryRun) {
152
- log.info("\n--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。");
186
+ if (outDirDirty)
187
+ log.info("\n" + m(lang, "outDirNotEmptyDryRunWarning", outDir));
188
+ log.info("\n" + m(lang, "dryRunNotice"));
153
189
  if (options.json) {
154
190
  console.log(JSON.stringify(maskDeep(buildPayload("dry-run", [], new Map(), new Map(), 0))));
155
191
  }
156
192
  return;
157
193
  }
194
+ // 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
195
+ // 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
196
+ // **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
197
+ // 那正是 --yes 已經示範過的路。要覆蓋,由使用者自己清掉目錄。
198
+ if (outDirDirty) {
199
+ throw new DispatchError(m(lang, "outDirNotEmptyAbort", outDir), 5);
200
+ }
158
201
  if (!options.yes) {
159
- const ok = await confirm("繼續?[y/N] ", options.json ? process.stderr : process.stdout);
202
+ const ok = await confirm(m(lang, "confirmPrompt"), options.json ? process.stderr : process.stdout);
160
203
  if (!ok) {
161
- log.info(process.stdin.isTTY
162
- ? "已取消,未呼叫任何 API。"
163
- : "非互動環境(stdin 不是 TTY)無人可確認,已取消,未呼叫任何 API。要在此環境派工請明確加上 --yes。");
204
+ log.info(process.stdin.isTTY ? m(lang, "cancelledInteractive") : m(lang, "cancelledNonInteractive"));
164
205
  if (options.json) {
165
206
  console.log(JSON.stringify(maskDeep(buildPayload("cancelled", [], new Map(), new Map(), 0))));
166
207
  }
@@ -173,13 +214,13 @@ async function main() {
173
214
  }
174
215
  catch (err) {
175
216
  outDirReady = false;
176
- console.error(`落檔目錄不可寫:${outDir}`);
217
+ console.error(m(lang, "outDirNotWritable", outDir));
177
218
  console.error(maskString(String(err)));
178
219
  }
179
- const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl")) : null;
220
+ const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl"), lang) : null;
180
221
  const semaphore = new Semaphore(options.concurrency);
181
222
  const onEvent = (event) => {
182
- log.info(formatEvent(event));
223
+ log.info(formatEvent(event, lang));
183
224
  runLog?.append(event);
184
225
  };
185
226
  // §10 步驟 8:信號量控制發起,Promise.allSettled 只負責收尾
@@ -204,7 +245,7 @@ async function main() {
204
245
  // §13(一):每支 spoke 完成即落檔,不等 Promise.allSettled——多 spoke 並行、
205
246
  // 其中一支慢很多時,快的那支已付費的產出不因慢的還在跑而暴露在中斷風險下。
206
247
  if (outDirReady) {
207
- await persistSpokeResult(outDir, result, (msg) => console.error(msg));
248
+ await persistSpokeResult(outDir, result, (msg) => console.error(msg), lang);
208
249
  }
209
250
  return result;
210
251
  }
@@ -263,16 +304,16 @@ async function main() {
263
304
  // rejected 分支的兜底——真實 result 重寫一次是冪等的(writeFile 覆蓋)。
264
305
  if (outDirReady) {
265
306
  for (const r of results) {
266
- await persistSpokeResult(outDir, r, (msg) => console.error(msg));
307
+ await persistSpokeResult(outDir, r, (msg) => console.error(msg), lang);
267
308
  }
268
- await writeSummary(outDir, ticketId, results, audits, toolCallAudits);
309
+ await writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang);
269
310
  await runLog?.flush();
270
- log.info(`\n落檔完成:${outDir}/`);
311
+ log.info("\n" + m(lang, "outDirWritten", outDir));
271
312
  }
272
313
  else {
273
314
  // 落檔目錄不可寫時,不讓已付費的結果消失——完整結果改印到 stderr(json 模式下
274
315
  // stdout 仍須維持只有最後那個單一 JSON 物件的契約,不能把這份診斷用資料混進去)。
275
- log.info("\n落檔目錄不可寫,完整報告已改印於 stderr:");
316
+ log.info("\n" + m(lang, "outDirFallbackStderr"));
276
317
  console.error(JSON.stringify(maskDeep(results), null, 2));
277
318
  }
278
319
  const allFailed = results.every((r) => r.status === "failed");
@@ -284,15 +325,9 @@ async function main() {
284
325
  console.log(JSON.stringify(maskDeep(buildPayload("executed", results, audits, toolCallAudits, exitCode))));
285
326
  }
286
327
  else {
287
- log.info("\n" + buildStdoutSummary(results));
328
+ log.info("\n" + buildStdoutSummary(results, lang));
288
329
  }
289
330
  }
290
- function buildStdoutSummary(results) {
291
- return results
292
- .map((r) => `${r.agent}: ${r.status} model=${r.modelReturned ?? "—"} token=${r.usage.totalTokens} ` +
293
- `cost=${r.costUsd === null ? "無價目資料" : `$${r.costUsd.toFixed(4)}`} 耗時=${r.latencyMs}ms`)
294
- .join("\n");
295
- }
296
331
  main().catch((err) => {
297
332
  if (err instanceof DispatchError) {
298
333
  console.error(maskString(err.message));
@@ -9,15 +9,33 @@
9
9
  import dotenv from "dotenv";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
+ // plan_i18n_v1.3.md §三:這兩個函式現在跑在 parseArgs/--help 之前(見 cli.ts),任何
13
+ // 會拋的東西都會擋住求助路徑。因此各自包 try/catch,任何例外都降級為「沒有設定檔」,
14
+ // 不中止、不拋——包括 os.homedir() 在無 HOME/無 passwd 對應時拋錯。
12
15
  export function resolveDispatchHome(env = process.env, homedir = os.homedir) {
13
- if (env.DISPATCH_HOME)
14
- return env.DISPATCH_HOME;
15
- if (env.XDG_CONFIG_HOME)
16
- return path.join(env.XDG_CONFIG_HOME, "dispatch");
17
- return path.join(homedir(), ".config", "dispatch");
16
+ try {
17
+ if (env.DISPATCH_HOME)
18
+ return env.DISPATCH_HOME;
19
+ // v0.2.0:目錄名與 CLI 同名(`dowafu`),原本叫 `dispatch`。**不做舊路徑回退**
20
+ // (乙案已否決)——回退那段程式碼一旦寫下就永遠刪不掉,而它要處理的是一次性的搬家。
21
+ // 代價是搬家沒做完時 loadDispatchEnv 靜默降級(見下方註解),症狀要到派工當下才出現,
22
+ // 所以 impl_tickets 的順序是「先複製目錄、再改這兩行」。
23
+ if (env.XDG_CONFIG_HOME)
24
+ return path.join(env.XDG_CONFIG_HOME, "dowafu");
25
+ return path.join(homedir(), ".config", "dowafu");
26
+ }
27
+ catch {
28
+ return null;
29
+ }
18
30
  }
19
31
  // 唯一允許呼叫 dotenv 的地方——`path` 一律明確指定為 dispatchHome 下的 `.env`,
20
32
  // 絕不留白(留白即回退成 dotenv 預設讀 cwd 的 `.env`,正是本函式存在的目的所要杜絕的)。
33
+ // 維持 `void` 簽名:讀取失敗一律降級為「沒有設定檔」(見上方註解),呼叫端不需要狀態。
21
34
  export function loadDispatchEnv(dispatchHome) {
22
- dotenv.config({ path: path.join(dispatchHome, ".env") });
35
+ try {
36
+ dotenv.config({ path: path.join(dispatchHome, ".env") });
37
+ }
38
+ catch {
39
+ // .env 是目錄/無讀取權限/內容畸形等,全部降級,不中止、不拋。
40
+ }
23
41
  }
package/dist/doctor.js ADDED
@@ -0,0 +1,91 @@
1
+ // 工單 W1(fix_batch_4)§一:`dowafu --doctor`——零素材、零成本的設定自檢。
2
+ //
3
+ // 組字串的純函式,依賴(env/homedir/檔案探針)比照 dispatch-home.ts 的做法以參數注入,
4
+ // 皆有指向真實環境的預設值——cli.ts 只需 `buildDoctorReport(lang, cmd, providersResult)`
5
+ // 就能在正式環境跑,doctor.test.ts 則能整組覆寫、不碰真實家目錄。
6
+ //
7
+ // providers.json 的載入是唯一沒有走這條注入路徑的資料來源:那段是既有的 loadProviders/
8
+ // bundledProvidersPath(async,且已有自己的驗證與錯誤訊息),改由 cli.ts 在呼叫本檔之前
9
+ // 先 await 好、包成 DoctorProvidersResult 傳進來——本檔本體維持同步、純函式。
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { resolveDispatchHome } from "./dispatch-home.js";
14
+ import { SECRET_ENV_VARS } from "./secret-env.js";
15
+ import { PROVIDERS_FORMAT_VERSION } from "./providers.js";
16
+ import { m } from "./messages.js";
17
+ const DEFAULT_PROBE = {
18
+ fileExists: (p) => {
19
+ try {
20
+ return fs.existsSync(p);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ },
26
+ readDir: (p) => {
27
+ try {
28
+ return fs.readdirSync(p);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ },
34
+ };
35
+ // 項四〈lens 定義〉:`.claude/agents/` 底下所有 hole-finder 開頭的檔,**含無後綴的
36
+ // `hole-finder.md`**——語言包確實出貨那一支,而 CLI 不限定 agent 名(validate.ts 只看
37
+ // `.claude/agents/<agent>.md` 在不在),所以它是可派的。漏算它會讓 doctor 報的支數
38
+ // 與使用者 `ls` 看到的對不上,而這支指令的用途正是「回報你手上實際有什麼」。
39
+ // `explore-haiku.md` 不算:它不是 hole-finder lens,沒有固定收尾句。
40
+ const HOLE_FINDER_LENS_PATTERN = /^hole-finder(-.+)?\.md$/;
41
+ function buildConfigDirValue(lang, env, dispatchHome) {
42
+ if (dispatchHome === null)
43
+ return m(lang, "doctorConfigDirUnresolved");
44
+ if (env.DISPATCH_HOME)
45
+ return m(lang, "doctorConfigDirSourceDispatchHome", dispatchHome);
46
+ if (env.XDG_CONFIG_HOME)
47
+ return m(lang, "doctorConfigDirSourceXdgConfigHome", dispatchHome);
48
+ return m(lang, "doctorConfigDirSourceDefault", dispatchHome);
49
+ }
50
+ function buildEnvValue(lang, probe, dispatchHome) {
51
+ if (dispatchHome === null)
52
+ return m(lang, "doctorEnvUnresolvedValue");
53
+ const envPath = path.join(dispatchHome, ".env");
54
+ return probe.fileExists(envPath) ? m(lang, "doctorEnvPresentValue") : m(lang, "doctorEnvMissingValue", envPath);
55
+ }
56
+ function buildModelListValue(lang, providers) {
57
+ if (!providers.ok)
58
+ return m(lang, "doctorModelListLoadFailedValue", providers.reason);
59
+ const joiner = lang === "zh" ? "、" : ", ";
60
+ const items = Object.entries(providers.providers)
61
+ .map(([name, config]) => m(lang, "doctorProviderCountItem", name, config.models.length))
62
+ .join(joiner);
63
+ return m(lang, "doctorModelListValue", PROVIDERS_FORMAT_VERSION, items);
64
+ }
65
+ function buildLensValue(lang, probe, cwd) {
66
+ const lensDir = path.join(cwd, ".claude", "agents");
67
+ const entries = probe.readDir(lensDir);
68
+ if (entries === null)
69
+ return m(lang, "doctorLensDirMissingValue", lensDir);
70
+ const joiner = lang === "zh" ? "、" : ", ";
71
+ const lenses = entries
72
+ .filter((f) => HOLE_FINDER_LENS_PATTERN.test(f))
73
+ .map((f) => f.slice(0, -".md".length))
74
+ .sort();
75
+ return m(lang, "doctorLensFoundValue", lensDir, lenses.length, lenses.join(joiner));
76
+ }
77
+ export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd()) {
78
+ const dispatchHome = resolveDispatchHome(env, homedir);
79
+ const apiKeyList = SECRET_ENV_VARS.map((k) => `${k.replace(/_API_KEY$/, "")} ${env[k] ? "✓" : "✗"}`).join(" ");
80
+ const lines = [
81
+ m(lang, "doctorHeader", cmd),
82
+ m(lang, "doctorConfigDirLine", buildConfigDirValue(lang, env, dispatchHome)),
83
+ m(lang, "doctorEnvLine", buildEnvValue(lang, probe, dispatchHome)),
84
+ m(lang, "doctorApiKeyLine", apiKeyList),
85
+ m(lang, "doctorModelListLine", buildModelListValue(lang, providers)),
86
+ m(lang, "doctorLensLine", buildLensValue(lang, probe, cwd)),
87
+ "",
88
+ m(lang, "doctorFooter"),
89
+ ];
90
+ return lines.join("\n");
91
+ }
@@ -1,11 +1,20 @@
1
1
  // plan_dispatch_v1.8.md §13:錯誤分類依 HTTP status code,不解析錯誤訊息字串——訊息格式
2
2
  // 各家不同且會改版,status code 是穩定契約。429 不在此分類範圍內(runner.ts 在呼叫此函式
3
3
  // 之前已用 describeError().is429 攔截,走獨立的 429 等待路徑)。
4
- // 暫時性:5xx、408,以及無 HTTP 回應的網路層錯誤(status 為 undefined——連線重置、DNS
5
- // 失敗、AbortController 逾時)。確定性:其他 4xx(400 參數錯、401、403、404…),重試
6
- // 必然再撞同一個錯,不重試。
4
+ // 暫時性:5xx、408,以及無 HTTP 回應的網路層錯誤(連線重置、DNS 失敗、AbortController
5
+ // 逾時)。確定性:其他 4xx(400 參數錯、401、403、404…),重試必然再撞同一個錯,不重試。
6
+ //
7
+ // **「無 HTTP 回應」有兩種表示法,兩種都要收**:`undefined`(錯誤物件根本沒有 status),
8
+ // 以及 **`0`**——`adapters/responses.ts` 把 OpenAI SDK 的 `APIConnectionError` 正規化成
9
+ // `anyErr.status ?? 0`,而那個 SDK 的連線錯誤**帶著 `status` 這個欄位、值為 undefined**,
10
+ // 所以會走進 `?? 0`。`0` 不是合法的 HTTP status code,拿它當「沒有回應」的哨兵是安全的。
11
+ //
12
+ // v0.3.0 對測(2026-08-13)實地踩到:DeepSeek 連線失敗 → status 0 → 判成 permanent →
13
+ // **`--retries` 一次都沒用上**,四支 spoke 在 1.2–1.5 秒內全部 failed。
14
+ // 本函式的註解原本就寫著網路層錯誤該重試,是 `?? 0` 讓那個意圖失效;測試也只測了
15
+ // `undefined`,剛好漏掉 `0`。
7
16
  export function classifyError(status) {
8
- if (status === undefined)
17
+ if (status === undefined || status === 0)
9
18
  return "transient";
10
19
  if (status >= 500 || status === 408)
11
20
  return "transient";
package/dist/gate.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // 此檢查在任何 API 呼叫之前,成本為零。
4
4
  import fs from "node:fs";
5
5
  import { DispatchError } from "./types.js";
6
+ import { m } from "./messages.js";
6
7
  // §14:charsPerToken 預設 1.0(不是 chars/4)——實測 chars/4 對中文系統性低估 2.7–4 倍,
7
8
  // 低估比高估危險,此閘門寧可誤報。providers.json 可逐家覆寫(見 cli.ts 呼叫端)。
8
9
  function estimateTokensFromChars(chars, charsPerToken) {
@@ -27,11 +28,11 @@ export function estimateSequentialRead(filePaths, charsPerToken) {
27
28
  sorted: amplify([...sizes].sort((a, b) => a - b)),
28
29
  };
29
30
  }
30
- export function checkGateOne(estimates, maxTokens) {
31
+ export function checkGateOne(estimates, maxTokens, lang) {
31
32
  const total = estimates.reduce((sum, e) => sum + e.estimatedTokens, 0);
32
33
  if (total > maxTokens) {
33
34
  const detail = estimates.map((e) => ` ${e.agent}: ${e.estimatedTokens}`).join("\n");
34
- throw new DispatchError(`閘門一超限:合計初始估算 ${total} tokens 超過 --max-tokens ${maxTokens}\n${detail}`, 3);
35
+ throw new DispatchError(m(lang, "gateOneExceeded", total, maxTokens, detail), 3);
35
36
  }
36
37
  return total;
37
38
  }