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/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);
package/dist/ticket.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { DispatchError } from "./types.js";
6
+ import { m } from "./messages.js";
6
7
  const FORMAT_MARKER = "<!-- format: v1 -->";
7
8
  function splitTableRow(line) {
8
9
  const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
@@ -12,15 +13,15 @@ function isSeparatorRow(cells) {
12
13
  return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c));
13
14
  }
14
15
  // §4:`_dispatch.md`。model/provider/agent 必填,無預設值;留白或寫 "default" 視為缺失。
15
- export function parseDispatchTable(markdown) {
16
+ export function parseDispatchTable(markdown, lang) {
16
17
  const lines = markdown.split(/\r?\n/);
17
18
  const firstNonBlank = lines.find((l) => l.trim().length > 0);
18
19
  if (firstNonBlank?.trim() !== FORMAT_MARKER) {
19
- throw new DispatchError(`_dispatch.md 首行須為 ${FORMAT_MARKER},實際為:${firstNonBlank?.trim() ?? "(空白)"}`, 2);
20
+ throw new DispatchError(m(lang, "formatMarkerMismatch", FORMAT_MARKER, firstNonBlank?.trim() ?? m(lang, "blankPlaceholder")), 2);
20
21
  }
21
22
  const headerIndex = lines.findIndex((l) => /^\s*\|\s*agent\s*\|/i.test(l));
22
23
  if (headerIndex === -1 || !isSeparatorRow(splitTableRow(lines[headerIndex + 1] ?? ""))) {
23
- throw new DispatchError("_dispatch.md 找不到派工表(缺 | agent | ... | 表頭或分隔列)", 2);
24
+ throw new DispatchError(m(lang, "dispatchTableMissingHeader"), 2);
24
25
  }
25
26
  const headerCells = splitTableRow(lines[headerIndex]).map((c) => c.toLowerCase());
26
27
  const rows = [];
@@ -39,15 +40,29 @@ export function parseDispatchTable(markdown) {
39
40
  const effort = get("effort");
40
41
  const isMissing = (v) => v.length === 0 || v.toLowerCase() === "default";
41
42
  if (isMissing(agent) || isMissing(provider) || isMissing(model)) {
42
- throw new DispatchError(`_dispatch.md 第 ${i + 1} 行缺 agent/provider/model 必填欄位(留白或寫 "default" 視為缺失):${line}`, 2);
43
+ throw new DispatchError(m(lang, "dispatchRowMissingFields", i + 1, line), 2);
43
44
  }
44
45
  rows.push({ agent, provider, model, effort: effort.length > 0 ? effort : undefined });
45
46
  }
46
47
  if (rows.length === 0) {
47
- throw new DispatchError("_dispatch.md 派工表沒有任何資料列", 2);
48
+ throw new DispatchError(m(lang, "dispatchTableEmpty"), 2);
49
+ }
50
+ const distinctAgents = new Set(rows.map((r) => r.agent));
51
+ if (distinctAgents.size !== rows.length) {
52
+ const counts = new Map();
53
+ for (const row of rows)
54
+ counts.set(row.agent, (counts.get(row.agent) ?? 0) + 1);
55
+ const [dupAgent, dupCount] = [...counts.entries()].find(([, count]) => count > 1);
56
+ throw new DispatchError(m(lang, "duplicateAgentInDispatchTable", dupAgent, dupCount), 2);
48
57
  }
49
58
  return rows;
50
59
  }
60
+ // plan_i18n_v1.2.md §6.2:「讀檔 → 剝 frontmatter → trim」這個片段跨多處共用(原為
61
+ // validate.ts 私有),放這裡不產生循環——audit.ts 已經 import 本檔。
62
+ export function stripFrontmatter(markdown) {
63
+ const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
64
+ return (match ? match[1] : markdown).trim();
65
+ }
51
66
  // 匯出供 audit.ts 重用(回報模板同樣是 `# 標題` 結構)。
52
67
  export function splitTopLevelSections(markdown) {
53
68
  const lines = markdown.split(/\r?\n/);
@@ -80,7 +95,7 @@ function parseBulletList(body) {
80
95
  .filter((v) => Boolean(v && v.length > 0));
81
96
  }
82
97
  // §4:`_shared.md`。「待審段落」缺失或空即中止;「前提」缺失為警告(空前提合法)。
83
- export function parseSharedDoc(markdown) {
98
+ export function parseSharedDoc(markdown, lang) {
84
99
  const sections = splitTopLevelSections(markdown);
85
100
  const reviewText = sections.get("待審段落") ?? sections.get("Under review");
86
101
  if (!reviewText || reviewText.length === 0) {
@@ -91,37 +106,32 @@ export function parseSharedDoc(markdown) {
91
106
  if (sections.has("待審段落") || sections.has("Under review")) {
92
107
  const stray = [...sections.keys()].filter((k) => k !== "待審段落" && k !== "Under review" && !k.startsWith("前提") && k !== "Premises");
93
108
  if (stray.length > 0) {
94
- throw new DispatchError(`_shared.md 的「# 待審段落」有標題但內容為空——被後面這些 \`#\` 標題切斷了:` +
95
- `${stray.map((s) => `「# ${s}」`).join("、")}。` +
96
- `工單以 \`#\` 切分區塊,內嵌的規劃書若自帶 \`#\` 標題請降成 \`##\`。` +
97
- `(注意:在「# 待審段落」下面補一行文字雖然能通過檢查,但規劃書本體仍會留在` +
98
- `後面那個區塊裡,spoke 收到的待審段落等於是空的。)`, 2);
109
+ throw new DispatchError(m(lang, "strayHeadingsCutReviewSection", stray), 2);
99
110
  }
100
111
  }
101
- throw new DispatchError('_shared.md 缺「# 待審段落」(或英文工單的「# Under review」)或內容為空', 2);
112
+ throw new DispatchError(m(lang, "missingReviewSection"), 2);
102
113
  }
103
114
  const premisesBody = sections.get("前提(不受審)") ?? sections.get("前提") ?? sections.get("Premises");
104
115
  const premises = premisesBody ? parseBulletList(premisesBody) : [];
105
116
  return { premises, reviewText };
106
117
  }
107
118
  // §4:`<agent>.md`。「具體問題」缺失或空即中止;「允許讀取」缺失為警告(空清單合法)。
108
- export function parseAgentTicket(markdown) {
119
+ export function parseAgentTicket(markdown, lang) {
109
120
  const sections = splitTopLevelSections(markdown);
110
- // 先中文後英文。命中哪一套就是這份工單的語言——不看內文,只看標記,因為內文可能
111
- // 中英混雜(英文規劃書配中文問題是常見寫法),標記則是作者明確選的。
121
+ // 先中文後英文:命中哪一套只決定用哪一組欄位鍵去讀值——不再代表語言選擇,語言改由
122
+ // run-level `--lang` 決定(見上方型別註解、plan_i18n_v1.3.md §一之5)。
112
123
  const zhQuestions = sections.get("具體問題");
113
124
  const enQuestions = sections.get("Questions");
114
- const lang = zhQuestions !== undefined ? "zh" : enQuestions !== undefined ? "en" : "zh";
115
125
  const questions = zhQuestions ?? enQuestions;
116
126
  if (!questions || questions.length === 0) {
117
- throw new DispatchError('<agent>.md 缺「# 具體問題」(或英文工單的「# Questions」)或內容為空', 2);
127
+ throw new DispatchError(m(lang, "missingQuestionsSection"), 2);
118
128
  }
119
129
  const allowedBody = sections.get("允許讀取") ?? sections.get("Allowed reads");
120
130
  const allowedReads = allowedBody ? parseBulletList(allowedBody) : [];
121
- return { questions, allowedReads, lang };
131
+ return { questions, allowedReads };
122
132
  }
123
133
  // 檔案系統存取層:讀工單目錄、組出完整 Ticket。
124
- export async function loadTicket(ticketDir) {
134
+ export async function loadTicket(ticketDir, lang) {
125
135
  const dispatchPath = path.join(ticketDir, "_dispatch.md");
126
136
  const sharedPath = path.join(ticketDir, "_shared.md");
127
137
  let dispatchText;
@@ -129,17 +139,17 @@ export async function loadTicket(ticketDir) {
129
139
  dispatchText = await readFile(dispatchPath, "utf8");
130
140
  }
131
141
  catch {
132
- throw new DispatchError(`找不到 ${dispatchPath}`, 2);
142
+ throw new DispatchError(m(lang, "fileNotFound", dispatchPath), 2);
133
143
  }
134
144
  let sharedText;
135
145
  try {
136
146
  sharedText = await readFile(sharedPath, "utf8");
137
147
  }
138
148
  catch {
139
- throw new DispatchError(`找不到 ${sharedPath}`, 2);
149
+ throw new DispatchError(m(lang, "fileNotFound", sharedPath), 2);
140
150
  }
141
- const rows = parseDispatchTable(dispatchText);
142
- const shared = parseSharedDoc(sharedText);
151
+ const rows = parseDispatchTable(dispatchText, lang);
152
+ const shared = parseSharedDoc(sharedText, lang);
143
153
  const perAgent = new Map();
144
154
  for (const row of rows) {
145
155
  const agentPath = path.join(ticketDir, `${row.agent}.md`);
@@ -148,9 +158,9 @@ export async function loadTicket(ticketDir) {
148
158
  agentText = await readFile(agentPath, "utf8");
149
159
  }
150
160
  catch {
151
- throw new DispatchError(`找不到 ${agentPath}(_dispatch.md 列了 agent "${row.agent}")`, 2);
161
+ throw new DispatchError(m(lang, "agentFileNotFound", agentPath, row.agent), 2);
152
162
  }
153
- perAgent.set(row.agent, parseAgentTicket(agentText));
163
+ perAgent.set(row.agent, parseAgentTicket(agentText, lang));
154
164
  }
155
165
  return { ticketDir, rows, shared, perAgent };
156
166
  }
package/dist/validate.js CHANGED
@@ -5,22 +5,20 @@ import fs from "node:fs";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { DispatchError } from "./types.js";
8
+ import { stripFrontmatter } from "./ticket.js";
8
9
  import { buildAllowSet } from "./whitelist.js";
10
+ import { m } from "./messages.js";
9
11
  function apiKeyEnvFor(provider) {
10
12
  return `${provider.toUpperCase()}_API_KEY`;
11
13
  }
12
- function stripFrontmatter(markdown) {
13
- const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
14
- return (match ? match[1] : markdown).trim();
15
- }
16
- async function readAgentBody(agentsDir, agent) {
14
+ async function readAgentBody(agentsDir, agent, lang) {
17
15
  const agentDefPath = path.join(agentsDir, `${agent}.md`);
18
16
  let text;
19
17
  try {
20
18
  text = await readFile(agentDefPath, "utf8");
21
19
  }
22
20
  catch {
23
- throw new DispatchError(`找不到 agent 定義檔 ${agentDefPath}(_dispatch.md 列了 "${agent}")`, 2);
21
+ throw new DispatchError(m(lang, "agentDefNotFound", agentDefPath, agent), 2);
24
22
  }
25
23
  return stripFrontmatter(text);
26
24
  }
@@ -28,57 +26,60 @@ function isUnderDocsDir(repoRoot, resolved) {
28
26
  const docsRoot = path.resolve(repoRoot, "_docs");
29
27
  return resolved === docsRoot || resolved.startsWith(docsRoot + path.sep);
30
28
  }
31
- export async function resolveSpokes(ticket, providers, repoRoot, agentsDir = path.join(repoRoot, ".claude", "agents")) {
29
+ export async function resolveSpokes(ticket, providers, repoRoot,
30
+ // plan_i18n_v1.3.md §一之3 第 3 點:必填、不得有預設值——有預設值的話呼叫端漏傳會
31
+ // 靜默回退成舊來源,連紅字都沒有,等同「傳了但傳的是舊來源」那種型別系統看不見的錯誤。
32
+ lang, agentsDir = path.join(repoRoot, ".claude", "agents")) {
32
33
  const resolved = [];
33
34
  for (const row of ticket.rows) {
34
35
  const providerConfig = providers[row.provider];
35
36
  if (!providerConfig) {
36
- throw new DispatchError(`_dispatch.md 的 "${row.agent}" 列引用了未定義於 providers.json 的 provider "${row.provider}"`, 2);
37
+ throw new DispatchError(m(lang, "providerUndefinedInRow", row.agent, row.provider), 2);
37
38
  }
38
39
  // §18:只檢查本次工單用到的 provider,缺即中止。
39
40
  const envName = apiKeyEnvFor(row.provider);
40
41
  if (!process.env[envName]) {
41
- throw new DispatchError(`缺少環境變數 ${envName}("${row.agent}" 列需要 provider "${row.provider}")`, 2);
42
+ throw new DispatchError(m(lang, "missingEnvVar", envName, row.agent, row.provider), 2);
42
43
  }
43
44
  // §5:model 須在 providers.json 的 models 白名單內;白名單為空 = 不做型號檢查。
44
45
  if (providerConfig.models.length > 0 && !providerConfig.models.includes(row.model)) {
45
- throw new DispatchError(`"${row.agent}" 列的 model "${row.model}" 不在 provider "${row.provider}" 的 models 白名單內` +
46
- `(允許:${providerConfig.models.join(", ")})`, 2);
46
+ throw new DispatchError(m(lang, "modelNotWhitelisted", row.agent, row.model, row.provider, providerConfig.models.join(", ")), 2);
47
47
  }
48
48
  // §4:effort 填了但不在該 provider 的 allowed 內即中止(含 allowed 為空陣列)。
49
49
  if (row.effort !== undefined && !providerConfig.reasoning.allowed.includes(row.effort)) {
50
- throw new DispatchError(`"${row.agent}" 列的 effort "${row.effort}" 不在 provider "${row.provider}" 的允許值域內` +
51
- `(允許值:${providerConfig.reasoning.allowed.length > 0 ? providerConfig.reasoning.allowed.join(", ") : "(空——尚未驗證,任何值皆拒絕)"})`, 2);
50
+ const list = providerConfig.reasoning.allowed.length > 0
51
+ ? providerConfig.reasoning.allowed.join(", ")
52
+ : m(lang, "emptyAllowedNote");
53
+ throw new DispatchError(m(lang, "effortNotAllowed", row.agent, row.effort, row.provider, list), 2);
52
54
  }
53
55
  // §5:「留白」不再是「不送任何 reasoning 參數」,而是送 reasoning.default。
54
56
  // allowed 為空時 default 不存在——該 provider 不可用,即使 effort 留白也中止。
55
57
  const effectiveEffort = row.effort ?? providerConfig.reasoning.default;
56
58
  if (effectiveEffort === undefined) {
57
- throw new DispatchError(`"${row.agent}" 列的 effort 留白,但 provider "${row.provider}" 未設 reasoning.default` +
58
- `(allowed 為空 = 尚未驗證,該 provider 不可用)`, 2);
59
+ throw new DispatchError(m(lang, "effortBlankNoDefault", row.agent, row.provider), 2);
59
60
  }
60
61
  const agentTicket = ticket.perAgent.get(row.agent);
61
62
  if (!agentTicket) {
62
63
  // loadTicket() 已保證存在,此處為型別窄化與防禦
63
- throw new DispatchError(`內部錯誤:找不到 "${row.agent}" 的工單內容`, 2);
64
+ throw new DispatchError(m(lang, "internalErrorTicketContentMissing", row.agent), 2);
64
65
  }
65
66
  // §4「針對 hub 會寫錯」:_docs/ 一律拒絕;允許清單逐一驗證存在,任一不存在即中止並指名。
66
67
  const allowedReadsResolved = [];
67
68
  for (const rel of agentTicket.allowedReads) {
68
69
  const resolvedPath = path.resolve(repoRoot, rel);
69
70
  if (isUnderDocsDir(repoRoot, resolvedPath)) {
70
- throw new DispatchError(`"${row.agent}" 的允許讀取清單指向 _docs/(spoke 禁區):${rel}`, 2);
71
+ throw new DispatchError(m(lang, "allowedReadsUnderDocs", row.agent, rel), 2);
71
72
  }
72
73
  let real;
73
74
  try {
74
75
  real = fs.realpathSync(resolvedPath);
75
76
  }
76
77
  catch {
77
- throw new DispatchError(`"${row.agent}" 的允許讀取清單指向不存在的路徑:${rel}`, 2);
78
+ throw new DispatchError(m(lang, "allowedReadsPathNotFound", row.agent, rel), 2);
78
79
  }
79
80
  allowedReadsResolved.push(real);
80
81
  }
81
- const agentBody = await readAgentBody(agentsDir, row.agent);
82
+ const agentBody = await readAgentBody(agentsDir, row.agent, lang);
82
83
  const sharedPath = path.join(ticket.ticketDir, "_shared.md");
83
84
  const ownAgentPath = path.join(ticket.ticketDir, `${row.agent}.md`);
84
85
  const allowSet = buildAllowSet([sharedPath, ownAgentPath, ...allowedReadsResolved]);
@@ -93,7 +94,7 @@ export async function resolveSpokes(ticket, providers, repoRoot, agentsDir = pat
93
94
  allowSet,
94
95
  allowedReadsResolved,
95
96
  allowedReadsRelative: agentTicket.allowedReads,
96
- lang: agentTicket.lang,
97
+ lang,
97
98
  });
98
99
  }
99
100
  return resolved;
package/dist/whitelist.js CHANGED
@@ -35,4 +35,12 @@ export function checkAllowlist(allowSet, requestedPath, repoRoot) {
35
35
  return { allowed: false, reason: insideRepo ? "not_in_allowlist" : "outside_repo" };
36
36
  }
37
37
  // 統一回給 spoke 的訊息:不區分「不存在」與「不在允許範圍」。
38
- export const ALLOWLIST_REJECT_MESSAGE = "不存在或不在允許範圍";
38
+ //
39
+ // i18n_classification_t2.md §三之2:這則訊息經 executeToolCall 當 resultText 送回對話,
40
+ // 消費者是 spoke 不是人類——歸 C 類,不進 messages.ts,由 spoke.lang 直接選用兩套並存的
41
+ // 雙語常數(同 runner.ts 本地的 200KB 截斷/--max-tool-calls 上限兩則走同一模式)。
42
+ const ALLOWLIST_REJECT_MESSAGE = "不存在或不在允許範圍";
43
+ const ALLOWLIST_REJECT_MESSAGE_EN = "Not found or outside the allowed list";
44
+ export function allowlistRejectMessage(lang) {
45
+ return lang === "en" ? ALLOWLIST_REJECT_MESSAGE_EN : ALLOWLIST_REJECT_MESSAGE;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Send a section of your design doc to external LLMs for review. They read only the files you whitelist, and nothing is billed until you confirm.",
5
5
  "keywords": [
6
6
  "llm",
@@ -33,7 +33,8 @@
33
33
  "files": [
34
34
  "dist",
35
35
  "providers.json",
36
- "publish"
36
+ "publish",
37
+ "README_zh-tw.md"
37
38
  ],
38
39
  "scripts": {
39
40
  "verify:providers": "tsx scripts/verify-providers.ts",
package/providers.json CHANGED
@@ -31,13 +31,18 @@
31
31
  "allowed": ["low", "high", "max"],
32
32
  "default": "high"
33
33
  },
34
- "models": ["deepseek-v4-flash"],
34
+ "models": ["deepseek-v4-flash", "deepseek-v4-pro"],
35
35
  "charsPerToken": 1.0,
36
36
  "tpmLimit": null,
37
37
  "maxSpokeTokens": null,
38
- "pricingSource": { "url": "https://api-docs.deepseek.com/quick_start/pricing/", "asOf": "2026-08-09", "note": "官方預告近期將調漲,且幅度顯著" },
38
+ "pricingSource": {
39
+ "url": "https://api-docs.deepseek.com/quick_start/pricing/",
40
+ "asOf": "2026-08-14",
41
+ "note": "本表填的是 2026-08-16 16:00 UTC 起生效的尖峰價。在此之前官方仍收舊價(flash 0.0028/0.14/0.28、pro 0.003625/0.435/0.87),所以生效前本表偏高。離峰(尖峰時段 01:00-04:00 與 06:00-10:00 UTC 以外)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。"
42
+ },
39
43
  "pricing": {
40
- "deepseek-v4-flash": { "inputPerM": 0.14, "cachedInputPerM": 0.0028, "outputPerM": 0.28 }
44
+ "deepseek-v4-flash": { "inputPerM": 0.44, "cachedInputPerM": 0.014, "outputPerM": 1.32 },
45
+ "deepseek-v4-pro": { "inputPerM": 1.32, "cachedInputPerM": 0.044, "outputPerM": 3.96 }
41
46
  }
42
47
  },
43
48
  "gemini": {