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
@@ -0,0 +1,66 @@
1
+ // plan_dispatch_v1.11.md §20:「不做某事」的規格,其測試必須在該事被做了時失敗。
2
+ // v1.10 §24.4 立的禁令(不得讀 cwd 的 .env)原本只靠 `dispatch-home.test.ts` 守住——但
3
+ // 那支測試守的是 `loadDispatchEnv` 這個函式的行為,不是禁令本身:`import "dotenv/config"`
4
+ // 是模組層級副作用,只有「載入該模組」才會觸發;沒有任何測試 import `cli.ts`,故把
5
+ // `import "dotenv/config"` 加回 `cli.ts` 頂部,既有測試依然全綠。
6
+ //
7
+ // 這裡改用靜態掃描:對 `src/**` 與 `scripts/**` 的原始碼逐行比對,不需執行、不需金鑰、
8
+ // 涵蓋所有現在與未來的檔案——子行程測試只能覆蓋被 spawn 的那一支入口,不取代此項。
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ // 唯一允許 `import dotenv from "dotenv"`(載入整個套件,而非 side-effect 的
12
+ // "dotenv/config" 子路徑)的檔案——它是 §24.4 明訂「唯一允許呼叫 dotenv 的地方」。
13
+ export const ALLOWED_BARE_DOTENV_IMPORT_FILE = path.join("src", "dispatch-home.ts");
14
+ // 只比對「行首(去除縮排後)即為 import 陳述式」的形式,故不會誤判 `//` 開頭、
15
+ // 說明這條禁令本身的註解行(本檔與 dispatch-home.ts/dispatch-home.test.ts/cli.ts
16
+ // 皆有這類註解,字面上含有 "dotenv/config" 字串,但不是真正的 import)。
17
+ //
18
+ // 已知涵蓋邊界(hub 驗收 2026-08-06 fixture 實測確認,刻意不擴大 regex 去堵):
19
+ // 1. 動態 import——`const x = await import("dotenv/config")` 不會被偵測,因為它不是
20
+ // 行首的 `import` 陳述式。
21
+ // 2. 跨行 import——`import {\n ...\n} from "dotenv/config"` 不會被偵測,因為比對
22
+ // 是逐「行」進行,模組指定字串沒有跟 `import` 出現在同一行。
23
+ // 不修的理由:要正確排除本檔自己這類說明註解會讓 regex 明顯變複雜,而這道防線要擋的
24
+ // 威脅模型是「有人為了方便把那一行原樣加回來」,不是刻意規避掃描——收益不成比例。
25
+ // 若日後真的出現這兩種寫法,判斷是否值得為此升級掃描邏輯(例如改成解析整個檔案而非
26
+ // 逐行比對),而不是預先做。
27
+ const DOTENV_CONFIG_IMPORT_PATTERN = /^\s*import\b.*["']dotenv\/config["']/;
28
+ const BARE_DOTENV_IMPORT_PATTERN = /^\s*import\b.*["']dotenv["']/;
29
+ function listTsFiles(dir) {
30
+ if (!fs.existsSync(dir))
31
+ return [];
32
+ const out = [];
33
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ const full = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ out.push(...listTsFiles(full));
37
+ }
38
+ else if (entry.isFile() && full.endsWith(".ts")) {
39
+ out.push(full);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ export function scanDotenvInvariant(repoRoot) {
45
+ const files = ["src", "scripts"].flatMap((d) => listTsFiles(path.join(repoRoot, d)));
46
+ const violations = [];
47
+ for (const file of files) {
48
+ const relFile = path.relative(repoRoot, file);
49
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
50
+ lines.forEach((line, i) => {
51
+ if (DOTENV_CONFIG_IMPORT_PATTERN.test(line)) {
52
+ violations.push({ file: relFile, line: i + 1, text: line.trim(), kind: "dotenv/config" });
53
+ return; // 一行不會同時命中兩種樣式("dotenv/config" 與 "dotenv" 的引號內容不同)
54
+ }
55
+ if (BARE_DOTENV_IMPORT_PATTERN.test(line) && relFile !== ALLOWED_BARE_DOTENV_IMPORT_FILE) {
56
+ violations.push({
57
+ file: relFile,
58
+ line: i + 1,
59
+ text: line.trim(),
60
+ kind: "bare-dotenv-outside-allowed-file",
61
+ });
62
+ }
63
+ });
64
+ }
65
+ return violations;
66
+ }
@@ -0,0 +1,13 @@
1
+ // plan_dispatch_v1.8.md §13:錯誤分類依 HTTP status code,不解析錯誤訊息字串——訊息格式
2
+ // 各家不同且會改版,status code 是穩定契約。429 不在此分類範圍內(runner.ts 在呼叫此函式
3
+ // 之前已用 describeError().is429 攔截,走獨立的 429 等待路徑)。
4
+ // 暫時性:5xx、408,以及無 HTTP 回應的網路層錯誤(status 為 undefined——連線重置、DNS
5
+ // 失敗、AbortController 逾時)。確定性:其他 4xx(400 參數錯、401、403、404…),重試
6
+ // 必然再撞同一個錯,不重試。
7
+ export function classifyError(status) {
8
+ if (status === undefined)
9
+ return "transient";
10
+ if (status >= 500 || status === 408)
11
+ return "transient";
12
+ return "permanent";
13
+ }
package/dist/gate.js ADDED
@@ -0,0 +1,37 @@
1
+ // plan_dispatch_v1.4.md §14 閘門一:呼叫前估算,擋量級錯誤。chars/4 粗估,刻意不精算——
2
+ // 目的是攔截「把整個 repo 塞進允許清單」這類量級錯誤,非精算成本。超限即中止(exit 3),
3
+ // 此檢查在任何 API 呼叫之前,成本為零。
4
+ import fs from "node:fs";
5
+ import { DispatchError } from "./types.js";
6
+ // §14:charsPerToken 預設 1.0(不是 chars/4)——實測 chars/4 對中文系統性低估 2.7–4 倍,
7
+ // 低估比高估危險,此閘門寧可誤報。providers.json 可逐家覆寫(見 cli.ts 呼叫端)。
8
+ function estimateTokensFromChars(chars, charsPerToken) {
9
+ return Math.ceil(chars / charsPerToken);
10
+ }
11
+ export function estimateTokens(text, charsPerToken) {
12
+ return estimateTokensFromChars(text.length, charsPerToken);
13
+ }
14
+ export function estimateAllowlistTokens(filePaths, charsPerToken) {
15
+ const totalChars = filePaths.reduce((sum, p) => sum + fs.readFileSync(p, "utf8").length, 0);
16
+ return estimateTokensFromChars(totalChars, charsPerToken);
17
+ }
18
+ export function estimateSequentialRead(filePaths, charsPerToken) {
19
+ const sizes = filePaths.map((p) => fs.readFileSync(p, "utf8").length);
20
+ const amplify = (ordered) => {
21
+ const n = ordered.length;
22
+ const chars = ordered.reduce((sum, s, i) => sum + s * (n - i), 0);
23
+ return estimateTokensFromChars(chars, charsPerToken);
24
+ };
25
+ return {
26
+ asListed: amplify(sizes),
27
+ sorted: amplify([...sizes].sort((a, b) => a - b)),
28
+ };
29
+ }
30
+ export function checkGateOne(estimates, maxTokens) {
31
+ const total = estimates.reduce((sum, e) => sum + e.estimatedTokens, 0);
32
+ if (total > maxTokens) {
33
+ const detail = estimates.map((e) => ` ${e.agent}: ${e.estimatedTokens}`).join("\n");
34
+ throw new DispatchError(`閘門一超限:合計初始估算 ${total} tokens 超過 --max-tokens ${maxTokens}\n${detail}`, 3);
35
+ }
36
+ return total;
37
+ }
@@ -0,0 +1,24 @@
1
+ // plan_dispatch_v1.10.md §10:輸出目錄的 gitignore 三態檢查(使用者裁示:檢查並警告,
2
+ // 不擋執行)。判定用 `git check-ignore -q`,三態不得合併——「未忽略」是需要處理的狀態,
3
+ // 「無法判定」不是(§10「三態不得合併」)。
4
+ //
5
+ // plan_dispatch_v1.11.md §10:判定基準必須與 outDir 的解析基準一致——兩者皆為 cwd,
6
+ // 不是 --repo-root。v1.10 用 `-C <repoRoot>` 判定,但 outDir 是相對 cwd 解析的
7
+ // (v1.11 §24.5:工單目錄/輸出目錄屬呼叫端 cwd,白名單邊界/agents 才屬 repoRoot),
8
+ // cwd ≠ repoRoot 時等於問錯 repo——git 對工作樹外的路徑回 fatal(exit 128),被舊邏輯
9
+ // 吞成 unknown,防線恰好在唯一有價值的情境(跨專案)靜默失效。
10
+ // 一般化規則:任何「對某路徑做判定」的檢查,其基準必須與該路徑的解析基準相同。
11
+ import { spawnSync } from "node:child_process";
12
+ // `cwd` 預設 `process.cwd()`——不接受呼叫端傳入 repoRoot 之類的其他基準;可覆寫純粹是
13
+ // 為了單元測試(例如以 temp git repo 取代真正的 process.cwd()),production 呼叫端
14
+ // (cli.ts)一律用預設值,不傳第二個參數,結構上就不會再誤傳 repoRoot。
15
+ export function checkGitignore(outDir, cwd = process.cwd()) {
16
+ const result = spawnSync("git", ["check-ignore", "-q", outDir], { cwd });
17
+ if (result.error)
18
+ return "unknown"; // git 不存在或無法執行
19
+ if (result.status === 0)
20
+ return "ignored";
21
+ if (result.status === 1)
22
+ return "not_ignored";
23
+ return "unknown"; // 其他 exit code:非 git repo(128)等,或路徑不在該 cwd 所屬的工作樹內
24
+ }
@@ -0,0 +1,80 @@
1
+ // plan_dispatch_v1.10.md §25:--json 輸出契約。純函式,把 SpokeRunResult/AuditResult
2
+ // 轉成 schema 定義的形狀——不在這裡做任何 I/O,方便不落地任何檔案就單元測試。
3
+ //
4
+ // 原文不進 JSON——維持 §16「stdout 只印摘要,不印原文」,hub 依「先原文、後融合」紀律
5
+ // 自行 Read 落檔。observationCount 保留 number | null(v1.9 §15),序列化時不得降級為 0。
6
+ //
7
+ // plan_dispatch_v1.11.md §25:新增 mode/plan/gitignoreStatus。`plan` 在步驟 6 之後
8
+ // 就已完全確定,三種 mode(dry-run/cancelled/executed)皆有,與是否實際執行無關;
9
+ // `spokes` 維持「執行結果」語意,dry-run/cancelled 時仍是空陣列——這是對的,不要改。
10
+ import { effectiveCap } from "./report.js";
11
+ export function buildJsonPlan(spokes, estimates, allowlistEstimates, cli) {
12
+ const estByAgent = new Map(estimates.map((e) => [e.agent, e.estimatedTokens]));
13
+ const allowlistByAgent = new Map(allowlistEstimates.map((e) => [e.agent, e]));
14
+ return spokes.map((spoke) => ({
15
+ agent: spoke.agent,
16
+ provider: spoke.provider,
17
+ api: spoke.providerConfig.api,
18
+ modelRequested: spoke.model,
19
+ effort: spoke.effort,
20
+ store: spoke.providerConfig.store === false ? "false" : "n/a",
21
+ estimatedTokens: estByAgent.get(spoke.agent) ?? 0,
22
+ cap: effectiveCap(spoke, cli),
23
+ allowlistEstimatedTokens: allowlistByAgent.get(spoke.agent)?.estimatedTokens ?? 0,
24
+ allowlistFileCount: allowlistByAgent.get(spoke.agent)?.fileCount ?? 0,
25
+ }));
26
+ }
27
+ export function buildJsonSpoke(result, audit, toolCallAudit) {
28
+ const allowed = result.toolCalls.filter((t) => t.allowed).length;
29
+ return {
30
+ agent: result.agent,
31
+ provider: result.provider,
32
+ api: result.api,
33
+ modelRequested: result.modelRequested,
34
+ modelReturned: result.modelReturned,
35
+ effort: result.effort ?? "",
36
+ store: result.store,
37
+ status: result.status,
38
+ budgetTrigger: result.budgetTrigger ?? null,
39
+ usage: result.usage,
40
+ costUsd: result.costUsd,
41
+ latencyMs: result.latencyMs,
42
+ waitedMs: result.waitedMs,
43
+ attempts: result.attempts,
44
+ toolCalls: {
45
+ total: result.toolCalls.length,
46
+ allowed,
47
+ rejected: result.toolCalls.length - allowed,
48
+ },
49
+ audit: audit
50
+ ? {
51
+ closingLine: audit.finalLinePass,
52
+ observationCount: audit.observationCount, // number | null 原樣保留,不得降級為 0
53
+ pathsOutsideAllowlist: audit.citedPathsOutsideAllowlist,
54
+ hasUnverifiableSection: audit.cannotVerifySectionPresent,
55
+ suspectMatches: audit.suspectPhrases,
56
+ zeroSourceRead: toolCallAudit?.zeroSourceRead ?? false,
57
+ }
58
+ : {
59
+ closingLine: false,
60
+ observationCount: null,
61
+ pathsOutsideAllowlist: [],
62
+ hasUnverifiableSection: false,
63
+ suspectMatches: [],
64
+ zeroSourceRead: toolCallAudit?.zeroSourceRead ?? false,
65
+ },
66
+ };
67
+ }
68
+ export function buildJsonPayload(args) {
69
+ return {
70
+ ticketId: args.ticketId,
71
+ repoRoot: args.repoRoot,
72
+ outDir: args.outDir,
73
+ providersSource: args.providersSource,
74
+ mode: args.mode,
75
+ plan: args.plan,
76
+ gitignoreStatus: args.gitignoreStatus,
77
+ spokes: args.results.map((r) => buildJsonSpoke(r, args.audits.get(r.agent), args.toolCallAudits.get(r.agent))),
78
+ exitCode: args.exitCode,
79
+ };
80
+ }
package/dist/mask.js ADDED
@@ -0,0 +1,83 @@
1
+ // plan_dispatch_v1.4.md §12:API key 不得出現在任何落檔、stdout 或錯誤訊息中。
2
+ // 遮蔽同時套用於 run.jsonl、raw/*.json 與 stdout 三個出口。刻意不對 error 物件
3
+ // 做 JSON.stringify(error):SDK 的 error 物件可能帶著 request headers。
4
+ const REDACTED_HEADER_KEYS = new Set([
5
+ "authorization",
6
+ "x-api-key",
7
+ "api-key",
8
+ "x-goog-api-key",
9
+ ]);
10
+ let secretValues = [];
11
+ // 啟動時登錄本次載入的所有金鑰值,逐一從輸出字串中剔除;正則是 sk-/AIza 等已知前綴的兜底,
12
+ // 不倚賴單一機制。
13
+ export function registerSecrets(values) {
14
+ secretValues = values.filter((v) => Boolean(v && v.length > 0));
15
+ }
16
+ export function maskString(input) {
17
+ let out = input;
18
+ for (const secret of secretValues) {
19
+ out = out.split(secret).join("***REDACTED***");
20
+ }
21
+ out = out.replace(/sk-[A-Za-z0-9]{10,}/g, "***REDACTED***");
22
+ out = out.replace(/ghp_[A-Za-z0-9]{10,}/g, "***REDACTED***");
23
+ out = out.replace(/AIza[A-Za-z0-9_-]{10,}/g, "***REDACTED***");
24
+ return out;
25
+ }
26
+ export function maskHeaders(headers) {
27
+ if (!headers || typeof headers !== "object")
28
+ return undefined;
29
+ const out = {};
30
+ for (const [key, value] of Object.entries(headers)) {
31
+ out[key] = REDACTED_HEADER_KEYS.has(key.toLowerCase())
32
+ ? "***REDACTED***"
33
+ : maskString(String(value));
34
+ }
35
+ return out;
36
+ }
37
+ export function maskDeep(value) {
38
+ if (typeof value === "string")
39
+ return maskString(value);
40
+ if (Array.isArray(value))
41
+ return value.map(maskDeep);
42
+ if (value && typeof value === "object") {
43
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskDeep(v)]));
44
+ }
45
+ return value;
46
+ }
47
+ export function describeError(err) {
48
+ if (err && typeof err === "object") {
49
+ const anyErr = err;
50
+ const status = typeof anyErr.status === "number" ? anyErr.status : undefined;
51
+ const headers = maskHeaders(anyErr.headers);
52
+ 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;
56
+ return {
57
+ status,
58
+ is429: status === 429,
59
+ retryAfterHeader: headers?.["retry-after"] ?? headers?.["Retry-After"] ?? null,
60
+ message,
61
+ errorBody,
62
+ headers,
63
+ };
64
+ }
65
+ return { is429: false, retryAfterHeader: null, message: maskString(String(err)) };
66
+ }
67
+ // 供 adapter 拋出時附掛 status/headers/error(責一致的形狀給 describeError 讀)。
68
+ export class ProviderHttpError extends Error {
69
+ status;
70
+ headers;
71
+ error;
72
+ request;
73
+ constructor(message, status, headers, error,
74
+ // §13:「raw 帶回導致的 400」須保留該次完整請求供比對,故錯誤本身也帶著它。
75
+ request) {
76
+ super(message);
77
+ this.status = status;
78
+ this.headers = headers;
79
+ this.error = error;
80
+ this.request = request;
81
+ this.name = "ProviderHttpError";
82
+ }
83
+ }
package/dist/output.js ADDED
@@ -0,0 +1,152 @@
1
+ // plan_dispatch_v1.4.md §12/§16:落檔與 run.jsonl。寫入序列化——前一次 appendFile 未
2
+ // resolve 前不發下一次;呼叫平行,寫入序列。所有落檔內容在寫入前經 maskDeep 遮蔽(§12)。
3
+ import { appendFile, mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { maskDeep, maskString } from "./mask.js";
6
+ export class RunLogWriter {
7
+ filePath;
8
+ queue = Promise.resolve();
9
+ constructor(filePath) {
10
+ this.filePath = filePath;
11
+ }
12
+ // plan_dispatch_v2.4.md §12(一):ts 語意為「append 被呼叫的當下」,即事件發生時刻,
13
+ // 故在此(方法本體)取時間,不得延後到 queue 的 then callback 內——落盤時間會被檔案
14
+ // 系統阻塞與 queue 排隊污染,失去診斷「哪一輪慢」的價值。ts 於 maskDeep 之後加入,
15
+ // 避免遮蔽邏輯誤傷時間字串。
16
+ append(event) {
17
+ const ts = new Date().toISOString();
18
+ const masked = maskDeep(event);
19
+ const line = JSON.stringify({ ...masked, ts }) + "\n";
20
+ // §12(二):單次 appendFile 失敗只影響該行,不得毒化 queue——否則其後每一次
21
+ // append 都不會執行,且 flush() 也會 rejected,等於一次瞬時寫入失敗讓該次派工
22
+ // 其餘所有事件全部遺失。失敗以 stderr 留痕,不靜默吞掉。
23
+ this.queue = this.queue.then(() => appendFile(this.filePath, line, "utf8")).catch((err) => {
24
+ console.error(`run.jsonl 寫入失敗:${maskString(String(err))}`);
25
+ });
26
+ }
27
+ async flush() {
28
+ await this.queue;
29
+ }
30
+ }
31
+ export async function ensureOutDir(outDir) {
32
+ await mkdir(path.join(outDir, "raw"), { recursive: true });
33
+ // §13:「同一工單重跑會覆蓋同名輸出目錄——刻意行為」。其餘產物(summary.md、
34
+ // <agent>.md、raw/*.json)走 writeFile 自然覆蓋,唯獨 run.jsonl 走 appendFile
35
+ // ——§12 的 append 是為**單次執行內**的中斷安全,不是跨次累積。不在此清空的話,
36
+ // 重跑會把上一次的事件留著,而 toolCalls[] 正是偵測「零讀取」的唯一依據
37
+ // (issue_log_v2.0.md 2026-08-07:hub 驗收時據此數錯 9/22 vs 實際 7/20)。
38
+ await writeFile(path.join(outDir, "run.jsonl"), "", "utf8");
39
+ }
40
+ // §13:raw 一律無條件覆寫。v2.4 實作期間曾加過「空 raw 不覆蓋磁碟上的非空內容」守衛
41
+ // (由 v2.4 §20 的一條測試要求逼出來),驗收時撤除:它破壞了 §13「同一工單重跑會覆蓋
42
+ // 同名輸出目錄」——重跑時該 spoke 若零 raw,上一次的內容會留著冒充本次證據,與
43
+ // 21bbd83 修掉的 run.jsonl 污染同型,而 raw/ 是「當時究竟送了什麼出去」的唯一證據。
44
+ // 「防禦性空 result 覆寫已付費內容」那條路徑的保證,由 persistSpokeResult 的
45
+ // 「例外不外傳」單獨承擔——例外不外傳之後,results[i] 永遠是 runSpoke 的真實回傳值。
46
+ export async function writeRawFiles(outDir, agent, result) {
47
+ await writeFile(path.join(outDir, "raw", `${agent}.request.json`), JSON.stringify(maskDeep(result.rawRequests), null, 2), "utf8");
48
+ await writeFile(path.join(outDir, "raw", `${agent}.response.json`), JSON.stringify(maskDeep(result.rawResponses), null, 2), "utf8");
49
+ // plan_fixes_v1.0.md §6:先前失敗時 request/response 兩份都是 [](兩者只記成功輪次),
50
+ // 中斷或失敗時完全沒有線索。獨立於上面兩份之外——不動既有「僅成功輪次」的語意,
51
+ // 逐次錯誤(含重試中途、非終局那次)另開一份,空陣列=未撞過任何錯誤。
52
+ await writeFile(path.join(outDir, "raw", `${agent}.errors.json`), JSON.stringify(maskDeep(result.rawErrors), null, 2), "utf8");
53
+ }
54
+ // §13:「原文產出」欄——failed 無產出;其餘(含 truncated:*)皆落檔,即使不完整,
55
+ // 因為那是已經付過錢的內容。
56
+ export async function writeSpokeText(outDir, agent, result) {
57
+ if (result.status === "failed")
58
+ return;
59
+ const content = result.finalText ?? `(無法取得完整回報,執行狀態:${result.status})`;
60
+ await writeFile(path.join(outDir, `${agent}.md`), content, "utf8");
61
+ }
62
+ // plan_dispatch_v2.4.md §13(一):每支 spoke 完成即落檔的落地點。落檔例外不得外傳——
63
+ // 落檔失敗不得使呼叫端的 promise rejected、不得改變該 spoke 的 status(落檔是產物持久化,
64
+ // 不是執行結果),但必須留下痕跡:寫入 result.errors[] 並經 onError 回報。
65
+ // writeSpokeText、writeRawFiles 各自包住例外,其一失敗不影響另一個執行。
66
+ export async function persistSpokeResult(outDir, result, onError) {
67
+ try {
68
+ await writeSpokeText(outDir, result.agent, result);
69
+ }
70
+ catch (err) {
71
+ const msg = `落檔失敗(${result.agent}.md):${maskString(String(err))}`;
72
+ result.errors.push(msg);
73
+ onError(msg);
74
+ }
75
+ try {
76
+ await writeRawFiles(outDir, result.agent, result);
77
+ }
78
+ catch (err) {
79
+ const msg = `落檔失敗(${result.agent} raw/):${maskString(String(err))}`;
80
+ result.errors.push(msg);
81
+ onError(msg);
82
+ }
83
+ }
84
+ function formatStoreCell(store) {
85
+ return store === "false" ? "false" : store;
86
+ }
87
+ const BUDGET_TRIGGER_LABEL = {
88
+ total: "總量",
89
+ reasoning: "推理累積",
90
+ reasoning_round: "推理單輪尖峰",
91
+ };
92
+ // §14:truncated:budget 需顯示觸發來源;reasoning_round 是「模型在某一輪卡住」的異常訊號,
93
+ // 與正常的總量/累積推理超支後續動作不同(前者查 prompt 或換 effort,後者調門檻),
94
+ // 須顯眼區分,不能跟其他 truncated:budget 混在一起看起來像同一種情況。
95
+ function formatStatusCell(r) {
96
+ if (r.status !== "truncated:budget" || !r.budgetTrigger)
97
+ return r.status;
98
+ const label = BUDGET_TRIGGER_LABEL[r.budgetTrigger];
99
+ const flag = r.budgetTrigger === "reasoning_round" ? "⚠ 異常尖峰・" : "";
100
+ return `${r.status}(${flag}${label})`;
101
+ }
102
+ // plan_dispatch_v2.0.md §15(二):清單外引用附出現章節與疑似縮寫來源,讓讀者不必自己去猜
103
+ // (issue_log_v2.0.md 2026-08-07:曾有 hub 因為裸字串誤判成稽核器的 bug,推錯了方向)。
104
+ function formatOutsideAllowlistCell(detail) {
105
+ if (detail.length === 0)
106
+ return "無";
107
+ return detail
108
+ .map((d) => {
109
+ const sectionPart = d.section ? `「${d.section}」節` : "章節外";
110
+ const suffixPart = d.suffixOf ? `;疑似 ${d.suffixOf} 的縮寫` : "";
111
+ return `${d.path}(${sectionPart}${suffixPart})`;
112
+ })
113
+ .join(",");
114
+ }
115
+ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits) {
116
+ const rows = results.map((r) => {
117
+ const a = audits.get(r.agent);
118
+ const t = toolCallAudits.get(r.agent);
119
+ const cells = [];
120
+ // plan_dispatch_v2.6.md §26 規格四:hub 會讀的地方才有用(「順序放大量」印進乾跑報表
121
+ // 後 hub 才真的重排清單,第 10 次派工,省 52%),放最前面顯眼標示。
122
+ if (r.unknownUsageKeys.length > 0) {
123
+ cells.push(`⚠ 未知 usage 欄位:${r.provider} ${r.unknownUsageKeys.join(", ")}`);
124
+ }
125
+ // §15(一):沒看程式碼就作答的簽名,放最前面顯眼標示——見 tool-call-audit.ts。
126
+ if (t?.zeroSourceRead) {
127
+ cells.push(`⚠ 零原始碼讀取(允許 ${t.allowedReadsCount} 檔)`);
128
+ }
129
+ if (t) {
130
+ cells.push(`工具呼叫:${t.total}(允許 ${t.allowed}/拒絕 ${t.rejected})`);
131
+ }
132
+ if (a) {
133
+ cells.push(`收尾句:${a.finalLinePass ? "pass" : "fail"}`, `觀察:${a.observationCount ?? "無法計數"}`, // v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
134
+ `清單外引用:${formatOutsideAllowlistCell(a.citedPathsOutsideAllowlistDetail)}`, `無法驗證欄:${a.cannotVerifySectionPresent ? "pass" : "fail"}`, `疑似禁止內容:${a.suspectPhrases.length > 0 ? a.suspectPhrases.join(",") : "無"}`);
135
+ }
136
+ const auditCell = cells.length > 0 ? cells.join(" / ") : "(無法稽核)";
137
+ // plan_fixes_v1.0.md §4:無價目資料須與「估出來是 $0」區分,不能印成空白或 0——
138
+ // 兩者對讀者的意義完全不同(沒資料 vs 免費)。
139
+ const costCell = r.costUsd === null ? "無價目資料" : `$${r.costUsd.toFixed(4)}`;
140
+ return `| ${r.agent} | ${r.provider} | ${r.api} | ${r.modelRequested} | ${r.modelReturned ?? "—"} | ${r.effort ?? "—"} | ${formatStoreCell(r.store)} | ${formatStatusCell(r)} | ${r.latencyMs}ms | ${r.usage.totalTokens} | ${costCell} | ${auditCell} |`;
141
+ });
142
+ return `# dispatch summary — ${ticketId}
143
+
144
+ | agent | provider | api | model(請求) | model(回傳) | effort | store | status | 耗時 | token | 估算成本 | 稽核 |
145
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
146
+ ${rows.join("\n")}
147
+ `;
148
+ }
149
+ export async function writeSummary(outDir, ticketId, results, audits, toolCallAudits) {
150
+ const md = buildSummaryMarkdown(ticketId, results, audits, toolCallAudits);
151
+ await writeFile(path.join(outDir, "summary.md"), md, "utf8");
152
+ }
@@ -0,0 +1,35 @@
1
+ // plan_dispatch_v1.10.md §24.2/§24.3:套件出貨資源(providers.json、package.json 的
2
+ // version)皆相對於本檔(`src/pkg-info.ts` 編譯後為 `dist/pkg-info.ts`,與 `dist/cli.js`
3
+ // 同層)以 `import.meta.url` 解析——不假設 cwd,因為 v1 的目標是「從任意目錄執行」。
4
+ //
5
+ // §24.2:`new URL("../providers.json", import.meta.url)` 在 `pnpm link --global` 下
6
+ // 是否解析得到,是規劃書明訂「必須實跑確認」的一步(Node 對 symlink 預設解析 realpath,
7
+ // 但這是推論不是實測)——已實跑驗證通過,見 issue_log 2026-08-06。
8
+ import { readFileSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ // package root 相對於本檔的位置:src/pkg-info.ts 與 dist/pkg-info.ts 都在 rootDir 正下方
11
+ // 一層(tsconfig.build.json 的 rootDir:"src" 保證兩者深度一致),故 "../" 恆指向 package root。
12
+ function packageRootURL(relativePath) {
13
+ return new URL(`../${relativePath}`, import.meta.url);
14
+ }
15
+ export function bundledProvidersPath() {
16
+ return fileURLToPath(packageRootURL("providers.json"));
17
+ }
18
+ export function getPackageVersion() {
19
+ const pkgPath = fileURLToPath(packageRootURL("package.json"));
20
+ const raw = JSON.parse(readFileSync(pkgPath, "utf8"));
21
+ return raw.version ?? "0.0.0";
22
+ }
23
+ // 指令名同樣取自 package.json,理由與上面一致:這份原始碼會在不同的套件名下出貨,
24
+ // 把名字寫死在說明文字裡,就會出現「--help 教你打 A、實際裝成 B」的落差——而那種落差
25
+ // 不會報錯,只會讓照做的人打不到指令。取 bin 的第一個 key;bin 是字串形式時它等於套件名。
26
+ export function getCommandName() {
27
+ const pkgPath = fileURLToPath(packageRootURL("package.json"));
28
+ const raw = JSON.parse(readFileSync(pkgPath, "utf8"));
29
+ if (raw.bin && typeof raw.bin === "object") {
30
+ const first = Object.keys(raw.bin)[0];
31
+ if (first)
32
+ return first;
33
+ }
34
+ return raw.name ?? "cli";
35
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,105 @@
1
+ // plan_dispatch_v1.4.md §8:system prompt 與第一個 user turn 的組裝規格。
2
+ // 三段依序串接:agent body(frontmatter 已由 validate.ts 剝除)+ 回報模板(dispatch 側維護,
3
+ // 不寫進 agent 檔)+ 工具說明。
4
+ // plan_dispatch_v2.0.md §16:從源頭消除「清單外引用」噪音——spoke 自行縮寫路徑會被 §15
5
+ // 稽核標記為清單外,即使指的其實是清單內的檔案(issue_log_v2.0.md 2026-08-07 的真實案例)。
6
+ import path from "node:path";
7
+ // 英文工單走這一份。內容與中文版逐條對應——同樣先「觀察/依據/原文」,同樣以固定收尾句
8
+ // 結束,因為稽核靠那句話判斷回報有沒有寫完。翻譯時刻意保留祈使句與「原文是主要依據、
9
+ // 行號是輔助」這兩處措辭:實測顯示 spoke 對句型敏感,描述句會被當成建議略過。
10
+ const REPORT_TEMPLATE_EN = `# Observations
11
+ 1. <observation>
12
+ Evidence: <file:line, or explicit reasoning>
13
+ Quote: <when citing a file, copy that one line verbatim; write "reasoning" when the evidence is reasoning>
14
+ —— when citing a file, the path must match the string in "Allowed reads" character for character; do not abbreviate it or write the filename alone.
15
+ —— **the quote is the primary evidence, the line number is secondary**: the hub locates the
16
+ real position by matching the quote, so quote verbatim. Copy only the line you are sure
17
+ of rather than rewriting one from memory.
18
+
19
+ # Cannot verify
20
+ - <files you needed but could not read, or gaps in the list>; write "none" if there are none
21
+
22
+ These are observations and questions. Whether to adopt them is for the hub and the user to decide.`;
23
+ const REPORT_TEMPLATE = `# 觀察
24
+ 1. <觀察>
25
+ 依據:<檔案:行號 或 明確推理>
26
+ 原文:<引用檔案時,逐字複製該處的一行原文;依據為推理時寫「推理」>
27
+ ——引用檔案時,路徑須與「允許讀取」清單中的字串逐字相同,不得縮寫或只寫檔名。
28
+ ——**原文是主要依據,行號是輔助**:hub 會用原文比對出實際位置,所以原文必須逐字,
29
+ 寧可只複製確定的那一行,也不要憑印象重寫。
30
+
31
+ # 無法驗證
32
+ - <需要但讀不到的檔案,或清單不足之處>;沒有則寫「無」
33
+
34
+ 以上為觀察與問題,採用與否由 hub 與使用者裁決。`;
35
+ // plan_dispatch_v2.1.md §8(二):工具說明只提「工單」,從未說允許清單的程式碼也要讀——
36
+ // 零讀取的第二個成因(issue_log_v2.0.md 2026-08-07「provider 端完整 log」)。
37
+ const TOOL_NOTE = "你有一個工具 `read_file(path)`。工單與允許讀取的程式碼檔案都不在本 prompt 中,\n須自行讀取;未讀過的檔案不得出現在「依據」中。";
38
+ const TOOL_NOTE_EN = "You have one tool, `read_file(path)`. Neither the ticket nor the code files you are\nallowed to read are in this prompt; read them yourself. A file you have not read must not\nappear in \"Evidence\".";
39
+ // plan_dispatch_v2.1.md §8(二):組裝順序改為 agent body → 工具說明 → 回報模板。原順序
40
+ // (agent body → 回報模板 → 工具說明)讓工具說明掉在回報模板的固定收尾句之後,在結構上
41
+ // 像附註——而那正是唯一說明「你有工具」的段落(provider log system-1.txt 全文證實)。
42
+ export function buildSystemPrompt(agentBody, lang = "zh") {
43
+ const toolNote = lang === "en" ? TOOL_NOTE_EN : TOOL_NOTE;
44
+ const template = lang === "en" ? REPORT_TEMPLATE_EN : REPORT_TEMPLATE;
45
+ return [agentBody.trim(), toolNote, template].join("\n\n");
46
+ }
47
+ // plan_dispatch_v2.1.md §8(一):步驟 3 原為「需要時讀取」的條件句、且未列路徑——provider
48
+ // log 顯示模型嚴格照句型行事,命令句(步驟 1、2)會執行、條件句(步驟 3)不會。改為與
49
+ // 步驟 1、2 同句型的命令句,並逐條列出允許清單路徑。綁定條件是「要引用就必須先讀」,不是
50
+ // 「必須讀完整份清單」——清單寧寬勿窄時硬性全讀會浪費 token,且與 §7「被拒呼叫仍計入
51
+ // --max-tool-calls」的成本模型衝突。
52
+ function buildStep3(allowedReadsRelative, lang) {
53
+ if (allowedReadsRelative.length === 0) {
54
+ return lang === "en"
55
+ ? "3. There are no readable files this time; answer from the section under review alone."
56
+ : "3. 本次無允許讀取檔案,僅依待審段落作答。";
57
+ }
58
+ const head = lang === "en"
59
+ ? "3. read_file each of the files below — any file you intend to cite in \"Evidence\" must be read first:"
60
+ : "3. 逐一 read_file 下列檔案——凡是要在「依據」中引用的檔案,必須先讀過:";
61
+ return [head, ...allowedReadsRelative.map((p) => ` - ${p}`)].join("\n");
62
+ }
63
+ // issue_log_v2.1.md:步驟 1、2 給絕對路徑、步驟 3 給相對路徑,spoke 看到兩種格式就會混用
64
+ // ——引用時寫成絕對路徑,稽核便判為「清單外引用」。第 7 次派工 11 筆、第 9 次 7 筆全是這種
65
+ // 噪音,而第 9 次同一欄裡還混著 2 筆「真的沒給檔」,假警報淹沒了真訊號。統一成相對路徑後
66
+ // spoke 全程只看得到一種格式。白名單那側本來就兩種都收(whitelist.ts:37 走
67
+ // path.resolve(repoRoot, requested)),故不需配合修改。
68
+ //
69
+ // 但工單目錄**不保證位於 repoRoot 內**——cli.ts 明文「工單目錄仍相對 cwd 解析,不要求位於
70
+ // repoRoot 內」。在外時轉相對會得到 ../.. 這種更難讀、也更容易被誤用的字串,故維持絕對。
71
+ function displayTicketDir(ticketDir, repoRoot) {
72
+ if (!repoRoot)
73
+ return ticketDir;
74
+ const rel = path.relative(repoRoot, ticketDir);
75
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
76
+ return ticketDir;
77
+ return rel;
78
+ }
79
+ export function buildFirstUserText(ticketDir, agent, allowedReadsRelative, repoRoot, lang = "zh") {
80
+ const dir = displayTicketDir(ticketDir, repoRoot);
81
+ if (lang === "en") {
82
+ return `Do these in order:
83
+ 1. read_file("${dir}/_shared.md") — the premises and the section under review
84
+ 2. read_file("${dir}/${agent}.md") — your questions and your list of readable files
85
+ ${buildStep3(allowedReadsRelative, lang)}
86
+ 4. Produce your report in the template given in the system prompt
87
+
88
+ Paths in "Allowed reads" are relative to the repo root, not to the ticket directory.
89
+ Files outside the list are refused. Do not retry a refused file; record what is missing
90
+ under "Cannot verify".`;
91
+ }
92
+ return `依序執行:
93
+ 1. read_file("${dir}/_shared.md") — 取得前提與待審段落
94
+ 2. read_file("${dir}/${agent}.md") — 取得你的具體問題與允許讀取清單
95
+ ${buildStep3(allowedReadsRelative, lang)}
96
+ 4. 依 system prompt 的回報模板產出
97
+
98
+ 「允許讀取」清單內的路徑相對於 repo 根目錄,不是相對於工單目錄。
99
+ 清單外的檔案會被拒絕。被拒時不要重試,在「無法驗證」欄記下缺什麼。`;
100
+ }
101
+ export function buildFinalizeUserText(lang = "zh") {
102
+ return lang === "en"
103
+ ? "You have reached the execution limit. Produce your report now from what you already have; do not call any more tools."
104
+ : "已達執行上限,請依現有資訊直接產出目前的回報,不要再呼叫工具。";
105
+ }