dowafu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/adapters/anthropic-messages.js +145 -0
  4. package/dist/adapters/gemini-native.js +114 -0
  5. package/dist/adapters/responses.js +112 -0
  6. package/dist/audit.js +123 -0
  7. package/dist/cli-args.js +138 -0
  8. package/dist/cli.js +304 -0
  9. package/dist/cost.js +54 -0
  10. package/dist/dispatch-home.js +23 -0
  11. package/dist/dotenv-invariant.js +66 -0
  12. package/dist/error-classify.js +13 -0
  13. package/dist/gate.js +37 -0
  14. package/dist/gitignore-check.js +24 -0
  15. package/dist/json-output.js +80 -0
  16. package/dist/mask.js +83 -0
  17. package/dist/output.js +152 -0
  18. package/dist/pkg-info.js +35 -0
  19. package/dist/prompt.js +105 -0
  20. package/dist/providers.js +151 -0
  21. package/dist/rate-limit.js +27 -0
  22. package/dist/raw-integrity.js +49 -0
  23. package/dist/report.js +101 -0
  24. package/dist/runner.js +328 -0
  25. package/dist/secret-env.js +6 -0
  26. package/dist/semaphore.js +25 -0
  27. package/dist/ticket.js +156 -0
  28. package/dist/tool-call-audit.js +19 -0
  29. package/dist/types.js +11 -0
  30. package/dist/usage.js +224 -0
  31. package/dist/validate.js +100 -0
  32. package/dist/whitelist.js +38 -0
  33. package/package.json +60 -0
  34. package/providers.json +84 -0
  35. package/publish/.agents/skills/find-holes-external/SKILL.md +418 -0
  36. package/publish/.agents/skills/preflight/SKILL.md +126 -0
  37. package/publish/.agents/skills/wrap/SKILL.md +65 -0
  38. package/publish/.claude/agents/explore-haiku.md +8 -0
  39. package/publish/.claude/agents/hole-finder-cost.md +15 -0
  40. package/publish/.claude/agents/hole-finder-feasibility.md +15 -0
  41. package/publish/.claude/agents/hole-finder-safety.md +15 -0
  42. package/publish/.claude/agents/hole-finder.md +14 -0
  43. package/publish/.claude/skills/find-holes/SKILL.md +112 -0
  44. package/publish/.claude/skills/find-holes-external/SKILL.md +434 -0
  45. package/publish/.claude/skills/preflight/SKILL.md +196 -0
  46. package/publish/.claude/skills/wrap/SKILL.md +62 -0
  47. package/publish/README.md +75 -0
  48. package/publish/workflow_spec.md +65 -0
