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/mask.js CHANGED
@@ -44,15 +44,28 @@ export function maskDeep(value) {
44
44
  }
45
45
  return value;
46
46
  }
47
+ // 序列化失敗一律降級為固定字串,不再往上拋——呼叫端(describeError)的職責是「把錯誤描述
48
+ // 出來」,它自己不能因為描述不出來而變成第二個錯誤。回傳值仍經 maskString,因為
49
+ // 不可序列化的物件其 toString 也可能帶出秘密。
50
+ function safeStringify(value) {
51
+ try {
52
+ return maskString(JSON.stringify(value));
53
+ }
54
+ catch {
55
+ return "[unserializable error body]";
56
+ }
57
+ }
47
58
  export function describeError(err) {
48
59
  if (err && typeof err === "object") {
49
60
  const anyErr = err;
50
61
  const status = typeof anyErr.status === "number" ? anyErr.status : undefined;
51
62
  const headers = maskHeaders(anyErr.headers);
52
63
  const message = typeof anyErr.message === "string" ? maskString(anyErr.message) : undefined;
53
- const errorBody = anyErr.error && typeof anyErr.error === "object"
54
- ? { message: maskString(JSON.stringify(anyErr.error)) }
55
- : undefined;
64
+ // issue_log_v2.5.md 待修 #10:JSON.stringify 遇 BigInt 會拋 TypeError、遇循環引用同樣拋,
65
+ // 而 runSpoke 沒有包住這條路徑——遮蔽層自己拋錯的下場是整支 spoke 落到 allSettled 的防禦
66
+ // 分支:status failed,而且**那次錯誤本身不會被記錄**,因為記錄它正是這個函式的工作。
67
+ // 序列化不了就退回型別描述,不得讓遮蔽層成為新的失敗來源。
68
+ const errorBody = anyErr.error && typeof anyErr.error === "object" ? { message: safeStringify(anyErr.error) } : undefined;
56
69
  return {
57
70
  status,
58
71
  is429: status === 429,
@@ -0,0 +1,396 @@
1
+ // plan_i18n_v1.2.md §5.1/plan_i18n_v1.3.md §四:使用者可見訊息(A 類)的機制層。
2
+ // 協定常數(B 類)與雙語常數(C 類,如 prompt 模板、收尾句、稽核偵測樣式)不進這裡——
3
+ // 前者不翻、後者由 spoke.lang 直接選用,見各自所在檔案。
4
+ //
5
+ // v1.0 的 Record<Lang, Record<MessageKey, string | ((...args: never[]) => string)>> 已作廢:
6
+ // 定義端可 assign,但 m() 內 v(...args) 對 unknown 參數會紅(不能指派給 never)。
7
+ // 這裡改用 per-key 精確型別——key → 參數 tuple 的對照——讓「key 齊全」與「參數個數
8
+ // (arity)」同時被型別系統擋住。arity 錯了會在執行期插出 undefined,那正是本版要消滅的
9
+ // 失敗形態。
10
+ //
11
+ // T2 只驗證這個機制本身能編譯、能用,當時的三個 key(unknownOption/missingProviders/
12
+ // dryRunNotice)是驗證 0/1/2 參數 arity 的範例,不是實際搬遷。T3 起 unknownOption/
13
+ // dryRunNotice 轉正(cli-args.ts/cli.ts 實際使用,見下方);missingProviders 由 T4
14
+ // (providers.ts:loadProviders)接手轉正,型別骨架沿用,不重建。
15
+ //
16
+ // 遮蔽邊界(v1.2 §5.2/v1.3 §四之1,零機械強制力的文件約定):maskString 一律留在呼叫端,
17
+ // 這裡的函式本體不得出現 String(err),也不得自己呼叫遮蔽——已遮蔽過的字串才能作為參數傳入。
18
+ //
19
+ // 組裝原則(T3 起):需要先組合再插入的片段(可用值清單、env 補充說明、cost 標籤、
20
+ // budgetTrigger 後綴…)一律由呼叫端組好、當作已完成的字串參數傳進來,MESSAGES 內的函式
21
+ // 本體不互相呼叫 m()——每個 key 只做「這一則訊息長什麼樣子」,組裝邏輯留在 cli-args.ts/
22
+ // cli.ts,兩者職責分開才好讀。
23
+ const MESSAGES = {
24
+ zh: {
25
+ unknownOption: (arg, helpText) => `未知選項:${arg}\n\n${helpText}`,
26
+ missingProviders: (path) => `找不到 providers.json:${path}`,
27
+ dryRunNotice: () => "--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。",
28
+ helpText: (cmd) => `用法:${cmd} <ticket-dir> [options]
29
+
30
+ --lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
31
+ --repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
32
+ --providers <path> 整檔取代出貨的 providers.json
33
+ --json stdout 只印結果 JSON,其餘輸出改走 stderr
34
+ --out <dir> 落檔目錄,預設 tmp/spoke/
35
+ --concurrency <n> 同時執行的 spoke 數,預設 2
36
+ --max-tokens <n> 呼叫前估算閘門(各 spoke 初始 prompt 總和),預設 200000
37
+ --max-spoke-tokens <n> 單一 spoke 執行期累積上限(實際 usage),預設 400000
38
+ --timeout <sec> 單次 API 呼叫逾時(不是整支 spoke),預設 600
39
+ --retries <n> 單輪呼叫的重試次數(僅暫時性錯誤),預設 2
40
+ --chars-per-token <n> 閘門一估算係數,預設 1.0(可由 providers.json 逐家覆寫)
41
+ --max-spoke-reasoning-tokens <n> 單一 spoke 的推理 token 累積上限,預設 50000
42
+ --max-round-reasoning-tokens <n> 單輪推理 token 上限,預設 null(不檢查)
43
+ --rate-limit-retries <n> 429 專用重試次數,預設 5(不計入 --retries)
44
+ --max-rate-wait <sec> 單次 429 等待上限,預設 30
45
+ --max-tool-calls <n> 單一 spoke 的 read_file 呼叫上限,預設 30
46
+ --dry-run 解析、驗證、估算、印報表,不呼叫 API
47
+ --yes 略過派工確認。非互動環境(stdin 不是 TTY)沒帶就中止
48
+ --doctor 印出設定自檢(不呼叫 API、不花錢)後結束(exit 0)
49
+ --help, -h 印本說明後結束(exit 0)
50
+ --version, -V 印版本號後結束(exit 0)`,
51
+ availableLangValues: () => "en、zh-tw、zh",
52
+ availableValuesSuffix: (values) => `(可用值:${values})`,
53
+ numberFlagInvalid: (name, value) => `--${name} 需要數字,收到:${value}`,
54
+ missingFlagValue: (name, suffix) => `--${name} 缺少值${suffix}`,
55
+ tooManyArgs: (arg, ticketDir, helpText) => `多餘的引數:${arg}(工單目錄已是 "${ticketDir}")\n\n${helpText}`,
56
+ invalidLangFlag: (value, availableSuffix, envNote) => `--lang 值無效:${value}${availableSuffix}${envNote}`,
57
+ invalidEnvAlsoNote: (envValue) => `;環境變數 DISPATCH_LANG 目前也是無效值:${envValue}`,
58
+ invalidEnvLang: (value, availableSuffix) => `環境變數 DISPATCH_LANG 值無效:${value}${availableSuffix}`,
59
+ noPricingData: () => "無價目資料",
60
+ eventSpokeStart: (agent, provider, model) => `[${agent}] 開始 → ${provider}/${model}`,
61
+ eventUnknownUsageKeys: (agent, round, keys) => `[${agent}] ⚠ round ${round} 出現未知 usage 欄位:${keys}`,
62
+ eventToolCall: (agent, path, allowed, reason) => `[${agent}] read_file(${path}) ${allowed ? "允許" : `拒絕(${reason})`}`,
63
+ eventRateLimitWait: (agent, seconds, source) => `[${agent}] 429,等待 ${seconds}s(來源:${source})`,
64
+ eventRoundError: (agent, round, status, message) => `[${agent}] ⚠ round ${round} 錯誤 status=${status}:${message}`,
65
+ eventSpokeEnd: (agent, status, latencyMs, totalTokens, costLabel, budgetSuffix) => `[${agent}] 結束 status=${status} latency=${latencyMs}ms totalTokens=${totalTokens} cost=${costLabel}${budgetSuffix}`,
66
+ apiKeyMissing: (provider) => `內部錯誤:${provider} 的 API key 遺失`,
67
+ confirmPrompt: () => "繼續?[y/N] ",
68
+ cancelledInteractive: () => "已取消,未呼叫任何 API。",
69
+ cancelledNonInteractive: () => "非互動環境(stdin 不是 TTY)無人可確認,已取消,未呼叫任何 API。要在此環境派工請明確加上 --yes。",
70
+ outDirNotWritable: (outDir) => `落檔目錄不可寫:${outDir}`,
71
+ // 護欄:舊產物是花過錢的東西,覆蓋掉之前沒有人會被問到。換一個 ticket-id 零成本,
72
+ // 所以這裡不提供 --overwrite 之類的旗標——有旗標就會有人直接加上去。
73
+ outDirNotEmptyAbort: (outDir) => `輸出目錄已有產物,未派工、未呼叫任何 API:${outDir}\n` +
74
+ `那是上一次跑出來的東西,覆蓋掉就沒了。兩條路:\n` +
75
+ ` 1. 換一個沒用過的 ticket-id 再派(建議,零成本)\n` +
76
+ ` 2. 由使用者自行清掉那個目錄之後重跑——這是他的決定,不是你的\n` +
77
+ ` * 上一次若是失敗收場(summary 全 failed、token 0),那底下沒有花過錢的東西,\n` +
78
+ ` 換 id 或請使用者清掉都行——但仍然由使用者決定要不要清`,
79
+ outDirNotEmptyDryRunWarning: (outDir) => `⚠ 輸出目錄已有產物:${outDir}\n 乾跑不受影響,但實跑會被擋下。換一個 ticket-id 即可。`,
80
+ outDirWritten: (outDir) => `落檔完成:${outDir}/`,
81
+ outDirFallbackStderr: () => "落檔目錄不可寫,完整報告已改印於 stderr:",
82
+ stdoutSummaryLine: (agent, status, model, tokens, costLabel, latencyMs) => `${agent}: ${status} model=${model} token=${tokens} cost=${costLabel} 耗時=${latencyMs}ms`,
83
+ formatMarkerMismatch: (marker, got) => `_dispatch.md 首行須為 ${marker},實際為:${got}`,
84
+ blankPlaceholder: () => "(空白)",
85
+ dispatchTableMissingHeader: () => "_dispatch.md 找不到派工表(缺 | agent | ... | 表頭或分隔列)",
86
+ dispatchRowMissingFields: (n, line) => `_dispatch.md 第 ${n} 行缺 agent/provider/model 必填欄位(留白或寫 "default" 視為缺失):${line}`,
87
+ duplicateAgentInDispatchTable: (agent, n) => `_dispatch.md 的 agent 欄重複:「${agent}」出現 ${n} 次。未派工、未呼叫任何 API。\n` +
88
+ `同一份 _dispatch.md 裡一個 agent 只能有一列。兩列同名會兩支都派出去、都計費,\n` +
89
+ `而 ${agent}.md 與 raw/${agent}.* 由後完成的那支覆蓋先完成的——留下哪一支不可控。\n` +
90
+ `要用同一個 lens 跑多個型號,拆成多個工單目錄(例如 <ticket-id>-luna、<ticket-id>-ds),各派一次。`,
91
+ dispatchTableEmpty: () => "_dispatch.md 派工表沒有任何資料列",
92
+ strayHeadingsCutReviewSection: (strayNames) => `_shared.md 的「# 待審段落」有標題但內容為空——被後面這些 \`#\` 標題切斷了:` +
93
+ `${strayNames.map((s) => `「# ${s}」`).join("、")}。` +
94
+ `工單以 \`#\` 切分區塊,內嵌的規劃書若自帶 \`#\` 標題請降成 \`##\`。` +
95
+ `(注意:在「# 待審段落」下面補一行文字雖然能通過檢查,但規劃書本體仍會留在` +
96
+ `後面那個區塊裡,spoke 收到的待審段落等於是空的。)`,
97
+ missingReviewSection: () => '_shared.md 缺「# 待審段落」(或英文工單的「# Under review」)或內容為空',
98
+ missingQuestionsSection: () => '<agent>.md 缺「# 具體問題」(或英文工單的「# Questions」)或內容為空',
99
+ fileNotFound: (path) => `找不到 ${path}`,
100
+ agentFileNotFound: (agentPath, agent) => `找不到 ${agentPath}(_dispatch.md 列了 agent "${agent}")`,
101
+ agentDefNotFound: (path, agent) => `找不到 agent 定義檔 ${path}(_dispatch.md 列了 "${agent}")`,
102
+ providerUndefinedInRow: (agent, provider) => `_dispatch.md 的 "${agent}" 列引用了未定義於 providers.json 的 provider "${provider}"`,
103
+ missingEnvVar: (envName, agent, provider) => `缺少環境變數 ${envName}("${agent}" 列需要 provider "${provider}")`,
104
+ modelNotWhitelisted: (agent, model, provider, list) => `"${agent}" 列的 model "${model}" 不在 provider "${provider}" 的 models 白名單內(允許:${list})`,
105
+ effortNotAllowed: (agent, effort, provider, list) => `"${agent}" 列的 effort "${effort}" 不在 provider "${provider}" 的允許值域內(允許值:${list})`,
106
+ emptyAllowedNote: () => "(空——尚未驗證,任何值皆拒絕)",
107
+ effortBlankNoDefault: (agent, provider) => `"${agent}" 列的 effort 留白,但 provider "${provider}" 未設 reasoning.default(allowed 為空 = 尚未驗證,該 provider 不可用)`,
108
+ internalErrorTicketContentMissing: (agent) => `內部錯誤:找不到 "${agent}" 的工單內容`,
109
+ allowedReadsUnderDocs: (agent, rel) => `"${agent}" 的允許讀取清單指向 _docs/(spoke 禁區):${rel}`,
110
+ allowedReadsPathNotFound: (agent, rel) => `"${agent}" 的允許讀取清單指向不存在的路徑:${rel}`,
111
+ reasoningStyleInvalid: (providerName, style) => `providers.json: ${providerName}.reasoning.style 值不合法:"${style}"`,
112
+ reasoningDefaultNotString: (providerName) => `providers.json: ${providerName}.reasoning.default 須為字串`,
113
+ reasoningDefaultMissing: (providerName) => `providers.json: ${providerName}.reasoning.default 缺失——allowed 非空時必填,不得回退到「不送參數」`,
114
+ reasoningDefaultNotAllowed: (providerName, def, list) => `providers.json: ${providerName}.reasoning.default "${def}" 不在 allowed 內(${list})`,
115
+ emptyList: () => "(空)",
116
+ positiveNumberRequired: (label, value) => `providers.json: ${label} 須為正數,實際為 ${value}`,
117
+ pricingNotObject: (providerName) => `providers.json: ${providerName}.pricing 不是物件`,
118
+ pricingModelNotObject: (providerName, model) => `providers.json: ${providerName}.pricing.${model} 不是物件`,
119
+ providerConfigNotObject: (name) => `providers.json: provider "${name}" 的設定不是物件`,
120
+ providerApiInvalid: (name, value) => `providers.json: ${name}.api 缺失或不合法(須為 "responses"、"gemini-native" 或 "anthropic-messages"),實際為 ${value}`,
121
+ providerStoreTrue: (name) => `providers.json: ${name}.store 為 true,違反設計原則 6(零留存)。載入即中止,不得依賴伺服器端狀態。`,
122
+ providerBaseUrlMissing: (name) => `providers.json: ${name}.baseURL 缺失`,
123
+ providerCharsPerTokenInvalid: (name) => `providers.json: ${name}.charsPerToken 須為正數`,
124
+ providersFileNotObject: () => "providers.json 格式不是物件",
125
+ providersFormatVersionMismatch: (want, got) => `providers.json: formatVersion 不符(預期 ${want},實際 ${got})。這通常代表 --providers 指向了舊版或不相容的檔案。`,
126
+ providersFileInvalidJson: (msg) => `providers.json 不是合法 JSON:${msg}`,
127
+ providerUndefined: (name) => `providers.json 未定義 provider "${name}"`,
128
+ runLogWriteFailed: (maskedErr) => `run.jsonl 寫入失敗:${maskedErr}`,
129
+ noFullReportAvailable: (status) => `(無法取得完整回報,執行狀態:${status})`,
130
+ persistTextFailed: (agent, maskedErr) => `落檔失敗(${agent}.md):${maskedErr}`,
131
+ persistRawFailed: (agent, maskedErr) => `落檔失敗(${agent} raw/):${maskedErr}`,
132
+ budgetTriggerLabel: (trigger) => ({ total: "總量", reasoning: "推理累積", reasoning_round: "推理單輪尖峰" })[trigger],
133
+ anomalySpikeFlag: () => "⚠ 異常尖峰・",
134
+ noneLabel: () => "無",
135
+ outsideAllowlistSection: (section) => `「${section}」節`,
136
+ outsideAllowlistNoSection: () => "章節外",
137
+ outsideAllowlistSuffixNote: (suffixOf) => `;疑似 ${suffixOf} 的縮寫`,
138
+ outsideAllowlistEntry: (path, detail) => `${path}(${detail})`,
139
+ unknownUsageKeysWarning: (provider, keys) => `⚠ 未知 usage 欄位:${provider} ${keys}`,
140
+ zeroSourceReadWarning: (n) => `⚠ 零原始碼讀取(允許 ${n} 檔)`,
141
+ toolCallStats: (total, allowed, rejected) => `工具呼叫:${total}(允許 ${allowed}/拒絕 ${rejected})`,
142
+ closingLineCell: (passFail) => `收尾句:${passFail}`,
143
+ observationCountCell: (display) => `觀察:${display}`,
144
+ cannotCountObservations: () => "無法計數",
145
+ outsideAllowlistCell: (detail) => `清單外引用:${detail}`,
146
+ cannotVerifySectionCell: (passFail) => `無法驗證欄:${passFail}`,
147
+ suspectPhrasesCell: (detail) => `疑似禁止內容:${detail}`,
148
+ auditUnavailable: () => "(無法稽核)",
149
+ summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
150
+
151
+ | agent | provider | api | model(請求) | model(回傳) | effort | store | status | 耗時 | token | 估算成本 | 稽核 |
152
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`,
153
+ providersBundled: (formatVersion) => `出貨(formatVersion ${formatVersion})`,
154
+ providersExplicit: (path, formatVersion) => `外部檔 ${path}(formatVersion ${formatVersion})`,
155
+ gitignoreNotIgnored: (outDir) => ` ⚠ 輸出目錄 ${outDir} 未被輸出目錄所在的 git repo 忽略`,
156
+ gitignoreUnknown: (outDir) => ` ℹ 無法判定輸出目錄 ${outDir} 是否被 .gitignore 涵蓋(非 git repo 或 git 不可用)`,
157
+ aboutToDispatch: (ticketId) => `即將派工 ${ticketId}:`,
158
+ initialPromptEstimate: (totalEst, maxTokens) => ` 初始 prompt 估算 ${totalEst} tokens(僅 system prompt+首則訊息,不含工單與允許清單;本閘門的估算上限 ${maxTokens})`,
159
+ allowlistTotalEstimate: (tokens, files) => ` 允許清單總量估算 ${tokens} tokens(${files} 檔)`,
160
+ allowlistEstimateCaveat: () => " └ 上限估計,不去重;實測程式碼素材約 3.5 字元/token,實際消耗通常遠低於此數",
161
+ sequentialReadAmplification: (n) => ` 逐個讀的順序放大量 ${n} tokens(清單內容被重送的總量;此為逐個讀假設下的上限,批次讀的廠牌不適用)`,
162
+ sequentialReadCanReduce: (n, pct) => ` └ ⚠ 大檔排清單最後可降至 ${n}(本項省 ${pct}%)`,
163
+ sequentialReadNearOptimal: (pct) => ` └ 目前順序已接近最佳(重排最多再省 ${pct}%)`,
164
+ sequentialReadCostNote: () => " 本項不含初始 prompt 與工單(不受排序影響),故總成本的節省比例低於此數",
165
+ worstCaseTotal: (n) => ` 最壞總消耗 ≈ ${n} tokens(各 spoke 之 cap 加總)`,
166
+ concurrencyLine: (n) => ` 並行度 ${n}`,
167
+ tpmPeakLine: (provider, limit, peak) => ` ${provider} tpmLimit ${limit},靜態估算峰值 ${peak}`,
168
+ tpmPeakCaveat: () => " └ 僅為靜態指標,不預測執行中的 TPM 曲線(429 等待會改變實際並行數)",
169
+ allowedReadsSummary: (n, outDir) => ` 允許讀取 ${n} 個檔案,輸出至 ${outDir}/`,
170
+ lensClosingLineZh: (agent) => `ℹ ${agent} 的 lens 收尾句符合中文版固定收尾句`,
171
+ lensClosingLineEn: (agent) => `ℹ ${agent} 的 lens 收尾句符合英文版固定收尾句`,
172
+ modelPricing: (input, output, cachedSuffix, asOfSuffix) => ` └ 單價 每 M token:input $${input}/output $${output}${cachedSuffix}${asOfSuffix}`,
173
+ modelPricingCachedSuffix: (cached) => `/cached input $${cached}`,
174
+ modelPricingAsOfSuffix: (asOf) => `;價目查證日 ${asOf}`,
175
+ modelPricingMissing: (model) => ` └ ⚠ providers.json 沒有 "${model}" 的價目,本型號無法估算成本`,
176
+ rawIntegrityCheckFailed: (msg) => `raw 完整性檢查失敗(實作缺陷,不重試):${msg}`,
177
+ rateLimitRetriesExceeded: (n) => `429 撞牆次數超過 --rate-limit-retries (${n})`,
178
+ rateLimitWaitExceeded: (s, cap) => `429 要求等待 ${s}s,超過 --max-rate-wait ${cap}s`,
179
+ usageUnavailableRound: (n) => `round ${n}: usage 不可用(usageMissing),保守收束`,
180
+ finalizeToolCallIgnored: (n) => `round ${n}: 收束呼叫仍回傳 tool call,忽略`,
181
+ responsesAdapterCallFailed: () => "responses adapter 呼叫失敗",
182
+ gateOneExceeded: (total, maxTokens, detail) => `閘門一超限:合計初始估算 ${total} tokens 超過 --max-tokens ${maxTokens}\n${detail}`,
183
+ rawIntegrityNotArray: () => "raw 完整性檢查失敗:assistant turn 的 raw 不是陣列(responses adapter 預期 raw 為上一輪 response.output 陣列)",
184
+ rawIntegrityItemNotFound: (type) => `raw 完整性檢查失敗:assistant turn 有一個 item(type=${type})未以原樣出現在送出的請求中,疑似續接時被過濾掉`,
185
+ rawIntegrityObjectNotFound: () => "raw 完整性檢查失敗:assistant turn 的 raw 未以原樣出現在送出的 contents 中,疑似續接時被重建或漏帶",
186
+ doctorHeader: (cmd) => `${cmd} doctor`,
187
+ doctorConfigDirLine: (value) => ` 設定目錄 ${value}`,
188
+ doctorConfigDirSourceDispatchHome: (dir) => `${dir}(來源:DISPATCH_HOME)`,
189
+ doctorConfigDirSourceXdgConfigHome: (dir) => `${dir}(來源:XDG_CONFIG_HOME)`,
190
+ doctorConfigDirSourceDefault: (dir) => `${dir}(來源:預設;DISPATCH_HOME 與 XDG_CONFIG_HOME 都沒設)`,
191
+ doctorConfigDirUnresolved: () => "無法解析(沒有 HOME)",
192
+ doctorEnvLine: (value) => ` .env ${value}`,
193
+ doctorEnvPresentValue: () => "有(不讀內容)",
194
+ doctorEnvMissingValue: (path) => `沒有:${path}`,
195
+ doctorEnvUnresolvedValue: () => "無法判定(設定目錄無法解析)",
196
+ doctorApiKeyLine: (value) => ` API key ${value}(只看有沒有值,不看內容)`,
197
+ doctorProviderCountItem: (name, count) => `${name} ${count} 個`,
198
+ doctorModelListLine: (value) => ` 型號白名單 ${value}`,
199
+ doctorModelListValue: (formatVersion, items) => `providers.json formatVersion ${formatVersion}:${items}`,
200
+ doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
201
+ doctorLensLine: (value) => ` lens 定義 ${value}`,
202
+ doctorLensFoundValue: (dirPath, count, names) => `${dirPath} 找到 ${count} 支:${names}`,
203
+ doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
204
+ doctorFooter: () => "本指令不呼叫任何 API,不會花錢。缺的項目怎麼補,見 README 的〈API keys〉一節。",
205
+ },
206
+ en: {
207
+ unknownOption: (arg, helpText) => `Unknown option: ${arg}\n\n${helpText}`,
208
+ missingProviders: (path) => `providers.json not found: ${path}`,
209
+ dryRunNotice: () => "--dry-run: parsed, validated, estimated, and reported only; no API calls were made.",
210
+ helpText: (cmd) => `Usage: ${cmd} <ticket-dir> [options]
211
+
212
+ --lang <en|zh-tw> Language for CLI output and spoke prompts, default en
213
+ --repo-root <dir> Root for the allowlist boundary and .claude/agents, default cwd
214
+ --providers <path> Replace the bundled providers.json entirely
215
+ --json stdout prints only the result JSON; all other output goes to stderr
216
+ --out <dir> Output directory, default tmp/spoke/
217
+ --concurrency <n> Number of spokes to run concurrently, default 2
218
+ --max-tokens <n> Pre-call estimation gate (sum of each spoke's initial prompt), default 200000
219
+ --max-spoke-tokens <n> Per-spoke runtime cumulative cap (actual usage), default 400000
220
+ --timeout <sec> Timeout for a single API call (not the whole spoke), default 600
221
+ --retries <n> Retries per round (transient errors only), default 2
222
+ --chars-per-token <n> Gate-one estimation coefficient, default 1.0 (overridable per provider in providers.json)
223
+ --max-spoke-reasoning-tokens <n> Per-spoke cumulative reasoning-token cap, default 50000
224
+ --max-round-reasoning-tokens <n> Per-round reasoning-token cap, default null (no check)
225
+ --rate-limit-retries <n> Retries dedicated to 429s, default 5 (not counted in --retries)
226
+ --max-rate-wait <sec> Max wait for a single 429, default 30
227
+ --max-tool-calls <n> Per-spoke read_file call cap, default 30
228
+ --dry-run Parse, validate, estimate, and print the report only; no API calls
229
+ --yes Skip the dispatch confirmation. Aborts in non-interactive environments (stdin not a TTY) unless given
230
+ --doctor Print the configuration self-check (no API call, no cost) and exit (exit 0)
231
+ --help, -h Print this help and exit (exit 0)
232
+ --version, -V Print the version and exit (exit 0)`,
233
+ availableLangValues: () => "en, zh-tw, zh",
234
+ availableValuesSuffix: (values) => ` (available: ${values})`,
235
+ numberFlagInvalid: (name, value) => `--${name} requires a number, got: ${value}`,
236
+ missingFlagValue: (name, suffix) => `--${name} is missing a value${suffix}`,
237
+ tooManyArgs: (arg, ticketDir, helpText) => `Unexpected extra argument: ${arg} (ticket directory is already "${ticketDir}")\n\n${helpText}`,
238
+ invalidLangFlag: (value, availableSuffix, envNote) => `--lang value is invalid: ${value}${availableSuffix}${envNote}`,
239
+ invalidEnvAlsoNote: (envValue) => `; DISPATCH_LANG is also currently invalid: ${envValue}`,
240
+ invalidEnvLang: (value, availableSuffix) => `Environment variable DISPATCH_LANG is invalid: ${value}${availableSuffix}`,
241
+ noPricingData: () => "No pricing data",
242
+ eventSpokeStart: (agent, provider, model) => `[${agent}] started → ${provider}/${model}`,
243
+ eventUnknownUsageKeys: (agent, round, keys) => `[${agent}] ⚠ round ${round} has unknown usage field(s): ${keys}`,
244
+ eventToolCall: (agent, path, allowed, reason) => `[${agent}] read_file(${path}) ${allowed ? "allowed" : `rejected(${reason})`}`,
245
+ eventRateLimitWait: (agent, seconds, source) => `[${agent}] 429, waiting ${seconds}s (source: ${source})`,
246
+ eventRoundError: (agent, round, status, message) => `[${agent}] ⚠ round ${round} error status=${status}: ${message}`,
247
+ eventSpokeEnd: (agent, status, latencyMs, totalTokens, costLabel, budgetSuffix) => `[${agent}] finished status=${status} latency=${latencyMs}ms totalTokens=${totalTokens} cost=${costLabel}${budgetSuffix}`,
248
+ apiKeyMissing: (provider) => `Internal error: missing API key for ${provider}`,
249
+ confirmPrompt: () => "Continue? [y/N] ",
250
+ cancelledInteractive: () => "Cancelled; no API calls were made.",
251
+ cancelledNonInteractive: () => "Non-interactive environment (stdin is not a TTY); nobody could confirm. Cancelled; no API calls were made. To dispatch in this environment, pass --yes explicitly.",
252
+ outDirNotWritable: (outDir) => `Output directory is not writable: ${outDir}`,
253
+ outDirNotEmptyAbort: (outDir) => `The output directory already holds artifacts. Nothing was dispatched; no API was called: ${outDir}\n` +
254
+ `Those came from a previous run, and overwriting them loses them. Two ways forward:\n` +
255
+ ` 1. Pick a ticket-id you have not used and dispatch under that (recommended; it costs nothing)\n` +
256
+ ` 2. Have the user clear that directory themselves, then rerun — that call is theirs, not yours\n` +
257
+ ` * If the previous run ended in failure (all failed, zero tokens), nothing under there was\n` +
258
+ ` paid for — a new id or the user clearing it are both fine, but it is still their call to clear`,
259
+ outDirNotEmptyDryRunWarning: (outDir) => `⚠ The output directory already holds artifacts: ${outDir}\n The dry run is unaffected, but the real run will be stopped. Pick a different ticket-id.`,
260
+ outDirWritten: (outDir) => `Files written to: ${outDir}/`,
261
+ outDirFallbackStderr: () => "Output directory is not writable; the full report was printed to stderr instead:",
262
+ stdoutSummaryLine: (agent, status, model, tokens, costLabel, latencyMs) => `${agent}: ${status} model=${model} token=${tokens} cost=${costLabel} elapsed=${latencyMs}ms`,
263
+ formatMarkerMismatch: (marker, got) => `_dispatch.md's first line must be ${marker}, got: ${got}`,
264
+ blankPlaceholder: () => "(blank)",
265
+ dispatchTableMissingHeader: () => "_dispatch.md: dispatch table not found (missing | agent | ... | header or separator row)",
266
+ dispatchRowMissingFields: (n, line) => `_dispatch.md line ${n} is missing required field(s) agent/provider/model (blank or "default" counts as missing): ${line}`,
267
+ duplicateAgentInDispatchTable: (agent, n) => `Duplicate agent in _dispatch.md: "${agent}" appears ${n} times. Nothing was dispatched; no API was called.\n` +
268
+ `One agent gets one row per _dispatch.md. Two rows with the same name dispatch both spokes and bill for both,\n` +
269
+ `while ${agent}.md and raw/${agent}.* are overwritten by whichever finishes last — which one survives is not\n` +
270
+ `under your control.\n` +
271
+ `To run one lens across several models, split it into separate ticket directories\n` +
272
+ `(for example <ticket-id>-luna and <ticket-id>-ds) and dispatch each once.`,
273
+ dispatchTableEmpty: () => "_dispatch.md's dispatch table has no data rows",
274
+ strayHeadingsCutReviewSection: (strayNames) => `_shared.md's "# Under review" heading exists but its content is empty — it was cut off by these ` +
275
+ `\`#\` headings that follow: ${strayNames.map((s) => `"# ${s}"`).join(", ")}. ` +
276
+ `The ticket splits into sections by \`#\`; if an embedded plan document itself has \`#\` headings, ` +
277
+ `demote them to \`##\`. ` +
278
+ `(Note: adding a line of text right under "# Under review" will pass this check, but the plan body ` +
279
+ `still lives in the section after it — the spoke's review section would effectively be empty.)`,
280
+ missingReviewSection: () => '_shared.md is missing "# Under review" (or "# 待審段落" in a Chinese-language ticket) or its content is empty',
281
+ missingQuestionsSection: () => '<agent>.md is missing "# Questions" (or "# 具體問題" in a Chinese-language ticket) or its content is empty',
282
+ fileNotFound: (path) => `Not found: ${path}`,
283
+ agentFileNotFound: (agentPath, agent) => `Not found: ${agentPath} (_dispatch.md lists agent "${agent}")`,
284
+ agentDefNotFound: (path, agent) => `Agent definition file not found: ${path} (_dispatch.md lists "${agent}")`,
285
+ providerUndefinedInRow: (agent, provider) => `_dispatch.md's "${agent}" row references provider "${provider}", which is not defined in providers.json`,
286
+ missingEnvVar: (envName, agent, provider) => `Missing environment variable ${envName} (the "${agent}" row requires provider "${provider}")`,
287
+ modelNotWhitelisted: (agent, model, provider, list) => `The "${agent}" row's model "${model}" is not in provider "${provider}"'s models whitelist (allowed: ${list})`,
288
+ effortNotAllowed: (agent, effort, provider, list) => `The "${agent}" row's effort "${effort}" is not in provider "${provider}"'s allowed range (allowed: ${list})`,
289
+ emptyAllowedNote: () => "(empty — not yet verified, any value is rejected)",
290
+ effortBlankNoDefault: (agent, provider) => `The "${agent}" row's effort is blank, but provider "${provider}" has no reasoning.default set ` +
291
+ `(empty allowed = not yet verified; this provider is unavailable)`,
292
+ internalErrorTicketContentMissing: (agent) => `Internal error: ticket content for "${agent}" not found`,
293
+ allowedReadsUnderDocs: (agent, rel) => `The "${agent}" allowed-reads list points into _docs/ (a spoke-restricted area): ${rel}`,
294
+ allowedReadsPathNotFound: (agent, rel) => `The "${agent}" allowed-reads list points to a path that does not exist: ${rel}`,
295
+ reasoningStyleInvalid: (providerName, style) => `providers.json: ${providerName}.reasoning.style is invalid: "${style}"`,
296
+ reasoningDefaultNotString: (providerName) => `providers.json: ${providerName}.reasoning.default must be a string`,
297
+ reasoningDefaultMissing: (providerName) => `providers.json: ${providerName}.reasoning.default is missing — required when allowed is non-empty, ` +
298
+ `must not fall back to "send no parameter"`,
299
+ reasoningDefaultNotAllowed: (providerName, def, list) => `providers.json: ${providerName}.reasoning.default "${def}" is not in allowed (${list})`,
300
+ emptyList: () => "(empty)",
301
+ positiveNumberRequired: (label, value) => `providers.json: ${label} must be a positive number, got ${value}`,
302
+ pricingNotObject: (providerName) => `providers.json: ${providerName}.pricing is not an object`,
303
+ pricingModelNotObject: (providerName, model) => `providers.json: ${providerName}.pricing.${model} is not an object`,
304
+ providerConfigNotObject: (name) => `providers.json: the configuration for provider "${name}" is not an object`,
305
+ providerApiInvalid: (name, value) => `providers.json: ${name}.api is missing or invalid (must be "responses", "gemini-native", or "anthropic-messages"), got ${value}`,
306
+ providerStoreTrue: (name) => `providers.json: ${name}.store is true, which violates design principle 6 (zero retention). ` +
307
+ `Aborting on load; server-side state must not be relied upon.`,
308
+ providerBaseUrlMissing: (name) => `providers.json: ${name}.baseURL is missing`,
309
+ providerCharsPerTokenInvalid: (name) => `providers.json: ${name}.charsPerToken must be a positive number`,
310
+ providersFileNotObject: () => "providers.json format is not an object",
311
+ providersFormatVersionMismatch: (want, got) => `providers.json: formatVersion mismatch (expected ${want}, got ${got}). ` +
312
+ `This usually means --providers points to an old or incompatible file.`,
313
+ providersFileInvalidJson: (msg) => `providers.json is not valid JSON: ${msg}`,
314
+ providerUndefined: (name) => `providers.json does not define provider "${name}"`,
315
+ runLogWriteFailed: (maskedErr) => `Failed to write run.jsonl: ${maskedErr}`,
316
+ noFullReportAvailable: (status) => `(Full report unavailable; execution status: ${status})`,
317
+ persistTextFailed: (agent, maskedErr) => `Failed to write file (${agent}.md): ${maskedErr}`,
318
+ persistRawFailed: (agent, maskedErr) => `Failed to write file (${agent} raw/): ${maskedErr}`,
319
+ budgetTriggerLabel: (trigger) => ({ total: "total", reasoning: "cumulative reasoning", reasoning_round: "single-round reasoning spike" })[trigger],
320
+ anomalySpikeFlag: () => "⚠ anomalous spike - ",
321
+ noneLabel: () => "none",
322
+ outsideAllowlistSection: (section) => `the "${section}" section`,
323
+ outsideAllowlistNoSection: () => "outside any section",
324
+ outsideAllowlistSuffixNote: (suffixOf) => `; possibly an abbreviation of ${suffixOf}`,
325
+ outsideAllowlistEntry: (path, detail) => `${path} (${detail})`,
326
+ unknownUsageKeysWarning: (provider, keys) => `⚠ Unknown usage field(s): ${provider} ${keys}`,
327
+ zeroSourceReadWarning: (n) => `⚠ Zero source reads (allowed ${n} file(s))`,
328
+ toolCallStats: (total, allowed, rejected) => `Tool calls:${total} (allowed ${allowed} / rejected ${rejected})`,
329
+ closingLineCell: (passFail) => `Closing line:${passFail}`,
330
+ observationCountCell: (display) => `Observations:${display}`,
331
+ cannotCountObservations: () => "uncountable",
332
+ outsideAllowlistCell: (detail) => `Citations outside allowlist:${detail}`,
333
+ cannotVerifySectionCell: (passFail) => `Cannot-verify section:${passFail}`,
334
+ suspectPhrasesCell: (detail) => `Suspect phrases:${detail}`,
335
+ auditUnavailable: () => "(audit unavailable)",
336
+ summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
337
+
338
+ | agent | provider | api | model(requested) | model(returned) | effort | store | status | latency | token | est. cost | audit |
339
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`,
340
+ providersBundled: (formatVersion) => `bundled (formatVersion ${formatVersion})`,
341
+ providersExplicit: (path, formatVersion) => `external file ${path} (formatVersion ${formatVersion})`,
342
+ gitignoreNotIgnored: (outDir) => ` ⚠ Output directory ${outDir} is not ignored by the git repo it lives in`,
343
+ gitignoreUnknown: (outDir) => ` ℹ Cannot determine whether output directory ${outDir} is covered by .gitignore (not a git repo, or git unavailable)`,
344
+ aboutToDispatch: (ticketId) => `About to dispatch ${ticketId}:`,
345
+ initialPromptEstimate: (totalEst, maxTokens) => ` Initial prompt estimate ${totalEst} tokens (system prompt + first message only; excludes the ticket and allowlist; this gate's cap is ${maxTokens})`,
346
+ allowlistTotalEstimate: (tokens, files) => ` Allowlist total estimate ${tokens} tokens (${files} file(s))`,
347
+ allowlistEstimateCaveat: () => " └ Upper-bound estimate, not deduplicated; measured code material is roughly 3.5 chars/token, so actual usage is usually well below this",
348
+ sequentialReadAmplification: (n) => ` Sequential-read order amplification ${n} tokens (total from re-sending list content; an upper bound that assumes sequential reads, and does not apply to providers that batch)`,
349
+ sequentialReadCanReduce: (n, pct) => ` └ ⚠ Sorting large files last could bring this down to ${n} (saves ${pct}% here)`,
350
+ sequentialReadNearOptimal: (pct) => ` └ Current order is already close to optimal (reordering saves at most ${pct}%)`,
351
+ sequentialReadCostNote: () => " This figure excludes the initial prompt and the ticket (unaffected by ordering), so the total-cost savings are lower than this",
352
+ worstCaseTotal: (n) => ` Worst-case total ≈ ${n} tokens (sum of each spoke's cap)`,
353
+ concurrencyLine: (n) => ` Concurrency ${n}`,
354
+ tpmPeakLine: (provider, limit, peak) => ` ${provider} tpmLimit ${limit}, statically estimated peak ${peak}`,
355
+ tpmPeakCaveat: () => " └ Static indicator only; does not predict the in-flight TPM curve (429 waits change actual concurrency)",
356
+ allowedReadsSummary: (n, outDir) => ` Allowed reads: ${n} file(s), output to ${outDir}/`,
357
+ lensClosingLineZh: (agent) => `ℹ ${agent}'s lens closing line matches the Chinese fixed closing line`,
358
+ lensClosingLineEn: (agent) => `ℹ ${agent}'s lens closing line matches the English fixed closing line`,
359
+ modelPricing: (input, output, cachedSuffix, asOfSuffix) => ` └ Price per M tokens: input $${input} / output $${output}${cachedSuffix}${asOfSuffix}`,
360
+ modelPricingCachedSuffix: (cached) => ` / cached input $${cached}`,
361
+ modelPricingAsOfSuffix: (asOf) => `; priced as of ${asOf}`,
362
+ modelPricingMissing: (model) => ` └ ⚠ providers.json has no pricing for "${model}"; cost cannot be estimated for it`,
363
+ rawIntegrityCheckFailed: (msg) => `Raw integrity check failed (implementation defect, not retried): ${msg}`,
364
+ rateLimitRetriesExceeded: (n) => `429 retry count exceeded --rate-limit-retries (${n})`,
365
+ rateLimitWaitExceeded: (s, cap) => `429 requested a ${s}s wait, exceeding --max-rate-wait ${cap}s`,
366
+ usageUnavailableRound: (n) => `round ${n}: usage unavailable (usageMissing), conservatively finalizing`,
367
+ finalizeToolCallIgnored: (n) => `round ${n}: finalize call still returned a tool call; ignored`,
368
+ responsesAdapterCallFailed: () => "responses adapter call failed",
369
+ gateOneExceeded: (total, maxTokens, detail) => `Gate one exceeded: total initial estimate ${total} tokens exceeds --max-tokens ${maxTokens}\n${detail}`,
370
+ rawIntegrityNotArray: () => "Raw integrity check failed: assistant turn's raw is not an array (the responses adapter expects raw to be the prior round's response.output array)",
371
+ rawIntegrityItemNotFound: (type) => `Raw integrity check failed: an assistant-turn item (type=${type}) was not found verbatim in the outgoing request — possibly filtered out during continuation`,
372
+ rawIntegrityObjectNotFound: () => "Raw integrity check failed: assistant turn's raw was not found verbatim in the outgoing contents — possibly rebuilt or dropped during continuation",
373
+ doctorHeader: (cmd) => `${cmd} doctor`,
374
+ doctorConfigDirLine: (value) => ` Config dir ${value}`,
375
+ doctorConfigDirSourceDispatchHome: (dir) => `${dir} (source: DISPATCH_HOME)`,
376
+ doctorConfigDirSourceXdgConfigHome: (dir) => `${dir} (source: XDG_CONFIG_HOME)`,
377
+ doctorConfigDirSourceDefault: (dir) => `${dir} (source: default; neither DISPATCH_HOME nor XDG_CONFIG_HOME is set)`,
378
+ doctorConfigDirUnresolved: () => "could not resolve (no HOME)",
379
+ doctorEnvLine: (value) => ` .env ${value}`,
380
+ doctorEnvPresentValue: () => "present (contents not read)",
381
+ doctorEnvMissingValue: (path) => `not found: ${path}`,
382
+ doctorEnvUnresolvedValue: () => "cannot determine (config directory could not resolve)",
383
+ doctorApiKeyLine: (value) => ` API keys ${value} (presence only; values are never read)`,
384
+ doctorProviderCountItem: (name, count) => `${name} ${count}`,
385
+ doctorModelListLine: (value) => ` Model list ${value}`,
386
+ doctorModelListValue: (formatVersion, items) => `providers.json formatVersion ${formatVersion}: ${items}`,
387
+ doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
388
+ doctorLensLine: (value) => ` Lens defs ${value}`,
389
+ doctorLensFoundValue: (dirPath, count, names) => `${dirPath} holds ${count}: ${names}`,
390
+ doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
391
+ doctorFooter: () => 'This command calls no API and costs nothing. To fill in what is missing, see "API keys" in the README.',
392
+ },
393
+ };
394
+ export function m(lang, key, ...args) {
395
+ return MESSAGES[lang][key](...args);
396
+ }