dowafu 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -25
- package/README_zh-tw.md +197 -0
- package/dist/adapters/anthropic-messages.js +18 -11
- package/dist/adapters/gemini-native.js +19 -16
- package/dist/adapters/read-file-tool-description.js +5 -0
- package/dist/adapters/responses.js +22 -14
- package/dist/audit.js +17 -1
- package/dist/cli-args.js +88 -38
- package/dist/cli.js +60 -43
- package/dist/dispatch-home.js +24 -6
- package/dist/gate.js +3 -2
- package/dist/mask.js +16 -3
- package/dist/messages.js +342 -0
- package/dist/output.js +60 -37
- package/dist/prompt.js +5 -3
- package/dist/providers.js +46 -34
- package/dist/raw-integrity.js +6 -5
- package/dist/report.js +76 -22
- package/dist/runner.js +21 -10
- package/dist/ticket.js +27 -25
- package/dist/validate.js +21 -20
- package/dist/whitelist.js +9 -1
- package/package.json +3 -2
- package/publish/en/.agents/skills/find-holes-external/SKILL.md +394 -0
- package/publish/en/.agents/skills/preflight/SKILL.md +120 -0
- package/publish/en/.agents/skills/wrap/SKILL.md +64 -0
- package/publish/en/.claude/agents/explore-haiku.md +8 -0
- package/publish/en/.claude/agents/hole-finder-cost.md +15 -0
- package/publish/en/.claude/agents/hole-finder-feasibility.md +15 -0
- package/publish/en/.claude/agents/hole-finder-safety.md +15 -0
- package/publish/en/.claude/agents/hole-finder.md +14 -0
- package/publish/en/.claude/skills/find-holes/SKILL.md +109 -0
- package/publish/en/.claude/skills/find-holes-external/SKILL.md +407 -0
- package/publish/en/.claude/skills/preflight/SKILL.md +179 -0
- package/publish/en/.claude/skills/wrap/SKILL.md +61 -0
- package/publish/en/README.md +106 -0
- package/publish/en/workflow_spec.md +71 -0
- package/publish/{.agents → zh-tw/.agents}/skills/find-holes-external/SKILL.md +109 -18
- package/publish/{.agents → zh-tw/.agents}/skills/preflight/SKILL.md +22 -5
- package/publish/{.claude → zh-tw/.claude}/skills/find-holes/SKILL.md +21 -4
- package/publish/{.claude → zh-tw/.claude}/skills/find-holes-external/SKILL.md +108 -17
- package/publish/{.claude → zh-tw/.claude}/skills/preflight/SKILL.md +21 -4
- package/publish/{README.md → zh-tw/README.md} +16 -0
- /package/publish/{.agents → zh-tw/.agents}/skills/wrap/SKILL.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/explore-haiku.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-cost.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-feasibility.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder-safety.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/agents/hole-finder.md +0 -0
- /package/publish/{.claude → zh-tw/.claude}/skills/wrap/SKILL.md +0 -0
- /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
|
-
|
|
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
|
-
|
|
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" };
|
|
@@ -62,7 +93,7 @@ export function parseArgs(argv) {
|
|
|
62
93
|
flagHandlers[name] = (v) => {
|
|
63
94
|
const n = Number(v);
|
|
64
95
|
if (!Number.isFinite(n))
|
|
65
|
-
throw new DispatchError(
|
|
96
|
+
throw new DispatchError(m(lang, "numberFlagInvalid", name, v), 2);
|
|
66
97
|
apply(n);
|
|
67
98
|
};
|
|
68
99
|
};
|
|
@@ -70,6 +101,10 @@ export function parseArgs(argv) {
|
|
|
70
101
|
flagHandlers["out"] = (v) => (options.out = v);
|
|
71
102
|
flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
|
|
72
103
|
flagHandlers["providers"] = (v) => (options.providersPath = v);
|
|
104
|
+
// 這裡只存原始字串,不驗證格式——格式與 DISPATCH_LANG 的組合判定需要 process.env,
|
|
105
|
+
// parseArgs 是純函式不碰它,交給 cli.ts 呼叫 resolveLang(見下)。重複帶 --lang 時
|
|
106
|
+
// 最後一次覆蓋前一次,與其他旗標一致(v1.3 §二之2 #8)。
|
|
107
|
+
flagHandlers["lang"] = (v) => (options.langRaw = v);
|
|
73
108
|
numFlag("concurrency", (n) => (options.concurrency = n));
|
|
74
109
|
numFlag("max-tokens", (n) => (options.maxTokens = n));
|
|
75
110
|
numFlag("max-spoke-tokens", (n) => (options.maxSpokeTokens = n));
|
|
@@ -96,10 +131,13 @@ export function parseArgs(argv) {
|
|
|
96
131
|
const name = arg.slice(2);
|
|
97
132
|
const handler = flagHandlers[name];
|
|
98
133
|
if (!handler)
|
|
99
|
-
throw new DispatchError(
|
|
134
|
+
throw new DispatchError(m(lang, "unknownOption", arg, helpText), 2);
|
|
100
135
|
const value = argv[++i];
|
|
101
|
-
if (value === undefined)
|
|
102
|
-
|
|
136
|
+
if (value === undefined) {
|
|
137
|
+
// v1.3 §二之2 #9:--lang 缺值時沿用既有的「缺少值」措辭,額外列出可用值。
|
|
138
|
+
const suffix = name === "lang" ? m(lang, "availableValuesSuffix", m(lang, "availableLangValues")) : "";
|
|
139
|
+
throw new DispatchError(m(lang, "missingFlagValue", name, suffix), 2);
|
|
140
|
+
}
|
|
103
141
|
handler(value);
|
|
104
142
|
}
|
|
105
143
|
else if (ticketDir === undefined) {
|
|
@@ -108,31 +146,43 @@ export function parseArgs(argv) {
|
|
|
108
146
|
else {
|
|
109
147
|
// v1.10 §9:多餘的 positional 原本被靜默忽略(`hub-dispatch a b` 只跑 a),
|
|
110
148
|
// 與設計原則 2(fail closed)不一致,改為中止。
|
|
111
|
-
throw new DispatchError(
|
|
149
|
+
throw new DispatchError(m(lang, "tooManyArgs", arg, ticketDir, helpText), 2);
|
|
112
150
|
}
|
|
113
151
|
}
|
|
114
152
|
if (!ticketDir) {
|
|
115
|
-
throw new DispatchError(
|
|
153
|
+
throw new DispatchError(helpText, 2);
|
|
116
154
|
}
|
|
117
155
|
return { mode: "run", ticketDir, options };
|
|
118
156
|
}
|
|
119
|
-
|
|
157
|
+
// plan_i18n_impl_tickets T3:跟 run-level lang,不是 env lang——這是執行中的即時輸出,
|
|
158
|
+
// 此時 resolveLang 早已成功、cli.ts 手上有權威值,直接傳進來即可,不必再猜語言。
|
|
159
|
+
export function formatEvent(event, lang) {
|
|
120
160
|
switch (event.type) {
|
|
121
161
|
case "spoke_start":
|
|
122
|
-
return
|
|
162
|
+
return m(lang, "eventSpokeStart", event.agent, event.provider, event.model);
|
|
123
163
|
case "round":
|
|
164
|
+
// 此則純 ASCII(round/usage/toolCalls 皆非中文),兩語言逐字相同,不必經 messages.ts。
|
|
124
165
|
return `[${event.agent}] round ${event.round} usage=${JSON.stringify(event.usage)} toolCalls=${event.hasToolCalls}`;
|
|
125
166
|
case "unknown_usage_keys":
|
|
126
|
-
return
|
|
167
|
+
return m(lang, "eventUnknownUsageKeys", event.agent, event.round, event.keys.join(", "));
|
|
127
168
|
case "tool_call":
|
|
128
|
-
return
|
|
169
|
+
return m(lang, "eventToolCall", event.agent, event.path, event.allowed, event.reason);
|
|
129
170
|
case "rate_limit_wait":
|
|
130
|
-
return
|
|
171
|
+
return m(lang, "eventRateLimitWait", event.agent, event.seconds, event.source);
|
|
131
172
|
case "round_error":
|
|
132
|
-
return
|
|
133
|
-
case "spoke_end":
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
return m(lang, "eventRoundError", event.agent, event.round, String(event.status ?? "—"), event.message);
|
|
174
|
+
case "spoke_end": {
|
|
175
|
+
const costLabel = event.costUsd === null ? m(lang, "noPricingData") : `$${event.costUsd.toFixed(4)}`;
|
|
176
|
+
const budgetSuffix = event.budgetTrigger ? ` budgetTrigger=${event.budgetTrigger}` : "";
|
|
177
|
+
return m(lang, "eventSpokeEnd", event.agent, event.status, event.latencyMs, event.totalTokens, costLabel, budgetSuffix);
|
|
178
|
+
}
|
|
137
179
|
}
|
|
138
180
|
}
|
|
181
|
+
// plan_i18n_impl_tickets T3b:moved out of cli.ts(原本是該檔的私有函式)。cli.ts 底部有
|
|
182
|
+
// `main().catch()` 的無條件呼叫,import 它會直接跑掉整支程式(見檔頭說明)——這支函式本身
|
|
183
|
+
// 純函式、不含 I/O,搬來這裡才有安全的方式可以單元測試,不必 spawn 子行程。
|
|
184
|
+
export function buildStdoutSummary(results, lang) {
|
|
185
|
+
return results
|
|
186
|
+
.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))
|
|
187
|
+
.join("\n");
|
|
188
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -19,19 +19,21 @@ 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 { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
|
|
27
27
|
import { checkGitignore } from "./gitignore-check.js";
|
|
28
28
|
import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
|
|
29
|
-
import { parseArgs, formatEvent,
|
|
29
|
+
import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
|
|
30
|
+
import { m } from "./messages.js";
|
|
30
31
|
function createAdapterFor(spoke) {
|
|
31
32
|
const apiKey = process.env[`${spoke.provider.toUpperCase()}_API_KEY`];
|
|
32
33
|
if (!apiKey) {
|
|
33
|
-
// resolveSpokes
|
|
34
|
-
|
|
34
|
+
// resolveSpokes 已檢查過,此處為型別窄化防禦。用 spoke.lang 而非 langRaw/options.lang——
|
|
35
|
+
// 它是 ResolvedSpoke 上已經過 resolveLang 判定的權威值,與 run-level lang 同一個來源。
|
|
36
|
+
throw new DispatchError(m(spoke.lang, "apiKeyMissing", spoke.provider), 2);
|
|
35
37
|
}
|
|
36
38
|
// §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
|
|
37
39
|
// 靜默走錯 adapter。switch 缺 case 時 TypeScript 因「不是每條路徑都回傳值」編譯失敗。
|
|
@@ -42,14 +44,16 @@ function createAdapterFor(spoke) {
|
|
|
42
44
|
apiKey,
|
|
43
45
|
store: spoke.providerConfig.store,
|
|
44
46
|
reasoning: spoke.providerConfig.reasoning,
|
|
47
|
+
lang: spoke.lang,
|
|
45
48
|
});
|
|
46
49
|
case "gemini-native":
|
|
47
|
-
return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL });
|
|
50
|
+
return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL, lang: spoke.lang });
|
|
48
51
|
case "anthropic-messages":
|
|
49
52
|
return createAnthropicAdapter({
|
|
50
53
|
baseURL: spoke.providerConfig.baseURL,
|
|
51
54
|
apiKey,
|
|
52
55
|
reasoning: spoke.providerConfig.reasoning,
|
|
56
|
+
lang: spoke.lang,
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
59
|
}
|
|
@@ -68,9 +72,22 @@ async function confirm(message, output) {
|
|
|
68
72
|
return answer.trim().toLowerCase() === "y";
|
|
69
73
|
}
|
|
70
74
|
async function main() {
|
|
71
|
-
|
|
75
|
+
// plan_i18n_v1.3.md §三:DISPATCH_HOME → .env → secrets 全部搬到 parseArgs 之前,
|
|
76
|
+
// 因為這段現在跑在 --help 之前,任何會拋的東西都會擋住求助路徑——resolveDispatchHome/
|
|
77
|
+
// loadDispatchEnv 內部已各自降級失敗為「沒有設定檔」。§24.4:明文禁止讀 cwd 的 .env,
|
|
78
|
+
// 故不用 `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
|
|
79
|
+
const dispatchHome = resolveDispatchHome();
|
|
80
|
+
if (dispatchHome !== null)
|
|
81
|
+
loadDispatchEnv(dispatchHome);
|
|
82
|
+
registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
|
|
83
|
+
// plan_i18n_impl_tickets T3:parseArgs 自己的解析期錯誤、以及 --help 本身的輸出,都發生在
|
|
84
|
+
// resolveLang 判定出權威語言之前——這裡只能用「DISPATCH_LANG 有效就用它,否則內建預設 en」
|
|
85
|
+
// 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
|
|
86
|
+
// 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
|
|
87
|
+
const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
|
|
88
|
+
const parsed = parseArgs(process.argv.slice(2), messageLang);
|
|
72
89
|
if (parsed.mode === "help") {
|
|
73
|
-
console.log(
|
|
90
|
+
console.log(m(messageLang, "helpText", getCommandName()));
|
|
74
91
|
return;
|
|
75
92
|
}
|
|
76
93
|
if (parsed.mode === "version") {
|
|
@@ -79,27 +96,28 @@ async function main() {
|
|
|
79
96
|
}
|
|
80
97
|
const { ticketDir, options } = parsed;
|
|
81
98
|
const log = makeLogger(options.json);
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
// plan_i18n_v1.3.md §二:--lang > DISPATCH_LANG > 內建預設,含九項組合判定
|
|
100
|
+
// (fail closed,無效值 exit 2)。help/version 已在上面短路,不會走到這裡。
|
|
101
|
+
//
|
|
102
|
+
// T1 驗收帶出的第一條(T3 執行):語言的權威來源只有這行的回傳值。CLI 層訊息一律用
|
|
103
|
+
// 這個 lang,不得讀 options.langRaw——後者是未經 parseLang 驗證的原始字串,可能是
|
|
104
|
+
// undefined 或未正規化的 "ZH-TW",誤用會靜默走中文分支且 typecheck 不會紅。
|
|
105
|
+
const lang = resolveLang(options.langRaw, process.env.DISPATCH_LANG);
|
|
88
106
|
// v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
|
|
89
107
|
// 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
|
|
90
108
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
91
109
|
const ticketId = path.basename(path.resolve(ticketDir));
|
|
92
110
|
// §10 步驟 1–2:工單解析+允許清單存在性(loadTicket/resolveSpokes 內部 fail closed)
|
|
93
|
-
const ticket = await loadTicket(ticketDir);
|
|
111
|
+
const ticket = await loadTicket(ticketDir, lang);
|
|
94
112
|
// §10 步驟 3/v1.10 §24.3:providers.json 隨工具出貨、不可覆寫(方案 D);
|
|
95
113
|
// --providers 是唯一逃生口,整檔取代,一樣過 formatVersion 檢查。
|
|
96
114
|
const providersPath = options.providersPath ? path.resolve(options.providersPath) : bundledProvidersPath();
|
|
97
115
|
const providersSource = options.providersPath
|
|
98
116
|
? { kind: "explicit", path: providersPath, formatVersion: PROVIDERS_FORMAT_VERSION }
|
|
99
117
|
: { kind: "bundled", formatVersion: PROVIDERS_FORMAT_VERSION };
|
|
100
|
-
const providers = await loadProviders(providersPath);
|
|
118
|
+
const providers = await loadProviders(providersPath, lang);
|
|
101
119
|
// §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)
|
|
102
|
-
const spokes = await resolveSpokes(ticket, providers, repoRoot);
|
|
120
|
+
const spokes = await resolveSpokes(ticket, providers, repoRoot, lang);
|
|
103
121
|
// §10 步驟 6/§14 閘門一:呼叫前估算
|
|
104
122
|
const estimates = spokes.map((spoke) => {
|
|
105
123
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
@@ -110,7 +128,7 @@ async function main() {
|
|
|
110
128
|
estimatedTokens: estimateTokens(systemPrompt, charsPerToken) + estimateTokens(firstUserText, charsPerToken),
|
|
111
129
|
};
|
|
112
130
|
});
|
|
113
|
-
checkGateOne(estimates, options.maxTokens);
|
|
131
|
+
checkGateOne(estimates, options.maxTokens, lang);
|
|
114
132
|
// plan_dispatch_v2.0.md §14:允許清單總量估算,獨立於閘門一之外,只呈現不設閘門
|
|
115
133
|
// (閘門一不含允許清單內容,見 gate.ts 的說明)。與 estimates 同一模式:算一次,
|
|
116
134
|
// 報表與 --json plan 共用,不重複讀檔。
|
|
@@ -142,25 +160,30 @@ async function main() {
|
|
|
142
160
|
exitCode,
|
|
143
161
|
});
|
|
144
162
|
// §10 步驟 7/§11:派工報表
|
|
145
|
-
const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, {
|
|
146
|
-
repoRoot,
|
|
147
|
-
providersSource,
|
|
148
|
-
gitignoreStatus,
|
|
149
|
-
});
|
|
163
|
+
const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, { repoRoot, providersSource, gitignoreStatus }, lang);
|
|
150
164
|
log.info(report);
|
|
165
|
+
// 護欄的預告:乾跑不受影響(它不寫任何東西),但先講,免得實跑才發現被擋。
|
|
166
|
+
const outDirDirty = await outDirHasArtifacts(outDir);
|
|
151
167
|
if (options.dryRun) {
|
|
152
|
-
|
|
168
|
+
if (outDirDirty)
|
|
169
|
+
log.info("\n" + m(lang, "outDirNotEmptyDryRunWarning", outDir));
|
|
170
|
+
log.info("\n" + m(lang, "dryRunNotice"));
|
|
153
171
|
if (options.json) {
|
|
154
172
|
console.log(JSON.stringify(maskDeep(buildPayload("dry-run", [], new Map(), new Map(), 0))));
|
|
155
173
|
}
|
|
156
174
|
return;
|
|
157
175
|
}
|
|
176
|
+
// 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
|
|
177
|
+
// 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
|
|
178
|
+
// **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
|
|
179
|
+
// 那正是 --yes 已經示範過的路。要覆蓋,由使用者自己清掉目錄。
|
|
180
|
+
if (outDirDirty) {
|
|
181
|
+
throw new DispatchError(m(lang, "outDirNotEmptyAbort", outDir), 5);
|
|
182
|
+
}
|
|
158
183
|
if (!options.yes) {
|
|
159
|
-
const ok = await confirm(
|
|
184
|
+
const ok = await confirm(m(lang, "confirmPrompt"), options.json ? process.stderr : process.stdout);
|
|
160
185
|
if (!ok) {
|
|
161
|
-
log.info(process.stdin.isTTY
|
|
162
|
-
? "已取消,未呼叫任何 API。"
|
|
163
|
-
: "非互動環境(stdin 不是 TTY)無人可確認,已取消,未呼叫任何 API。要在此環境派工請明確加上 --yes。");
|
|
186
|
+
log.info(process.stdin.isTTY ? m(lang, "cancelledInteractive") : m(lang, "cancelledNonInteractive"));
|
|
164
187
|
if (options.json) {
|
|
165
188
|
console.log(JSON.stringify(maskDeep(buildPayload("cancelled", [], new Map(), new Map(), 0))));
|
|
166
189
|
}
|
|
@@ -173,13 +196,13 @@ async function main() {
|
|
|
173
196
|
}
|
|
174
197
|
catch (err) {
|
|
175
198
|
outDirReady = false;
|
|
176
|
-
console.error(
|
|
199
|
+
console.error(m(lang, "outDirNotWritable", outDir));
|
|
177
200
|
console.error(maskString(String(err)));
|
|
178
201
|
}
|
|
179
|
-
const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl")) : null;
|
|
202
|
+
const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl"), lang) : null;
|
|
180
203
|
const semaphore = new Semaphore(options.concurrency);
|
|
181
204
|
const onEvent = (event) => {
|
|
182
|
-
log.info(formatEvent(event));
|
|
205
|
+
log.info(formatEvent(event, lang));
|
|
183
206
|
runLog?.append(event);
|
|
184
207
|
};
|
|
185
208
|
// §10 步驟 8:信號量控制發起,Promise.allSettled 只負責收尾
|
|
@@ -204,7 +227,7 @@ async function main() {
|
|
|
204
227
|
// §13(一):每支 spoke 完成即落檔,不等 Promise.allSettled——多 spoke 並行、
|
|
205
228
|
// 其中一支慢很多時,快的那支已付費的產出不因慢的還在跑而暴露在中斷風險下。
|
|
206
229
|
if (outDirReady) {
|
|
207
|
-
await persistSpokeResult(outDir, result, (msg) => console.error(msg));
|
|
230
|
+
await persistSpokeResult(outDir, result, (msg) => console.error(msg), lang);
|
|
208
231
|
}
|
|
209
232
|
return result;
|
|
210
233
|
}
|
|
@@ -263,16 +286,16 @@ async function main() {
|
|
|
263
286
|
// rejected 分支的兜底——真實 result 重寫一次是冪等的(writeFile 覆蓋)。
|
|
264
287
|
if (outDirReady) {
|
|
265
288
|
for (const r of results) {
|
|
266
|
-
await persistSpokeResult(outDir, r, (msg) => console.error(msg));
|
|
289
|
+
await persistSpokeResult(outDir, r, (msg) => console.error(msg), lang);
|
|
267
290
|
}
|
|
268
|
-
await writeSummary(outDir, ticketId, results, audits, toolCallAudits);
|
|
291
|
+
await writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang);
|
|
269
292
|
await runLog?.flush();
|
|
270
|
-
log.info(
|
|
293
|
+
log.info("\n" + m(lang, "outDirWritten", outDir));
|
|
271
294
|
}
|
|
272
295
|
else {
|
|
273
296
|
// 落檔目錄不可寫時,不讓已付費的結果消失——完整結果改印到 stderr(json 模式下
|
|
274
297
|
// stdout 仍須維持只有最後那個單一 JSON 物件的契約,不能把這份診斷用資料混進去)。
|
|
275
|
-
log.info("\n
|
|
298
|
+
log.info("\n" + m(lang, "outDirFallbackStderr"));
|
|
276
299
|
console.error(JSON.stringify(maskDeep(results), null, 2));
|
|
277
300
|
}
|
|
278
301
|
const allFailed = results.every((r) => r.status === "failed");
|
|
@@ -284,15 +307,9 @@ async function main() {
|
|
|
284
307
|
console.log(JSON.stringify(maskDeep(buildPayload("executed", results, audits, toolCallAudits, exitCode))));
|
|
285
308
|
}
|
|
286
309
|
else {
|
|
287
|
-
log.info("\n" + buildStdoutSummary(results));
|
|
310
|
+
log.info("\n" + buildStdoutSummary(results, lang));
|
|
288
311
|
}
|
|
289
312
|
}
|
|
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
313
|
main().catch((err) => {
|
|
297
314
|
if (err instanceof DispatchError) {
|
|
298
315
|
console.error(maskString(err.message));
|
package/dist/dispatch-home.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
35
|
+
try {
|
|
36
|
+
dotenv.config({ path: path.join(dispatchHome, ".env") });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// .env 是目錄/無讀取權限/內容畸形等,全部降級,不中止、不拋。
|
|
40
|
+
}
|
|
23
41
|
}
|
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(
|
|
35
|
+
throw new DispatchError(m(lang, "gateOneExceeded", total, maxTokens, detail), 3);
|
|
35
36
|
}
|
|
36
37
|
return total;
|
|
37
38
|
}
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,
|