package/dist/audit.js ADDED
@@ -0,0 +1,123 @@
1
+ // plan_dispatch_v1.4.md §15:確定性稽核,全部以程式判定,不經模型。純函式,吃 spoke 的
2
+ // finalText 與允許讀取清單,吐結構化稽核結果。「疑似」而非「fail」的界線見 §15 說明——
3
+ // 禁止內容關鍵詞必有誤判,故只標記交 hub 判讀,不得自動刪改 spoke 原文。
4
+ import { splitTopLevelSections } from "./ticket.js";
5
+ export const FIXED_CLOSING_LINE = "以上為觀察與問題,採用與否由 hub 與使用者裁決。";
6
+ // 英文工單走英文模板,收尾句與章節名也跟著換。稽核**兩套都認**且不看工單語言——spoke
7
+ // 偶爾會用另一種語言作答,那是產出品質的事,不該讓稽核整份判 fail 而蓋掉真正的訊號。
8
+ export const FIXED_CLOSING_LINE_EN = "These are observations and questions. Whether to adopt them is for the hub and the user to decide.";
9
+ const OBSERVATIONS_SECTIONS = ["觀察", "Observations"];
10
+ const CANNOT_VERIFY_SECTIONS = ["無法驗證", "Cannot verify"];
11
+ function getSection(sections, names) {
12
+ for (const n of names) {
13
+ const body = sections.get(n);
14
+ if (body !== undefined)
15
+ return body;
16
+ }
17
+ return undefined;
18
+ }
19
+ const SUSPECT_PHRASES = ["應該改成", "建議採用", "嚴重度", "高風險", "應廢止"];
20
+ // 抓看起來像相對路徑的引用:至少一層目錄+副檔名,容許前後有反引號與 :行號。
21
+ // plan_dispatch_v1.12.md §15:字元類須容許中括號,否則 Next.js 動態路由段([id]、
22
+ // [...slug]、[[...slug]])會把路徑從中括號後截斷——截斷後的字串當然不在允許清單內,
23
+ // 於是每一條合法引用都被誤判為清單外(首次外部派工實測:三個 spoke 全部誤報)。
24
+ // 比對方式維持精確集合比對不變,只改路徑抽取的容許字元。
25
+ const PATH_REGEX = /`?([\w.\-[\]]+(?:\/[\w.\-[\]]+)+\.[A-Za-z0-9]{1,10})(?::\d+)?`?/g;
26
+ // plan_dispatch_v1.9.md §15:稽核的職責是記錄,不是規範 spoke 的表達結構。原本只認
27
+ // 範本的平鋪編號("1. "),deepseek 用「## 問題 N」+「**觀察 N.N**」的巢狀結構時被判成
28
+ // 0 條——而那次是三份回報中最豐富的一份。放寬為多種常見形式皆計入,依序嘗試、採第一個
29
+ // 有命中的樣式;一種都沒命中時記 null(「數不出來」),不得降級為 0(「沒有」)。
30
+ const OBSERVATION_PATTERNS = [
31
+ /^\d+\.\s/, // 頂層有序清單:"1. "(範本原定格式)
32
+ /^\*\*\d+\.\s/, // 粗體包住編號:"**1. 內容**"(plan_fixes_v1.0.md §1:兩輪各中一次,數不出來時誤判「無法計數」)
33
+ /^(?:\*\*)?觀察\s*\d+(?:\.\d+)?(?:\*\*)?[::]/, // 巢狀觀察標記:"**觀察 1.1**:" 或 "觀察 1:"
34
+ /^#{2,4}\s*觀察\s*\d+/, // 標題形式:"### 觀察 1"
35
+ /^(?:\*\*)?Observation\s*\d+(?:\.\d+)?(?:\*\*)?[::]/i, // 英文模板的巢狀標記
36
+ /^#{2,4}\s*Observation\s*\d+/i, // 英文模板的標題形式
37
+ ];
38
+ function countObservations(observationsBody) {
39
+ if (observationsBody.trim().length === 0)
40
+ return 0; // 章節存在但確實空白=明確的零,不是數不出來
41
+ const lines = observationsBody.split(/\r?\n/).map((l) => l.trim());
42
+ for (const pattern of OBSERVATION_PATTERNS) {
43
+ const count = lines.filter((l) => pattern.test(l)).length;
44
+ if (count > 0)
45
+ return count;
46
+ }
47
+ return null; // 章節有內容,但沒有一種已知樣式命中——無法辨識,不是沒有
48
+ }
49
+ // 找出 targetPath 第一次出現的頂層章節(依文件順序)。用同一個 PATH_REGEX 逐章節重新
50
+ // 抽取比對,而非對章節內文字做 includes 子字串比對,避免相近路徑互相誤判。
51
+ function findSection(sections, targetPath) {
52
+ for (const [heading, body] of sections) {
53
+ const pathsInSection = new Set([...body.matchAll(PATH_REGEX)].map((m) => m[1]));
54
+ if (pathsInSection.has(targetPath))
55
+ return heading;
56
+ }
57
+ return null;
58
+ }
59
+ // 若 citedPath 是某個允許清單項目的路徑後綴(以 "/" 為界,非任意子字串),回傳該項目
60
+ // 原始字串——供讀者判斷是否為縮寫,而非臆測。
61
+ function findSuffixSource(citedPath, allowedRelativePaths) {
62
+ return allowedRelativePaths.find((allowed) => allowed.length > citedPath.length &&
63
+ allowed.endsWith(citedPath) &&
64
+ allowed[allowed.length - citedPath.length - 1] === "/");
65
+ }
66
+ export function auditSpoke(finalText, allowedRelativePaths) {
67
+ if (!finalText) {
68
+ return {
69
+ finalLinePass: false,
70
+ observationCount: null, // 完全沒有內容可數,不是「數出來是零」
71
+ citedPaths: [],
72
+ citedPathsOutsideAllowlist: [],
73
+ citedPathsOutsideAllowlistDetail: [],
74
+ cannotVerifySectionPresent: false,
75
+ suspectPhrases: [],
76
+ };
77
+ }
78
+ const lines = finalText.split(/\r?\n/).map((l) => l.trim());
79
+ const lastNonEmpty = [...lines].reverse().find((l) => l.length > 0) ?? "";
80
+ const finalLinePass = lastNonEmpty === FIXED_CLOSING_LINE || lastNonEmpty === FIXED_CLOSING_LINE_EN;
81
+ const sections = splitTopLevelSections(finalText);
82
+ const observationsSection = getSection(sections, OBSERVATIONS_SECTIONS);
83
+ const observationCount = observationsSection !== undefined ? countObservations(observationsSection) : null;
84
+ const cannotVerifySectionPresent = CANNOT_VERIFY_SECTIONS.some((n) => sections.has(n));
85
+ const citedPaths = [...new Set([...finalText.matchAll(PATH_REGEX)].map((m) => m[1]))];
86
+ const allowedSet = new Set(allowedRelativePaths);
87
+ // 熱修補(issue_log_v1.1.md):「無法驗證」章節內的路徑必然在允許清單之外——§16 回報
88
+ // 模板定義該欄為「需要但讀不到的檔案」,出現清單外路徑是模板要求的正確行為,不是 §15
89
+ // 要防的「臆測或引用工單原文」。故清單外判定排除只出現在該章節的路徑;citedPaths 本身
90
+ // 維持完整記錄不變,記錄(§12)與判定(§15)是不同職責。
91
+ //
92
+ // 「只出現在」是關鍵字——同一路徑若跨「觀察」與「無法驗證」兩節出現,觀察節那筆仍須被
93
+ // 抓到(那正是 §15 要防的訊號:先在觀察節臆測引用,再於無法驗證節「自首」寫不在清單內,
94
+ // 藉此讓臆測那筆連帶被放行)。故須先算出「無法驗證節以外」引用了哪些路徑,只有兩者皆
95
+ // 不成立(在無法驗證節出現、且未在別處出現)才排除。
96
+ //
97
+ // 取捨:spoke 若在「無法驗證」欄裡編造一個不存在、且未在別處引用的路徑,此處抓不到。
98
+ // 可接受——該欄本來就是列「讀不到的檔案」,單獨出現在那裡不構成 §15 要防的訊號。
99
+ const cannotVerifySectionText = getSection(sections, CANNOT_VERIFY_SECTIONS) ?? "";
100
+ const pathsCitedInCannotVerifySection = new Set([...cannotVerifySectionText.matchAll(PATH_REGEX)].map((m) => m[1]));
101
+ const elsewhereText = [...sections.entries()]
102
+ .filter(([heading]) => !CANNOT_VERIFY_SECTIONS.includes(heading))
103
+ .map(([, body]) => body)
104
+ .join("\n");
105
+ const pathsCitedElsewhere = new Set([...elsewhereText.matchAll(PATH_REGEX)].map((m) => m[1]));
106
+ const pathsOnlyInCannotVerify = new Set([...pathsCitedInCannotVerifySection].filter((p) => !pathsCitedElsewhere.has(p)));
107
+ const citedPathsOutsideAllowlist = citedPaths.filter((p) => !allowedSet.has(p) && !pathsOnlyInCannotVerify.has(p));
108
+ const suspectPhrases = SUSPECT_PHRASES.filter((phrase) => finalText.includes(phrase));
109
+ const citedPathsOutsideAllowlistDetail = citedPathsOutsideAllowlist.map((p) => ({
110
+ path: p,
111
+ section: findSection(sections, p),
112
+ suffixOf: findSuffixSource(p, allowedRelativePaths),
113
+ }));
114
+ return {
115
+ finalLinePass,
116
+ observationCount,
117
+ citedPaths,
118
+ citedPathsOutsideAllowlist,
119
+ citedPathsOutsideAllowlistDetail,
120
+ cannotVerifySectionPresent,
121
+ suspectPhrases,
122
+ };
123
+ }
@@ -0,0 +1,138 @@
1
+ // plan_dispatch_v1.4.md §9:CLI 引數解析與說明文字,抽成純函式(不含任何 I/O)以便單元
2
+ // 測試——`src/cli.ts` 是真正的進入點(bottom 有 `main().catch()` 的副作用呼叫),import
3
+ // 它來測 parseArgs 會直接跑掉整支程式;拆到這裡後 cli.ts 只是薄殼。
4
+ //
5
+ // plan_dispatch_v1.10.md §9:新增 --repo-root/--providers/--json/--help/--version,
6
+ // 並收緊兩處:多餘 positional 即中止(原靜默忽略,違反 fail closed);無參數/--help 印
7
+ // 完整用法(原僅一行)。
8
+ import { DispatchError } from "./types.js";
9
+ import { getCommandName } from "./pkg-info.js";
10
+ export const HELP_TEXT = `用法:${getCommandName()} <ticket-dir> [options]
11
+
12
+ --repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
13
+ --providers <path> 整檔取代出貨的 providers.json
14
+ --json stdout 只印結果 JSON,其餘輸出改走 stderr
15
+ --out <dir> 落檔目錄,預設 tmp/spoke/
16
+ --concurrency <n> 同時執行的 spoke 數,預設 2
17
+ --max-tokens <n> 呼叫前估算閘門(各 spoke 初始 prompt 總和),預設 200000
18
+ --max-spoke-tokens <n> 單一 spoke 執行期累積上限(實際 usage),預設 400000
19
+ --timeout <sec> 單次 API 呼叫逾時(不是整支 spoke),預設 600
20
+ --retries <n> 單輪呼叫的重試次數(僅暫時性錯誤),預設 2
21
+ --chars-per-token <n> 閘門一估算係數,預設 1.0(可由 providers.json 逐家覆寫)
22
+ --max-spoke-reasoning-tokens <n> 單一 spoke 的推理 token 累積上限,預設 50000
23
+ --max-round-reasoning-tokens <n> 單輪推理 token 上限,預設 null(不檢查)
24
+ --rate-limit-retries <n> 429 專用重試次數,預設 5(不計入 --retries)
25
+ --max-rate-wait <sec> 單次 429 等待上限,預設 30
26
+ --max-tool-calls <n> 單一 spoke 的 read_file 呼叫上限,預設 30
27
+ --dry-run 解析、驗證、估算、印報表,不呼叫 API
28
+ --yes 略過派工確認。非互動環境(stdin 不是 TTY)沒帶就中止
29
+ --help, -h 印本說明後結束(exit 0)
30
+ --version, -V 印版本號後結束(exit 0)`;
31
+ const DEFAULTS = {
32
+ out: "tmp/spoke",
33
+ concurrency: 2,
34
+ maxTokens: 200_000,
35
+ maxSpokeTokens: 400_000,
36
+ // plan_fixes_v1.0.md §2b:實測單次 API 呼叫曾達 559s(token 只多 18%、輪數相同,
37
+ // 耗時卻是 2.7 倍,判定為對方伺服器負載、不可歸因),300 太容易誤砍未卡住的呼叫。
38
+ timeoutSec: 600,
39
+ // §14:重試是輪級不是 spoke 級(sendWithResilience 收的是當前 conversation),成本
40
+ // 增量遠小於 v1.6 誤述的「1+N 次全額計費」;多輪長執行窗口中的網路波動是真實風險,
41
+ // 故預設由 0 改 2(v1.7 裁示)。
42
+ retries: 2,
43
+ rateLimitRetries: 5,
44
+ maxRateWaitSec: 30,
45
+ maxToolCalls: 30,
46
+ charsPerToken: 1.0,
47
+ maxSpokeReasoningTokens: 50_000,
48
+ maxRoundReasoningTokens: null,
49
+ json: false,
50
+ dryRun: false,
51
+ yes: false,
52
+ };
53
+ export function parseArgs(argv) {
54
+ // --help/--version 可出現在任何位置,且優先於其他一切解析——不要求先有 ticketDir。
55
+ if (argv.includes("--help") || argv.includes("-h"))
56
+ return { mode: "help" };
57
+ if (argv.includes("--version") || argv.includes("-V"))
58
+ return { mode: "version" };
59
+ const options = { ...DEFAULTS };
60
+ let ticketDir;
61
+ const numFlag = (name, apply) => {
62
+ flagHandlers[name] = (v) => {
63
+ const n = Number(v);
64
+ if (!Number.isFinite(n))
65
+ throw new DispatchError(`--${name} 需要數字,收到:${v}`, 2);
66
+ apply(n);
67
+ };
68
+ };
69
+ const flagHandlers = {};
70
+ flagHandlers["out"] = (v) => (options.out = v);
71
+ flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
72
+ flagHandlers["providers"] = (v) => (options.providersPath = v);
73
+ numFlag("concurrency", (n) => (options.concurrency = n));
74
+ numFlag("max-tokens", (n) => (options.maxTokens = n));
75
+ numFlag("max-spoke-tokens", (n) => (options.maxSpokeTokens = n));
76
+ numFlag("timeout", (n) => (options.timeoutSec = n));
77
+ numFlag("retries", (n) => (options.retries = n));
78
+ numFlag("rate-limit-retries", (n) => (options.rateLimitRetries = n));
79
+ numFlag("max-rate-wait", (n) => (options.maxRateWaitSec = n));
80
+ numFlag("max-tool-calls", (n) => (options.maxToolCalls = n));
81
+ numFlag("chars-per-token", (n) => (options.charsPerToken = n));
82
+ numFlag("max-spoke-reasoning-tokens", (n) => (options.maxSpokeReasoningTokens = n));
83
+ numFlag("max-round-reasoning-tokens", (n) => (options.maxRoundReasoningTokens = n));
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const arg = argv[i];
86
+ if (arg === "--dry-run") {
87
+ options.dryRun = true;
88
+ }
89
+ else if (arg === "--yes") {
90
+ options.yes = true;
91
+ }
92
+ else if (arg === "--json") {
93
+ options.json = true;
94
+ }
95
+ else if (arg.startsWith("--")) {
96
+ const name = arg.slice(2);
97
+ const handler = flagHandlers[name];
98
+ if (!handler)
99
+ throw new DispatchError(`未知選項:${arg}\n\n${HELP_TEXT}`, 2);
100
+ const value = argv[++i];
101
+ if (value === undefined)
102
+ throw new DispatchError(`--${name} 缺少值`, 2);
103
+ handler(value);
104
+ }
105
+ else if (ticketDir === undefined) {
106
+ ticketDir = arg;
107
+ }
108
+ else {
109
+ // v1.10 §9:多餘的 positional 原本被靜默忽略(`hub-dispatch a b` 只跑 a),
110
+ // 與設計原則 2(fail closed)不一致,改為中止。
111
+ throw new DispatchError(`多餘的引數:${arg}(工單目錄已是 "${ticketDir}")\n\n${HELP_TEXT}`, 2);
112
+ }
113
+ }
114
+ if (!ticketDir) {
115
+ throw new DispatchError(HELP_TEXT, 2);
116
+ }
117
+ return { mode: "run", ticketDir, options };
118
+ }
119
+ export function formatEvent(event) {
120
+ switch (event.type) {
121
+ case "spoke_start":
122
+ return `[${event.agent}] 開始 → ${event.provider}/${event.model}`;
123
+ case "round":
124
+ return `[${event.agent}] round ${event.round} usage=${JSON.stringify(event.usage)} toolCalls=${event.hasToolCalls}`;
125
+ case "unknown_usage_keys":
126
+ return `[${event.agent}] ⚠ round ${event.round} 出現未知 usage 欄位:${event.keys.join(", ")}`;
127
+ case "tool_call":
128
+ return `[${event.agent}] read_file(${event.path}) ${event.allowed ? "允許" : `拒絕(${event.reason})`}`;
129
+ case "rate_limit_wait":
130
+ return `[${event.agent}] 429,等待 ${event.seconds}s(來源:${event.source})`;
131
+ case "round_error":
132
+ return `[${event.agent}] ⚠ round ${event.round} 錯誤 status=${event.status ?? "—"}:${event.message}`;
133
+ case "spoke_end":
134
+ return (`[${event.agent}] 結束 status=${event.status} latency=${event.latencyMs}ms totalTokens=${event.totalTokens}` +
135
+ ` cost=${event.costUsd === null ? "無價目資料" : `$${event.costUsd.toFixed(4)}`}` +
136
+ (event.budgetTrigger ? ` budgetTrigger=${event.budgetTrigger}` : ""));
137
+ }
138
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ // plan_dispatch_v1.4.md §9/§10:CLI 入口,串起 §10 執行流程 1–10。
3
+ // plan_dispatch_v1.10.md §10:新增步驟 0(設定解析)與步驟 6.5(gitignore 三態檢查),
4
+ // 皆在任何 API 呼叫之前。引數解析、說明文字、事件格式化拆到 cli-args.ts(純函式,
5
+ // 供單元測試);此檔只負責串接與 I/O。
6
+ import path from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { DispatchError } from "./types.js";
9
+ import { loadTicket } from "./ticket.js";
10
+ import { loadProviders, PROVIDERS_FORMAT_VERSION } from "./providers.js";
11
+ import { resolveSpokes } from "./validate.js";
12
+ import { estimateTokens, estimateAllowlistTokens, estimateSequentialRead, checkGateOne } from "./gate.js";
13
+ import { buildFirstUserText, buildSystemPrompt } from "./prompt.js";
14
+ import { buildReport, effectiveCap, effectiveCharsPerToken } from "./report.js";
15
+ import { runSpoke } from "./runner.js";
16
+ import { Semaphore } from "./semaphore.js";
17
+ import { createResponsesAdapter } from "./adapters/responses.js";
18
+ import { createGeminiAdapter } from "./adapters/gemini-native.js";
19
+ import { createAnthropicAdapter } from "./adapters/anthropic-messages.js";
20
+ import { auditSpoke } from "./audit.js";
21
+ import { auditToolCalls } from "./tool-call-audit.js";
22
+ import { ensureOutDir, persistSpokeResult, RunLogWriter, writeSummary } from "./output.js";
23
+ import { registerSecrets, maskString, maskDeep } from "./mask.js";
24
+ import { SECRET_ENV_VARS } from "./secret-env.js";
25
+ import { resolveDispatchHome, loadDispatchEnv } from "./dispatch-home.js";
26
+ import { bundledProvidersPath, getPackageVersion } from "./pkg-info.js";
27
+ import { checkGitignore } from "./gitignore-check.js";
28
+ import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
29
+ import { parseArgs, formatEvent, HELP_TEXT } from "./cli-args.js";
30
+ function createAdapterFor(spoke) {
31
+ const apiKey = process.env[`${spoke.provider.toUpperCase()}_API_KEY`];
32
+ if (!apiKey) {
33
+ // resolveSpokes 已檢查過,此處為型別窄化防禦
34
+ throw new DispatchError(`內部錯誤:${spoke.provider} 的 API key 遺失`, 2);
35
+ }
36
+ // §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
37
+ // 靜默走錯 adapter。switch 缺 case 時 TypeScript 因「不是每條路徑都回傳值」編譯失敗。
38
+ switch (spoke.providerConfig.api) {
39
+ case "responses":
40
+ return createResponsesAdapter({
41
+ baseURL: spoke.providerConfig.baseURL,
42
+ apiKey,
43
+ store: spoke.providerConfig.store,
44
+ reasoning: spoke.providerConfig.reasoning,
45
+ });
46
+ case "gemini-native":
47
+ return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL });
48
+ case "anthropic-messages":
49
+ return createAnthropicAdapter({
50
+ baseURL: spoke.providerConfig.baseURL,
51
+ apiKey,
52
+ reasoning: spoke.providerConfig.reasoning,
53
+ });
54
+ }
55
+ }
56
+ function makeLogger(json) {
57
+ return { info: (msg) => (json ? console.error(msg) : console.log(msg)) };
58
+ }
59
+ async function confirm(message, output) {
60
+ // 非互動環境(agent 經 shell 呼叫時 stdin 不是 TTY)沒有人能回答這個提示。
61
+ // fail closed:先前這裡 return true,等於唯一的付費閘門是操作文件裡的一句話;
62
+ // 要在這種環境派工必須明確帶 --yes。
63
+ if (!process.stdin.isTTY)
64
+ return false;
65
+ const rl = createInterface({ input: process.stdin, output });
66
+ const answer = await rl.question(message);
67
+ rl.close();
68
+ return answer.trim().toLowerCase() === "y";
69
+ }
70
+ async function main() {
71
+ const parsed = parseArgs(process.argv.slice(2));
72
+ if (parsed.mode === "help") {
73
+ console.log(HELP_TEXT);
74
+ return;
75
+ }
76
+ if (parsed.mode === "version") {
77
+ console.log(getPackageVersion());
78
+ return;
79
+ }
80
+ const { ticketDir, options } = parsed;
81
+ const log = makeLogger(options.json);
82
+ // §10 步驟 0:設定解析——DISPATCH_HOME → .env → providers 來源 → formatVersion。
83
+ // 全部在任何 API 呼叫之前,成本為零。§24.4:明文禁止讀 cwd 的 .env,故不用
84
+ // `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
85
+ const dispatchHome = resolveDispatchHome();
86
+ loadDispatchEnv(dispatchHome);
87
+ registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
88
+ // v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
89
+ // 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
90
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
91
+ const ticketId = path.basename(path.resolve(ticketDir));
92
+ // §10 步驟 1–2:工單解析+允許清單存在性(loadTicket/resolveSpokes 內部 fail closed)
93
+ const ticket = await loadTicket(ticketDir);
94
+ // §10 步驟 3/v1.10 §24.3:providers.json 隨工具出貨、不可覆寫(方案 D);
95
+ // --providers 是唯一逃生口,整檔取代,一樣過 formatVersion 檢查。
96
+ const providersPath = options.providersPath ? path.resolve(options.providersPath) : bundledProvidersPath();
97
+ const providersSource = options.providersPath
98
+ ? { kind: "explicit", path: providersPath, formatVersion: PROVIDERS_FORMAT_VERSION }
99
+ : { kind: "bundled", formatVersion: PROVIDERS_FORMAT_VERSION };
100
+ const providers = await loadProviders(providersPath);
101
+ // §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)
102
+ const spokes = await resolveSpokes(ticket, providers, repoRoot);
103
+ // §10 步驟 6/§14 閘門一:呼叫前估算
104
+ const estimates = spokes.map((spoke) => {
105
+ const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
106
+ const firstUserText = buildFirstUserText(path.resolve(ticketDir), spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
107
+ const charsPerToken = effectiveCharsPerToken(spoke, options);
108
+ return {
109
+ agent: spoke.agent,
110
+ estimatedTokens: estimateTokens(systemPrompt, charsPerToken) + estimateTokens(firstUserText, charsPerToken),
111
+ };
112
+ });
113
+ checkGateOne(estimates, options.maxTokens);
114
+ // plan_dispatch_v2.0.md §14:允許清單總量估算,獨立於閘門一之外,只呈現不設閘門
115
+ // (閘門一不含允許清單內容,見 gate.ts 的說明)。與 estimates 同一模式:算一次,
116
+ // 報表與 --json plan 共用,不重複讀檔。
117
+ const allowlistEstimates = spokes.map((spoke) => ({
118
+ agent: spoke.agent,
119
+ estimatedTokens: estimateAllowlistTokens(spoke.allowedReadsResolved, effectiveCharsPerToken(spoke, options)),
120
+ fileCount: spoke.allowedReadsResolved.length,
121
+ sequential: estimateSequentialRead(spoke.allowedReadsResolved, effectiveCharsPerToken(spoke, options)),
122
+ }));
123
+ const outDir = path.join(options.out, ticketId);
124
+ // §10 步驟 6.5/v1.11 §10:輸出目錄的 gitignore 三態檢查,警告不擋執行。判定基準
125
+ // 必須與 outDir 的解析基準一致——outDir 相對 cwd(v1.11 §24.5),故不傳 repoRoot,
126
+ // 用 checkGitignore 的預設值(process.cwd())。
127
+ const gitignoreStatus = checkGitignore(outDir);
128
+ // v1.11 §25:plan 在此刻(步驟 6 之後)已完全確定,與是否實際執行無關——dry-run/
129
+ // cancelled/executed 三種 mode 的 --json 輸出皆含同一份 plan。
130
+ const plan = buildJsonPlan(spokes, estimates, allowlistEstimates, options);
131
+ const buildPayload = (mode, results, audits, toolCallAudits, exitCode) => buildJsonPayload({
132
+ ticketId,
133
+ repoRoot,
134
+ outDir,
135
+ providersSource,
136
+ mode,
137
+ plan,
138
+ gitignoreStatus,
139
+ results,
140
+ audits,
141
+ toolCallAudits,
142
+ exitCode,
143
+ });
144
+ // §10 步驟 7/§11:派工報表
145
+ const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, {
146
+ repoRoot,
147
+ providersSource,
148
+ gitignoreStatus,
149
+ });
150
+ log.info(report);
151
+ if (options.dryRun) {
152
+ log.info("\n--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。");
153
+ if (options.json) {
154
+ console.log(JSON.stringify(maskDeep(buildPayload("dry-run", [], new Map(), new Map(), 0))));
155
+ }
156
+ return;
157
+ }
158
+ if (!options.yes) {
159
+ const ok = await confirm("繼續?[y/N] ", options.json ? process.stderr : process.stdout);
160
+ if (!ok) {
161
+ log.info(process.stdin.isTTY
162
+ ? "已取消,未呼叫任何 API。"
163
+ : "非互動環境(stdin 不是 TTY)無人可確認,已取消,未呼叫任何 API。要在此環境派工請明確加上 --yes。");
164
+ if (options.json) {
165
+ console.log(JSON.stringify(maskDeep(buildPayload("cancelled", [], new Map(), new Map(), 0))));
166
+ }
167
+ return;
168
+ }
169
+ }
170
+ let outDirReady = true;
171
+ try {
172
+ await ensureOutDir(outDir);
173
+ }
174
+ catch (err) {
175
+ outDirReady = false;
176
+ console.error(`落檔目錄不可寫:${outDir}`);
177
+ console.error(maskString(String(err)));
178
+ }
179
+ const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl")) : null;
180
+ const semaphore = new Semaphore(options.concurrency);
181
+ const onEvent = (event) => {
182
+ log.info(formatEvent(event));
183
+ runLog?.append(event);
184
+ };
185
+ // §10 步驟 8:信號量控制發起,Promise.allSettled 只負責收尾
186
+ const settled = await Promise.allSettled(spokes.map(async (spoke) => {
187
+ await semaphore.acquire();
188
+ try {
189
+ const adapter = createAdapterFor(spoke);
190
+ const result = await runSpoke(spoke, adapter, ticketDir, {
191
+ repoRoot,
192
+ timeoutMs: options.timeoutSec * 1000,
193
+ retries: options.retries,
194
+ rateLimitRetries: options.rateLimitRetries,
195
+ maxRateWaitSec: options.maxRateWaitSec,
196
+ maxToolCalls: options.maxToolCalls,
197
+ maxSpokeTokens: effectiveCap(spoke, options),
198
+ maxSpokeReasoningTokens: options.maxSpokeReasoningTokens,
199
+ maxRoundReasoningTokens: options.maxRoundReasoningTokens,
200
+ charsPerToken: effectiveCharsPerToken(spoke, options),
201
+ semaphore,
202
+ onEvent,
203
+ });
204
+ // §13(一):每支 spoke 完成即落檔,不等 Promise.allSettled——多 spoke 並行、
205
+ // 其中一支慢很多時,快的那支已付費的產出不因慢的還在跑而暴露在中斷風險下。
206
+ if (outDirReady) {
207
+ await persistSpokeResult(outDir, result, (msg) => console.error(msg));
208
+ }
209
+ return result;
210
+ }
211
+ finally {
212
+ semaphore.release();
213
+ }
214
+ }));
215
+ const results = settled.map((s, i) => {
216
+ if (s.status === "fulfilled")
217
+ return s.value;
218
+ // §13:spoke 本身的錯誤已在 runSpoke 內部收斂為 status:"failed";這裡是防禦,
219
+ // 理論上不該發生(runSpoke 不對外拋錯)。
220
+ const spoke = spokes[i];
221
+ return {
222
+ agent: spoke.agent,
223
+ provider: spoke.provider,
224
+ api: spoke.providerConfig.api,
225
+ modelRequested: spoke.model,
226
+ modelReturned: null,
227
+ effort: spoke.effort,
228
+ store: "unknown",
229
+ status: "failed",
230
+ finalText: null,
231
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, available: false },
232
+ costUsd: null,
233
+ finishReason: null,
234
+ finishReasonRaw: null,
235
+ toolCalls: [],
236
+ rateLimitHits: [],
237
+ unknownUsageKeys: [],
238
+ attempts: 0,
239
+ errors: [maskString(String(s.reason))],
240
+ requestId: null,
241
+ startedAt: Date.now(),
242
+ finishedAt: Date.now(),
243
+ latencyMs: 0,
244
+ waitedMs: 0,
245
+ estimatedPromptTokens: 0,
246
+ rawRequests: [],
247
+ rawResponses: [],
248
+ rawErrors: [],
249
+ };
250
+ });
251
+ // §10 步驟 9:確定性稽核
252
+ const audits = new Map(results.map((r) => {
253
+ const spoke = spokes.find((sp) => sp.agent === r.agent);
254
+ return [r.agent, auditSpoke(r.finalText, spoke.allowedReadsRelative)];
255
+ }));
256
+ // plan_dispatch_v2.0.md §15(一):tool 呼叫是執行資料不是文字,獨立於 auditSpoke 之外判定。
257
+ const toolCallAudits = new Map(results.map((r) => {
258
+ const spoke = spokes.find((sp) => sp.agent === r.agent);
259
+ return [r.agent, auditToolCalls(r.agent, r.toolCalls, spoke.allowedReadsRelative.length)];
260
+ }));
261
+ // §10 步驟 10:落檔 + stdout 摘要。這裡的迴圈定位已改變(v2.4 §13 規格二):
262
+ // per-spoke 落檔已在 spokes.map 內完成,此處是「落檔失敗的重試」,不再是
263
+ // rejected 分支的兜底——真實 result 重寫一次是冪等的(writeFile 覆蓋)。
264
+ if (outDirReady) {
265
+ for (const r of results) {
266
+ await persistSpokeResult(outDir, r, (msg) => console.error(msg));
267
+ }
268
+ await writeSummary(outDir, ticketId, results, audits, toolCallAudits);
269
+ await runLog?.flush();
270
+ log.info(`\n落檔完成:${outDir}/`);
271
+ }
272
+ else {
273
+ // 落檔目錄不可寫時,不讓已付費的結果消失——完整結果改印到 stderr(json 模式下
274
+ // stdout 仍須維持只有最後那個單一 JSON 物件的契約,不能把這份診斷用資料混進去)。
275
+ log.info("\n落檔目錄不可寫,完整報告已改印於 stderr:");
276
+ console.error(JSON.stringify(maskDeep(results), null, 2));
277
+ }
278
+ const allFailed = results.every((r) => r.status === "failed");
279
+ const exitCode = allFailed ? 4 : 0;
280
+ if (allFailed) {
281
+ process.exitCode = exitCode;
282
+ }
283
+ if (options.json) {
284
+ console.log(JSON.stringify(maskDeep(buildPayload("executed", results, audits, toolCallAudits, exitCode))));
285
+ }
286
+ else {
287
+ log.info("\n" + buildStdoutSummary(results));
288
+ }
289
+ }
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
+ main().catch((err) => {
297
+ if (err instanceof DispatchError) {
298
+ console.error(maskString(err.message));
299
+ process.exitCode = err.exitCode;
300
+ return;
301
+ }
302
+ console.error(maskString(String(err)));
303
+ process.exitCode = 1;
304
+ });
package/dist/cost.js ADDED
@@ -0,0 +1,54 @@
1
+ // plan_fixes_v1.0.md §4:成本用單價換算,不只給 token 數——實測 token 只差 18%,
2
+ // 依官方單價實算卻差 23.2 倍(單價本身差 10.7×〜26.8×)。只看 token 數會嚴重誤判
3
+ // 成本量級(issue_log_v2.3.md:外部 hub 依 token 判「gemini 貴 2.5 倍」,是這個誤判
4
+ // 的實例)。純函式,不做 I/O,供單元測試。
5
+ //
6
+ // 兩個必須處理的細節(§4):
7
+ //
8
+ // 1. gemini 的 thoughtsTokenCount(正規化後的 reasoningTokens)不是 candidatesTokenCount
9
+ // (outputTokens)的子集——官方帳目關係是 promptTokenCount + candidatesTokenCount +
10
+ // thoughtsTokenCount = totalTokenCount(usage.ts 已驗證),且官方文件明文「Output
11
+ // price (including thinking tokens)」,故兩者都要計費,outputTokens 之外要另加
12
+ // reasoningTokens。openai/deepseek/anthropic 的 reasoning/thinking token 是
13
+ // output 的子集,已經含在 outputTokens 裡,不可再加,否則重複計費。
14
+ //
15
+ // 2. anthropic 的 inputTokens 有快取時「不含」快取部分——cachedTokens(cache_read)與
16
+ // cacheWriteTokens(cache_creation)是另外兩個獨立欄位,總量是四者之和(usage.ts
17
+ // 已驗證)。openai/deepseek/gemini 相反:cachedTokens/cacheWriteTokens 是
18
+ // inputTokens 的子集(官方對「快取命中」的計費方式是「原生 input 價的一部分改用
19
+ // 快取價」),要從 inputTokens 扣掉才是「非快取價」該計費的量,否則會把快取部分
20
+ // 重複計成原價。
21
+ // exhaustive 對照表,缺一支會被 TypeScript 擋下——與 usage.ts 的 usageProviderKeyFor
22
+ // 同樣理由:不留「其餘」預設分支,逼新增第四家 provider 時必須手動同步。
23
+ const REASONING_INCLUDED_IN_OUTPUT = {
24
+ responses: true, // openai/deepseek:reasoning_tokens ⊆ output_tokens
25
+ "gemini-native": false, // thoughtsTokenCount 與 candidatesTokenCount 是並列,非子集
26
+ "anthropic-messages": true, // thinking_tokens ⊆ output_tokens
27
+ };
28
+ const CACHED_IS_SUBSET_OF_INPUT = {
29
+ responses: true,
30
+ "gemini-native": true,
31
+ "anthropic-messages": false, // input_tokens 已排除快取部分,四者並列相加
32
+ };
33
+ // pricing 缺席(該模型無價目資料)或 usage 不可用時回傳 null,不是 0——「無法估算」與
34
+ // 「估出來是零」必須可區分,否則報表會把「沒資料」誤讀成「免費」。
35
+ export function estimateCostUsd(usage, api, pricing) {
36
+ if (!pricing)
37
+ return null;
38
+ if (!usage.available)
39
+ return null;
40
+ const cachedTokens = usage.cachedTokens ?? 0;
41
+ const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
42
+ const reasoningTokens = usage.reasoningTokens ?? 0;
43
+ const cachedIsSubset = CACHED_IS_SUBSET_OF_INPUT[api];
44
+ const regularInputTokens = cachedIsSubset
45
+ ? Math.max(0, usage.inputTokens - cachedTokens - cacheWriteTokens)
46
+ : usage.inputTokens;
47
+ const billableOutputTokens = REASONING_INCLUDED_IN_OUTPUT[api] ? usage.outputTokens : usage.outputTokens + reasoningTokens;
48
+ const cachedPricePerM = pricing.cachedInputPerM ?? pricing.inputPerM;
49
+ const cacheWritePricePerM = pricing.cacheWritePerM ?? pricing.inputPerM;
50
+ return ((regularInputTokens / 1_000_000) * pricing.inputPerM +
51
+ (cachedTokens / 1_000_000) * cachedPricePerM +
52
+ (cacheWriteTokens / 1_000_000) * cacheWritePricePerM +
53
+ (billableOutputTokens / 1_000_000) * pricing.outputPerM);
54
+ }
@@ -0,0 +1,23 @@
1
+ // plan_dispatch_v1.10.md §24.4:API key 的載入順序與位置。ambient process.env 優先
2
+ // (CI、一次性覆寫),其次 $DISPATCH_HOME/.env;dotenv 預設不覆寫既有變數,故 ambient
3
+ // 優先自然成立,不需額外邏輯。
4
+ //
5
+ // 明文禁令:dispatch 不得讀取 cwd 的 `.env`——cwd 是被審專案,`import "dotenv/config"`
6
+ // 會把該專案的整份 `.env`(資料庫密碼、第三方 token、webhook secret)載入一個正對三家
7
+ // 外部 API 發請求的行程,而 §12 的遮蔽名單認不出那些秘密。此檔取代原本
8
+ // `src/cli.ts` 頂部的 `import "dotenv/config"`。
9
+ import dotenv from "dotenv";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ export function resolveDispatchHome(env = process.env, homedir = os.homedir) {
13
+ if (env.DISPATCH_HOME)
14
+ return env.DISPATCH_HOME;
15
+ if (env.XDG_CONFIG_HOME)
16
+ return path.join(env.XDG_CONFIG_HOME, "dispatch");
17
+ return path.join(homedir(), ".config", "dispatch");
18
+ }
19
+ // 唯一允許呼叫 dotenv 的地方——`path` 一律明確指定為 dispatchHome 下的 `.env`,
20
+ // 絕不留白(留白即回退成 dotenv 預設讀 cwd 的 `.env`,正是本函式存在的目的所要杜絕的)。
21
+ export function loadDispatchEnv(dispatchHome) {
22
+ dotenv.config({ path: path.join(dispatchHome, ".env") });
23
+ }