dowafu 0.4.0 → 0.5.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/dist/cli.js CHANGED
@@ -4,14 +4,18 @@
4
4
  // 皆在任何 API 呼叫之前。引數解析、說明文字、事件格式化拆到 cli-args.ts(純函式,
5
5
  // 供單元測試);此檔只負責串接與 I/O。
6
6
  import path from "node:path";
7
+ import { readFile } from "node:fs/promises";
8
+ import { realpathSync } from "node:fs";
7
9
  import { createInterface } from "node:readline/promises";
10
+ import { fileURLToPath } from "node:url";
8
11
  import { DispatchError } from "./types.js";
12
+ import { loadProviders } from "./providers.js";
9
13
  import { loadTicket } from "./ticket.js";
10
- import { loadProviders, PROVIDERS_FORMAT_VERSION } from "./providers.js";
11
14
  import { resolveSpokes } from "./validate.js";
12
15
  import { estimateTokens, estimateAllowlistTokens, estimateSequentialRead, checkGateOne } from "./gate.js";
13
16
  import { buildFirstUserText, buildSystemPrompt } from "./prompt.js";
14
17
  import { buildReport, effectiveCap, effectiveCharsPerToken } from "./report.js";
18
+ import { checkGitignore } from "./gitignore-check.js";
15
19
  import { runSpoke } from "./runner.js";
16
20
  import { Semaphore } from "./semaphore.js";
17
21
  import { createResponsesAdapter } from "./adapters/responses.js";
@@ -19,48 +23,267 @@ import { createGeminiAdapter } from "./adapters/gemini-native.js";
19
23
  import { createAnthropicAdapter } from "./adapters/anthropic-messages.js";
20
24
  import { auditSpoke } from "./audit.js";
21
25
  import { auditToolCalls } from "./tool-call-audit.js";
22
- import { ensureOutDir, outDirHasArtifacts, persistSpokeResult, RunLogWriter, writeSummary } from "./output.js";
26
+ import { persistSpokeResultDb, readResults, writeSummaryDb } from "./output.js";
27
+ import { recordRound } from "./progress.js";
23
28
  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 { buildDoctorReport } from "./doctor.js";
29
+ import { buildDoctorReportData, renderDoctorReport } from "./doctor.js";
27
30
  import { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
28
- import { checkGitignore } from "./gitignore-check.js";
29
31
  import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
30
32
  import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
31
33
  import { m } from "./messages.js";
32
- function createAdapterFor(spoke) {
33
- const apiKey = process.env[`${spoke.provider.toUpperCase()}_API_KEY`];
34
- if (!apiKey) {
35
- // resolveSpokes 已檢查過,此處為型別窄化防禦。用 spoke.lang 而非 langRaw/options.lang——
36
- // 它是 ResolvedSpoke 上已經過 resolveLang 判定的權威值,與 run-level lang 同一個來源。
37
- throw new DispatchError(m(spoke.lang, "apiKeyMissing", spoke.provider), 2);
38
- }
39
- // §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
40
- // 靜默走錯 adapter。switch case TypeScript 因「不是每條路徑都回傳值」編譯失敗。
41
- switch (spoke.providerConfig.api) {
34
+ import { defaultDbPath, openDb } from "./db.js";
35
+ import { issueToken, listTokens, revokeToken } from "./api-token.js";
36
+ import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
37
+ import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
38
+ import { serve, stopDaemon } from "./daemon.js";
39
+ import { daemonAlive, daemonStatePath } from "./liveness.js";
40
+ import { startStdioServer } from "./mcp/stdio.js";
41
+ import { isModelEnabled, listModels, mergeProviders, readProvidersFile, seedIfEmpty, setModelEnabled } from "./providers-store.js";
42
+ import { buildKeyStatusRows, getProviderKey, listKnownProviderNames, listProviderKeys, maskTail, removeProviderKey, recordProviderKeyTest, setProviderKey, } from "./provider-key.js";
43
+ // §29 規格十:窮盡式分派,不留「其餘落到 gemini」的 fallback——那會讓未來第四家 provider
44
+ // 靜默走錯 adapter。switch 缺 case 時 TypeScript 因「不是每條路徑都回傳值」編譯失敗。
45
+ function buildAdapterForConfig(providerConfig, apiKey, lang) {
46
+ switch (providerConfig.api) {
42
47
  case "responses":
43
48
  return createResponsesAdapter({
44
- baseURL: spoke.providerConfig.baseURL,
49
+ baseURL: providerConfig.baseURL,
45
50
  apiKey,
46
- store: spoke.providerConfig.store,
47
- reasoning: spoke.providerConfig.reasoning,
48
- lang: spoke.lang,
51
+ store: providerConfig.store,
52
+ reasoning: providerConfig.reasoning,
53
+ lang,
49
54
  });
50
55
  case "gemini-native":
51
- return createGeminiAdapter({ apiKey, baseURL: spoke.providerConfig.baseURL, lang: spoke.lang });
56
+ return createGeminiAdapter({ apiKey, baseURL: providerConfig.baseURL, lang });
52
57
  case "anthropic-messages":
53
58
  return createAnthropicAdapter({
54
- baseURL: spoke.providerConfig.baseURL,
59
+ baseURL: providerConfig.baseURL,
55
60
  apiKey,
56
- reasoning: spoke.providerConfig.reasoning,
57
- lang: spoke.lang,
61
+ reasoning: providerConfig.reasoning,
62
+ lang,
58
63
  });
59
64
  }
60
65
  }
66
+ // 工單 key-db §B:API key 的唯一來源是 DB 的 `provider_keys` 表(0.5.0 起不讀環境變數,
67
+ // 使用者裁示 2026-09-06,理由見 provider-key.ts 檔頭)。resolveSpokes 更早的那道齊備檢查
68
+ // 現在收 hasApiKey 回呼、問的是同一張表,兩處不會分岔。
69
+ function createAdapterFor(spoke, db) {
70
+ const apiKey = getProviderKey(db, spoke.provider)?.apiKey;
71
+ if (!apiKey) {
72
+ // resolveSpokes 已檢查過,此處為型別窄化防禦。用 spoke.lang 而非 langRaw/options.lang——
73
+ // 它是 ResolvedSpoke 上已經過 resolveLang 判定的權威值,與 run-level lang 同一個來源。
74
+ throw new DispatchError(m(spoke.lang, "apiKeyMissing", spoke.provider), 2);
75
+ }
76
+ return buildAdapterForConfig(spoke.providerConfig, apiKey, spoke.lang);
77
+ }
61
78
  function makeLogger(json) {
62
79
  return { info: (msg) => (json ? console.error(msg) : console.log(msg)) };
63
80
  }
81
+ // 熱修補(票 B 驗收):候選清單要印幾碼才夠分辨。最少 8 碼(維持原本的簡短),
82
+ // 但若在候選之間仍有重複就一路加長到能區分為止;全等時退回完整 id。
83
+ function shortestDistinctIdWidth(ids) {
84
+ const longest = Math.max(0, ...ids.map((id) => id.length));
85
+ for (let width = 8; width < longest; width++) {
86
+ if (new Set(ids.map((id) => id.slice(0, width))).size === ids.length)
87
+ return width;
88
+ }
89
+ return longest;
90
+ }
91
+ // 熱修補(票 A 驗收缺口二):所有經 CLI 開啟的 DB 都在空表時播種一次出貨檔。
92
+ // 通知走 **stderr**——stdout 要留給 --json 與 MCP 的 JSON-RPC,多印一行會壞掉契約。
93
+ //
94
+ // 工單 key-db §D:這是唯一開 DB 的地方,所以也是唯一需要重新註冊遮罩名單的地方——
95
+ // DB 裡存著的每一把 provider key,不管這次會不會被用到,全部餵進 registerSecrets。
96
+ // registerSecrets 是覆寫不是累加(mask.ts 的陷阱,見票 §D 引用的 v0_5_0/_archived/log.md §十四),
97
+ // 這裡若只餵 DB 那份會蓋掉 main() 頂部已經註冊過的 env 來源,因此兩份要一起餵。
98
+ function openDbSeeded(dbPath, lang) {
99
+ const db = openDb(dbPath, lang);
100
+ if (seedIfEmpty(db, lang))
101
+ console.error(m(lang, "providersSeeded", bundledProvidersPath()));
102
+ registerSecrets(listProviderKeys(db).map((key) => key.apiKey));
103
+ return db;
104
+ }
105
+ // 工單 status-stdin §B:使用者裁示(2026-09-07)——D1 把工單本體移進 DB 之後,
106
+ // `ticket-store.ts` 的 createTicket/addSpoke 本來就吃字串,問題只在這裡(CLI)先把
107
+ // 內容寫成暫存檔、CLI 再讀那個檔,等於繞了一圈。`-` 代表從 stdin 讀本文,與路徑寫法
108
+ // 共用同一個位置(`<shared-file>`/`<body-file>`),add-spoke 選用的 `[effort]` 因此
109
+ // 不受影響,仍然在同一個位置解析。
110
+ //
111
+ // 匯出是為了讓「stdin 是 TTY」這個守衛能被直接單元測試:真的 TTY 在自動化測試裡無法
112
+ // 可靠模擬(沒有 pty),而 cli.ts 這個模組本身在被 import 的當下就會執行 main(),
113
+ // 不能拿來 import 後再呼叫——所以守衛本身必須是一個不依賴模組載入副作用、可以單獨
114
+ // 呼叫驗證的純函式。
115
+ export function assertStdinPiped(isTTY, lang) {
116
+ if (isTTY)
117
+ throw new DispatchError(m(lang, "ticketStdinRequiresPipe"), 2);
118
+ }
119
+ async function readBodyOrStdin(pathOrDash, lang) {
120
+ if (pathOrDash !== "-")
121
+ return readFile(pathOrDash, "utf8");
122
+ // 先檢查再讀:TTY 情況下 for-await 會掛住等鍵盤輸入,必須在那之前就中止(不卡住)。
123
+ assertStdinPiped(process.stdin.isTTY, lang);
124
+ const chunks = [];
125
+ for await (const chunk of process.stdin)
126
+ chunks.push(chunk);
127
+ return Buffer.concat(chunks).toString("utf8");
128
+ }
129
+ async function ticketCommand(argv, lang) {
130
+ const command = getCommandName();
131
+ const args = [...argv];
132
+ let dbPath;
133
+ let repoRoot = process.cwd();
134
+ for (let i = 0; i < args.length; i++) {
135
+ if (args[i] === "--db") {
136
+ dbPath = args[i + 1];
137
+ args.splice(i, 2);
138
+ i--;
139
+ }
140
+ else if (args[i] === "--repo-root") {
141
+ repoRoot = path.resolve(args[i + 1] ?? "");
142
+ args.splice(i, 2);
143
+ i--;
144
+ }
145
+ }
146
+ const [action, ...rest] = args;
147
+ if (!action || action === "--help" || action === "-h") {
148
+ console.log(m(lang, "ticketHelp", command));
149
+ return;
150
+ }
151
+ const db = openDbSeeded(dbPath, lang);
152
+ switch (action) {
153
+ case "create": {
154
+ const [name, sharedPath] = rest;
155
+ if (!name || !sharedPath)
156
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
157
+ createTicket(db, name, await readBodyOrStdin(sharedPath, lang), lang);
158
+ return;
159
+ }
160
+ case "add-spoke": {
161
+ const [name, agent, provider, model, bodyPath, effort] = rest;
162
+ if (!name || !agent || !provider || !model || !bodyPath)
163
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
164
+ addSpoke(db, name, { agent, provider, model, body: await readBodyOrStdin(bodyPath, lang), effort }, lang);
165
+ return;
166
+ }
167
+ case "add-allow": {
168
+ const [name, agent, rel] = rest;
169
+ if (!name || !agent || !rel)
170
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
171
+ await addAllow(db, name, agent, rel, repoRoot, lang);
172
+ return;
173
+ }
174
+ case "import": {
175
+ const [dir, suppliedName] = rest;
176
+ if (!dir)
177
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
178
+ await importTicketDirectory(db, dir, suppliedName ?? path.basename(path.resolve(dir)), repoRoot, lang);
179
+ return;
180
+ }
181
+ case "show": {
182
+ const [name] = rest;
183
+ if (!name)
184
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
185
+ console.log(formatTicket(db, name, lang));
186
+ return;
187
+ }
188
+ default:
189
+ throw new DispatchError(m(lang, "ticketHelp", command), 2);
190
+ }
191
+ }
192
+ async function providersCommand(argv, lang) {
193
+ const args = [...argv];
194
+ let dbPath;
195
+ for (let i = 0; i < args.length; i++) {
196
+ if (args[i] === "--db") {
197
+ dbPath = args[i + 1];
198
+ args.splice(i, 2);
199
+ i--;
200
+ }
201
+ }
202
+ const [action, ...rest] = args;
203
+ const command = getCommandName();
204
+ if (!action || action === "--help" || action === "-h") {
205
+ console.log(m(lang, "providersHelp", command));
206
+ return;
207
+ }
208
+ const db = openDbSeeded(dbPath, lang);
209
+ if (action === "list") {
210
+ const models = listModels(db);
211
+ if (models.length === 0)
212
+ console.log(m(lang, "providersListEmpty"));
213
+ for (const model of models)
214
+ console.log(m(lang, "providersListRow", model.provider, model.model, model.inputPerM?.toString() ?? "—", model.outputPerM?.toString() ?? "—", model.enabled));
215
+ return;
216
+ }
217
+ if (action === "import") {
218
+ const [filePath] = rest;
219
+ if (!filePath)
220
+ throw new DispatchError(m(lang, "providersHelp", command), 2);
221
+ mergeProviders(db, await readProvidersFile(path.resolve(filePath), lang), path.resolve(filePath) === bundledProvidersPath());
222
+ console.log(m(lang, "providersImported", filePath));
223
+ return;
224
+ }
225
+ if (action === "enable" || action === "disable") {
226
+ const [first, second, third] = rest;
227
+ if (!first || third !== undefined)
228
+ throw new DispatchError(m(lang, "providersHelp", command), 2);
229
+ setModelEnabled(db, first, second, action === "enable", lang);
230
+ console.log(m(lang, action === "enable" ? "providersEnabled" : "providersDisabled", second ?? first));
231
+ return;
232
+ }
233
+ throw new DispatchError(m(lang, "providersHelp", command), 2);
234
+ }
235
+ // 工單 C §B-2:入站 HTTP 憑證的發/驗/撤銷 CLI 面積。與 providers/ticket 子指令同一種
236
+ // 「--db 先剝離、其餘走一個小 switch」形狀。
237
+ async function tokenCommand(argv, lang) {
238
+ const command = getCommandName();
239
+ const args = [...argv];
240
+ let dbPath;
241
+ for (let i = 0; i < args.length; i++) {
242
+ if (args[i] === "--db") {
243
+ dbPath = args[i + 1];
244
+ args.splice(i, 2);
245
+ i--;
246
+ }
247
+ }
248
+ const [action, ...rest] = args;
249
+ if (!action || action === "--help" || action === "-h") {
250
+ console.log(m(lang, "tokenHelp", command));
251
+ return;
252
+ }
253
+ const db = openDbSeeded(dbPath, lang);
254
+ switch (action) {
255
+ case "issue": {
256
+ let label;
257
+ for (let i = 0; i < rest.length; i++) {
258
+ if (rest[i] === "--label")
259
+ label = rest[i + 1];
260
+ }
261
+ const { id, plaintext } = issueToken(db, label);
262
+ console.log(m(lang, "tokenIssued", id, plaintext));
263
+ return;
264
+ }
265
+ case "list": {
266
+ const tokens = listTokens(db);
267
+ if (tokens.length === 0) {
268
+ console.log(m(lang, "tokenListEmpty"));
269
+ return;
270
+ }
271
+ for (const t of tokens)
272
+ console.log(m(lang, "tokenListRow", t.id, t.label ?? "—", t.created_at, t.revoked_at !== null));
273
+ return;
274
+ }
275
+ case "revoke": {
276
+ const [id] = rest;
277
+ if (!id)
278
+ throw new DispatchError(m(lang, "tokenHelp", command), 2);
279
+ revokeToken(db, id, lang);
280
+ console.log(m(lang, "tokenRevoked", id));
281
+ return;
282
+ }
283
+ default:
284
+ throw new DispatchError(m(lang, "tokenHelp", command), 2);
285
+ }
286
+ }
64
287
  async function confirm(message, output) {
65
288
  // 非互動環境(agent 經 shell 呼叫時 stdin 不是 TTY)沒有人能回答這個提示。
66
289
  // fail closed:先前這裡 return true,等於唯一的付費閘門是操作文件裡的一句話;
@@ -72,20 +295,304 @@ async function confirm(message, output) {
72
295
  rl.close();
73
296
  return answer.trim().toLowerCase() === "y";
74
297
  }
298
+ // 工單 key-db §C-1 之1:貼上時不回顯,但這是唯一需要逐字元收字的地方——readline/promises
299
+ // 的 question() 沒有隱藏輸入的選項。用 raw mode 自行收字元,僅在 Enter 時換行,不逐字回顯;
300
+ // Backspace 可修正,Ctrl+C 直接離開(不留在 raw mode)。
301
+ async function readSecret(prompt) {
302
+ process.stdout.write(prompt);
303
+ return new Promise((resolve) => {
304
+ const stdin = process.stdin;
305
+ const wasRaw = stdin.isRaw;
306
+ stdin.setRawMode?.(true);
307
+ stdin.resume();
308
+ stdin.setEncoding("utf8");
309
+ let value = "";
310
+ const cleanup = () => {
311
+ stdin.removeListener("data", onData);
312
+ stdin.setRawMode?.(wasRaw ?? false);
313
+ stdin.pause();
314
+ };
315
+ const onData = (chunk) => {
316
+ for (const char of chunk) {
317
+ if (char === "\n" || char === "\r") {
318
+ cleanup();
319
+ process.stdout.write("\n");
320
+ resolve(value);
321
+ return;
322
+ }
323
+ if (char === "\u0003") {
324
+ // Ctrl+C:離開前恢復終端機模式,不留在 raw mode。
325
+ cleanup();
326
+ process.stdout.write("\n");
327
+ process.exit(130);
328
+ }
329
+ if (char === "\u007f" || char === "\b") {
330
+ value = value.slice(0, -1);
331
+ continue;
332
+ }
333
+ value += char;
334
+ }
335
+ };
336
+ stdin.on("data", onData);
337
+ });
338
+ }
339
+ function assertKnownProvider(db, provider, lang) {
340
+ const known = listKnownProviderNames(db);
341
+ if (!known.includes(provider)) {
342
+ throw new DispatchError(m(lang, "keyUnknownProvider", provider, known.join(", ")), 2);
343
+ }
344
+ }
345
+ // `dowafu key`(互動選單)、`key list` 共用的狀態字串。0.5.0 起 DB 是唯一來源,
346
+ // 所以「有沒有設定」就等於 maskedTail 是不是 null——沒有來源欄位要顯示。
347
+ function keyStatusText(lang, row) {
348
+ if (row.maskedTail === null)
349
+ return m(lang, "keyStatusMissing");
350
+ const configured = m(lang, "keyStatusDb", row.maskedTail, row.updatedAt ? row.updatedAt.slice(0, 10) : "");
351
+ if (row.lastTestSucceeded === null || row.lastTestedAt === null || row.lastTestModel === null)
352
+ return configured + m(lang, "keyStatusUntested");
353
+ return configured + m(lang, row.lastTestSucceeded ? "keyStatusTestSucceeded" : "keyStatusTestFailed", row.lastTestModel, row.lastTestedAt.slice(0, 10));
354
+ }
355
+ // key-visibility §B-0:把付費邊界抽成可注入 sender。此函式不做 console I/O,測試可用假
356
+ // sender 同時覆核成功與失敗寫回 DB;正式呼叫仍由 runKeyTest 保留原本的確認與輸出責任。
357
+ export async function executeKeyTest(db, provider, lang, send) {
358
+ const stored = getProviderKey(db, provider);
359
+ if (!stored)
360
+ throw new DispatchError(m(lang, "keyTestKeyMissing", provider), 2);
361
+ const providers = loadProviders(db, lang);
362
+ const providerConfig = providers[provider];
363
+ if (!providerConfig)
364
+ throw new DispatchError(m(lang, "keyUnknownProvider", provider, Object.keys(providers).join(", ")), 2);
365
+ const candidates = listModels(db).filter((model) => model.provider === provider && model.enabled);
366
+ if (candidates.length === 0)
367
+ throw new DispatchError(m(lang, "keyTestNoEnabledModel", provider), 2);
368
+ const model = candidates[0].model;
369
+ try {
370
+ await send({ providerConfig, apiKey: stored.apiKey, model, lang });
371
+ recordProviderKeyTest(db, provider, model, true);
372
+ return { model, succeeded: true };
373
+ }
374
+ catch (err) {
375
+ const detail = maskString(String(err));
376
+ recordProviderKeyTest(db, provider, model, false);
377
+ return { model, succeeded: false, detail };
378
+ }
379
+ }
380
+ async function runKeyTest(db, provider, lang) {
381
+ console.log(m(lang, "keyTestCostWarning", provider));
382
+ const ok = await confirm(m(lang, "keyTestConfirmPrompt"), process.stdout);
383
+ if (!ok) {
384
+ console.log(m(lang, "keyTestCancelled"));
385
+ return;
386
+ }
387
+ const outcome = await executeKeyTest(db, provider, lang, async ({ providerConfig, apiKey, model, lang: testLang }) => {
388
+ const adapter = buildAdapterForConfig(providerConfig, apiKey, testLang);
389
+ await adapter.send({ systemPrompt: "", turns: [{ role: "user", text: "ping" }] }, { model, effort: providerConfig.reasoning.default, enableTools: false });
390
+ });
391
+ if (outcome.succeeded) {
392
+ console.log(m(lang, "keyTestSucceeded", provider, outcome.model));
393
+ return;
394
+ }
395
+ throw new DispatchError(m(lang, "keyTestFailed", provider, outcome.detail), 2);
396
+ }
397
+ // 工單 key-db §C:`dowafu key`(免參數,互動)/`key list`/`key rm`/`key test`。
398
+ // 裁示 1/3:不吃命令列參數、不留非互動的寫入路徑——互動選單在 stdin 不是 TTY 時必須
399
+ // 明確拒絕,不得掛住等輸入、不得讀管道內容。`list`/`rm`/`test` 不受此限(C-3)。
400
+ async function keyCommand(argv, lang) {
401
+ const command = getCommandName();
402
+ const args = [...argv];
403
+ let dbPath;
404
+ for (let i = 0; i < args.length; i++) {
405
+ if (args[i] === "--db") {
406
+ dbPath = args[i + 1];
407
+ args.splice(i, 2);
408
+ i--;
409
+ }
410
+ }
411
+ const [action, ...rest] = args;
412
+ if (action === "--help" || action === "-h") {
413
+ console.log(m(lang, "keyHelp", command));
414
+ return;
415
+ }
416
+ const db = openDbSeeded(dbPath, lang);
417
+ if (action === "list") {
418
+ const rows = buildKeyStatusRows(db);
419
+ for (const row of rows)
420
+ console.log(m(lang, "keyListRow", row.provider, keyStatusText(lang, row)));
421
+ return;
422
+ }
423
+ if (action === "rm") {
424
+ const [provider] = rest;
425
+ if (!provider)
426
+ throw new DispatchError(m(lang, "keyHelp", command), 2);
427
+ assertKnownProvider(db, provider, lang);
428
+ const removed = removeProviderKey(db, provider);
429
+ console.log(m(lang, removed ? "keyRemoved" : "keyRemovedNotFound", provider));
430
+ return;
431
+ }
432
+ if (action === "test") {
433
+ const [provider] = rest;
434
+ if (!provider)
435
+ throw new DispatchError(m(lang, "keyTestHelp", command), 2);
436
+ assertKnownProvider(db, provider, lang);
437
+ await runKeyTest(db, provider, lang);
438
+ return;
439
+ }
440
+ if (action !== undefined)
441
+ throw new DispatchError(m(lang, "keyHelp", command), 2);
442
+ // 免參數:互動選單(裁示 1:一個指令 + 選單選 provider + 貼上;裁示 3:不留非互動路徑)。
443
+ if (!process.stdin.isTTY) {
444
+ throw new DispatchError(m(lang, "keyInteractiveRequiresTty"), 2);
445
+ }
446
+ const rows = buildKeyStatusRows(db);
447
+ console.log(m(lang, "keyMenuHeader"));
448
+ const width = Math.max(...rows.map((row) => row.provider.length));
449
+ rows.forEach((row, i) => {
450
+ console.log(m(lang, "keyMenuRow", i + 1, row.provider.padEnd(width), keyStatusText(lang, row)));
451
+ });
452
+ const menuRl = createInterface({ input: process.stdin, output: process.stdout });
453
+ const choiceRaw = (await menuRl.question(m(lang, "keyMenuPrompt", rows.length))).trim();
454
+ menuRl.close();
455
+ if (choiceRaw.toLowerCase() === "q") {
456
+ console.log(m(lang, "keyMenuQuit"));
457
+ return;
458
+ }
459
+ const choice = Number(choiceRaw);
460
+ if (!Number.isInteger(choice) || choice < 1 || choice > rows.length) {
461
+ throw new DispatchError(m(lang, "keyMenuInvalidChoice"), 2);
462
+ }
463
+ const provider = rows[choice - 1].provider;
464
+ // C-1 要求 1:前後空白要 trim——貼上最常見的失敗是帶了尾隨空白或只貼到一半。
465
+ const secret = (await readSecret(m(lang, "keyPastePrompt", provider))).trim();
466
+ if (!secret) {
467
+ console.log(m(lang, "keyPasteEmpty"));
468
+ return;
469
+ }
470
+ const record = setProviderKey(db, provider, secret);
471
+ console.log(m(lang, "keySavedTail", maskTail(record.apiKey)));
472
+ // §D:這把剛存進 DB 的值必須立刻進遮罩名單——下面「要不要立即驗證」若真的呼叫 API,
473
+ // 這把已經在 secretValues 裡,不會原樣寫進任何錯誤訊息。
474
+ registerSecrets(listProviderKeys(db).map((k) => k.apiKey));
475
+ // 裁示 2:不直接驗,存完問一句要不要立即驗;預設 N,跑之前明講會花錢(runKeyTest 自己會問)。
476
+ const verifyRl = createInterface({ input: process.stdin, output: process.stdout });
477
+ const verifyAnswer = (await verifyRl.question(m(lang, "keyVerifyPrompt", provider))).trim().toLowerCase();
478
+ verifyRl.close();
479
+ if (verifyAnswer === "y") {
480
+ await runKeyTest(db, provider, lang);
481
+ }
482
+ }
75
483
  async function main() {
76
- // plan_i18n_v1.3.md §三:DISPATCH_HOME .env secrets 全部搬到 parseArgs 之前,
77
- // 因為這段現在跑在 --help 之前,任何會拋的東西都會擋住求助路徑——resolveDispatchHome/
78
- // loadDispatchEnv 內部已各自降級失敗為「沒有設定檔」。§24.4:明文禁止讀 cwd .env,
79
- // 故不用 `import "dotenv/config"`(那會讀 cwd),改為明確指定 dispatchHome 下的路徑。
80
- const dispatchHome = resolveDispatchHome();
81
- if (dispatchHome !== null)
82
- loadDispatchEnv(dispatchHome);
83
- registerSecrets(SECRET_ENV_VARS.map((k) => process.env[k]));
484
+ // 0.5.0 main() 開頭不再做任何 key 相關的載入:沒有 `.env`、沒有環境變數來源
485
+ // (見 dispatch-home.ts 檔頭)。`$DISPATCH_HOME` 仍是 DB 的預設落點,但由 defaultDbPath()
486
+ // 自己解析;遮罩的註冊移到各個持有 db handle 的路徑上(openDbSeeded 之後)。
84
487
  // plan_i18n_impl_tickets T3:parseArgs 自己的解析期錯誤、以及 --help 本身的輸出,都發生在
85
488
  // resolveLang 判定出權威語言之前——這裡只能用「DISPATCH_LANG 有效就用它,否則內建預設 en」
86
489
  // 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
87
490
  // 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
88
491
  const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
492
+ if (process.argv[2] === "ticket") {
493
+ await ticketCommand(process.argv.slice(3), messageLang);
494
+ return;
495
+ }
496
+ if (process.argv[2] === "providers") {
497
+ await providersCommand(process.argv.slice(3), messageLang);
498
+ return;
499
+ }
500
+ if (process.argv[2] === "token") {
501
+ await tokenCommand(process.argv.slice(3), messageLang);
502
+ return;
503
+ }
504
+ if (process.argv[2] === "key") {
505
+ await keyCommand(process.argv.slice(3), messageLang);
506
+ return;
507
+ }
508
+ if (process.argv[2] === "result") {
509
+ const args = process.argv.slice(3);
510
+ const positional = [];
511
+ let dbPath;
512
+ for (let i = 0; i < args.length; i++) {
513
+ if (args[i] === "--db")
514
+ dbPath = args[++i];
515
+ else
516
+ positional.push(args[i]);
517
+ }
518
+ if (positional.length !== 1)
519
+ throw new DispatchError(m(messageLang, "resultHelp", getCommandName()), 2);
520
+ const db = openDbSeeded(dbPath, messageLang);
521
+ const requestedId = positional[0];
522
+ // `result` has three accepted identifiers. Resolve an exact job id first, then an
523
+ // exact ticket id, and only then a job-id prefix: exact identifiers must never be
524
+ // reinterpreted as a shorter prefix when their text happens to collide.
525
+ let resultId = requestedId;
526
+ const exactJob = getJob(db, requestedId);
527
+ if (exactJob) {
528
+ resultId = exactJob.id;
529
+ }
530
+ else if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(requestedId)) {
531
+ const candidates = findJobsByIdPrefix(db, requestedId);
532
+ if (candidates.length > 1) {
533
+ console.log(m(messageLang, "resultPrefixAmbiguous", requestedId, candidates.length));
534
+ const idWidth = shortestDistinctIdWidth(candidates.map((job) => job.id));
535
+ for (const job of candidates)
536
+ console.log(m(messageLang, "resultCandidate", job.id.slice(0, idWidth), job.ticket, job.status));
537
+ return;
538
+ }
539
+ if (candidates.length === 1)
540
+ resultId = candidates[0].id;
541
+ }
542
+ // 驗收熱修補:失敗 spoke 的字樣先前固定英文(readResults 的 lang 有預設值而這裡沒傳),
543
+ // 中文模式下會印出一段英文——違反「對使用者的字串一律走 messages.ts 中英兩套」。
544
+ const lines = readResults(db, resultId, messageLang);
545
+ if (lines.length === 0)
546
+ throw new DispatchError(m(messageLang, "resultNotFound", requestedId), 2);
547
+ console.log(lines.join("\n\n"));
548
+ return;
549
+ }
550
+ if (process.argv[2] === "approve") {
551
+ const args = process.argv.slice(3);
552
+ const positional = [];
553
+ let dbPath;
554
+ for (let i = 0; i < args.length; i++) {
555
+ if (args[i] === "--db")
556
+ dbPath = args[++i];
557
+ else
558
+ positional.push(args[i]);
559
+ }
560
+ if (positional.length > 1)
561
+ throw new DispatchError(m(messageLang, "approveHelp", getCommandName()), 2);
562
+ const db = openDbSeeded(dbPath, messageLang);
563
+ const prefix = positional[0];
564
+ const pending = prefix === undefined ? listPendingApprovalJobs(db) : findJobsByIdPrefix(db, prefix);
565
+ if (prefix === undefined && pending.length === 0) {
566
+ console.log(m(messageLang, "approveNoPending"));
567
+ return;
568
+ }
569
+ if (pending.length > 1) {
570
+ if (prefix === undefined)
571
+ console.log(m(messageLang, "approveMultiplePending", pending.length));
572
+ else
573
+ console.log(m(messageLang, "approvePrefixAmbiguous", prefix, pending.length));
574
+ // 熱修補:候選清單原本一律截到 8 碼,而歧義前綴的每一筆前 8 碼**必然相同**——
575
+ // 訊息叫人「給長一點的 id」,列出來的卻正好是分不出來的那一段,照著做不到。
576
+ // 改為取「足以在本次候選中互相區分」的長度,最少仍是 8。
577
+ const idWidth = shortestDistinctIdWidth(pending.map((job) => job.id));
578
+ for (const job of pending)
579
+ console.log(m(messageLang, "approvePendingCandidate", job.id.slice(0, idWidth), job.ticket, job.created_at));
580
+ return;
581
+ }
582
+ const before = pending[0];
583
+ if (!before)
584
+ throw new DispatchError(m(messageLang, "jobNotFound", prefix ?? ""), 2);
585
+ // Render the complete paid-work summary before changing the gate state.
586
+ // A corrupted job therefore stays pending instead of becoming approved with
587
+ // no information shown to the human operator.
588
+ const ticketSummary = formatTicket(db, before.ticket, messageLang);
589
+ console.log(ticketSummary);
590
+ const job = approveJob(db, before.id);
591
+ if (!job)
592
+ throw new DispatchError(m(messageLang, "jobNotApprovable", before.id, before.status), 2);
593
+ console.log(m(messageLang, "jobApproved", job.id));
594
+ return;
595
+ }
89
596
  const parsed = parseArgs(process.argv.slice(2), messageLang);
90
597
  if (parsed.mode === "help") {
91
598
  console.log(m(messageLang, "helpText", getCommandName()));
@@ -101,18 +608,77 @@ async function main() {
101
608
  // §一之6:型號白名單走既有的 loadProviders/bundledProvidersPath,載入失敗印失敗原因、
102
609
  // 不中止(doctor 本身 exit 一律 0,見下方 buildDoctorReport 的呼叫沒有任何 throw 路徑)。
103
610
  let providersResult;
611
+ // 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
612
+ // DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
613
+ let keyStatusRows = [];
614
+ // A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
615
+ // 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
616
+ // 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
617
+ const doctorDbPath = parsed.options.dbPath ?? defaultDbPath();
104
618
  try {
105
- const providers = await loadProviders(bundledProvidersPath(), messageLang);
106
- providersResult = { ok: true, providers };
619
+ const doctorDb = openDbSeeded(doctorDbPath, messageLang);
620
+ const providers = loadProviders(doctorDb, messageLang);
621
+ providersResult = { ok: true, providers, models: listModels(doctorDb) };
622
+ keyStatusRows = buildKeyStatusRows(doctorDb);
107
623
  }
108
624
  catch (err) {
109
625
  const reason = err instanceof DispatchError ? err.message : maskString(String(err));
110
626
  providersResult = { ok: false, reason };
111
627
  }
112
- console.log(buildDoctorReport(messageLang, getCommandName(), providersResult));
628
+ const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
629
+ console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
113
630
  return;
114
631
  }
115
- const { ticketDir, options } = parsed;
632
+ if (parsed.mode === "serve") {
633
+ if (parsed.stop) {
634
+ const outcome = await stopDaemon(parsed.options.dbPath, {
635
+ force: parsed.options.yes,
636
+ confirmRunningJobs: async (count) => {
637
+ console.log(m(messageLang, "daemonStopRunningWarning", count));
638
+ return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
639
+ },
640
+ });
641
+ switch (outcome.status) {
642
+ case "not_running":
643
+ console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
644
+ break;
645
+ case "cancelled":
646
+ console.log(m(messageLang, "daemonStopCancelled"));
647
+ break;
648
+ case "pid_not_dowafu":
649
+ console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
650
+ break;
651
+ case "signal_failed":
652
+ console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
653
+ break;
654
+ case "stopped":
655
+ console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
656
+ break;
657
+ case "restarted":
658
+ console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
659
+ break;
660
+ }
661
+ return;
662
+ }
663
+ try {
664
+ const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
665
+ console.log(m(messageLang, "daemonStarted"));
666
+ console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
667
+ // B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
668
+ if (!daemon.hasActiveToken)
669
+ console.log(m(messageLang, "httpNoTokensWarning"));
670
+ }
671
+ catch (err) {
672
+ const detail = err instanceof Error ? err.message : String(err);
673
+ throw new DispatchError(m(messageLang, "daemonStartFailed", detail), 2);
674
+ }
675
+ return;
676
+ }
677
+ if (parsed.mode === "mcp") {
678
+ startStdioServer({ dbPath: parsed.options.dbPath, lang: messageLang });
679
+ return;
680
+ }
681
+ const { ticketDir: ticketName, options } = parsed;
116
682
  const log = makeLogger(options.json);
117
683
  // plan_i18n_v1.3.md §二:--lang > DISPATCH_LANG > 內建預設,含九項組合判定
118
684
  // (fail closed,無效值 exit 2)。help/version 已在上面短路,不會走到這裡。
@@ -124,22 +690,20 @@ async function main() {
124
690
  // v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
125
691
  // 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
126
692
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
127
- const ticketId = path.basename(path.resolve(ticketDir));
693
+ const ticketId = ticketName;
694
+ const db = openDbSeeded(options.dbPath, lang);
128
695
  // §10 步驟 1–2:工單解析+允許清單存在性(loadTicket/resolveSpokes 內部 fail closed)
129
- const ticket = await loadTicket(ticketDir, lang);
130
- // §10 步驟 3/v1.10 §24.3:providers.json 隨工具出貨、不可覆寫(方案 D);
131
- // --providers 是唯一逃生口,整檔取代,一樣過 formatVersion 檢查。
132
- const providersPath = options.providersPath ? path.resolve(options.providersPath) : bundledProvidersPath();
133
- const providersSource = options.providersPath
134
- ? { kind: "explicit", path: providersPath, formatVersion: PROVIDERS_FORMAT_VERSION }
135
- : { kind: "bundled", formatVersion: PROVIDERS_FORMAT_VERSION };
136
- const providers = await loadProviders(providersPath, lang);
137
- // §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)
138
- const spokes = await resolveSpokes(ticket, providers, repoRoot, lang);
696
+ const ticket = await loadTicket(db, ticketName, lang);
697
+ const providersSource = { kind: "database" };
698
+ const providers = loadProviders(db, lang);
699
+ // §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)。
700
+ // 兩個回呼都是必填:validate.ts 不持有 db handle,「這個型號開著嗎」與「這個 provider
701
+ // 有沒有 key」都由這裡接上——漏傳編不過(typecheck 涵蓋測試檔)。
702
+ const spokes = await resolveSpokes(ticket, providers, repoRoot, lang, (provider, model) => isModelEnabled(db, provider, model), (provider) => getProviderKey(db, provider) !== undefined);
139
703
  // §10 步驟 6/§14 閘門一:呼叫前估算
140
704
  const estimates = spokes.map((spoke) => {
141
705
  const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
142
- const firstUserText = buildFirstUserText(path.resolve(ticketDir), spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
706
+ const firstUserText = buildFirstUserText(ticket.ticketDir, spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
143
707
  const charsPerToken = effectiveCharsPerToken(spoke, options);
144
708
  return {
145
709
  agent: spoke.agent,
@@ -152,15 +716,18 @@ async function main() {
152
716
  // 報表與 --json plan 共用,不重複讀檔。
153
717
  const allowlistEstimates = spokes.map((spoke) => ({
154
718
  agent: spoke.agent,
155
- estimatedTokens: estimateAllowlistTokens(spoke.allowedReadsResolved, effectiveCharsPerToken(spoke, options)),
719
+ estimatedTokens: estimateAllowlistTokens(spoke.allowedReadsContent ?? [], effectiveCharsPerToken(spoke, options)),
156
720
  fileCount: spoke.allowedReadsResolved.length,
157
- sequential: estimateSequentialRead(spoke.allowedReadsResolved, effectiveCharsPerToken(spoke, options)),
721
+ sequential: estimateSequentialRead(spoke.allowedReadsContent ?? [], effectiveCharsPerToken(spoke, options)),
158
722
  }));
159
- const outDir = path.join(options.out, ticketId);
160
- // §10 步驟 6.5/v1.11 §10:輸出目錄的 gitignore 三態檢查,警告不擋執行。判定基準
161
- // 必須與 outDir 的解析基準一致——outDir 相對 cwd(v1.11 §24.5),故不傳 repoRoot,
162
- // checkGitignore 的預設值(process.cwd())。
163
- const gitignoreStatus = checkGitignore(outDir);
723
+ const resultId = options.jobId ?? ticketId;
724
+ const outDir = `db:${resultId}`;
725
+ // cost-cap §E #1:outDir(`db:<id>`)是假路徑,git 永遠判不出結果——舊碼因此寫死
726
+ // "unknown" 並印出編出來的理由(「not a git repo, or git unavailable」,沒有人去檢查)。
727
+ // 產物已搬進 DB(v1.11 §24.5),真正該檢查的是資料庫檔案本身的位置:`--db` 可以指到
728
+ // repo 裡面,這樣才問得出「派工產物會不會被誤 commit」這個問題原本想問的事。
729
+ const resolvedDbPath = options.dbPath ?? defaultDbPath();
730
+ const gitignoreStatus = checkGitignore(resolvedDbPath);
164
731
  // v1.11 §25:plan 在此刻(步驟 6 之後)已完全確定,與是否實際執行無關——dry-run/
165
732
  // cancelled/executed 三種 mode 的 --json 輸出皆含同一份 plan。
166
733
  const plan = buildJsonPlan(spokes, estimates, allowlistEstimates, options);
@@ -182,15 +749,16 @@ async function main() {
182
749
  repoRoot,
183
750
  providersSource,
184
751
  gitignoreStatus,
752
+ dbPath: resolvedDbPath,
185
753
  reviewTextChars: ticket.shared.reviewText.length,
186
754
  strayHeadings: ticket.shared.strayHeadings,
187
755
  }, lang);
188
756
  log.info(report);
189
757
  // 護欄的預告:乾跑不受影響(它不寫任何東西),但先講,免得實跑才發現被擋。
190
- const outDirDirty = await outDirHasArtifacts(outDir);
758
+ const alreadyRun = hasResults(db, resultId);
191
759
  if (options.dryRun) {
192
- if (outDirDirty)
193
- log.info("\n" + m(lang, "outDirNotEmptyDryRunWarning", outDir));
760
+ if (alreadyRun)
761
+ log.info("\n" + m(lang, "resultsExistDryRunWarning", resultId));
194
762
  log.info("\n" + m(lang, "dryRunNotice"));
195
763
  if (options.json) {
196
764
  console.log(JSON.stringify(maskDeep(buildPayload("dry-run", [], new Map(), new Map(), 0))));
@@ -200,9 +768,9 @@ async function main() {
200
768
  // 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
201
769
  // 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
202
770
  // **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
203
- // 那正是 --yes 已經示範過的路。要覆蓋,由使用者自己清掉目錄。
204
- if (outDirDirty) {
205
- throw new DispatchError(m(lang, "outDirNotEmptyAbort", outDir), 5);
771
+ // 那正是 --yes 已經示範過的路。要覆蓋,由使用者自己把那筆結果從 DB 刪掉。
772
+ if (alreadyRun) {
773
+ throw new DispatchError(m(lang, "resultsExistAbort", resultId), 5);
206
774
  }
207
775
  if (!options.yes) {
208
776
  const ok = await confirm(m(lang, "confirmPrompt"), options.json ? process.stderr : process.stdout);
@@ -214,27 +782,21 @@ async function main() {
214
782
  return;
215
783
  }
216
784
  }
217
- let outDirReady = true;
218
- try {
219
- await ensureOutDir(outDir);
220
- }
221
- catch (err) {
222
- outDirReady = false;
223
- console.error(m(lang, "outDirNotWritable", outDir));
224
- console.error(maskString(String(err)));
225
- }
226
- const runLog = outDirReady ? new RunLogWriter(path.join(outDir, "run.jsonl"), lang) : null;
227
785
  const semaphore = new Semaphore(options.concurrency);
228
786
  const onEvent = (event) => {
229
787
  log.info(formatEvent(event, lang));
230
- runLog?.append(event);
788
+ // B(gaps #6):這串事件的第二個消費者。`RunLogWriter` 曾經是它(寫 run.jsonl),
789
+ // 票 B 退場之後 DB 這側就沒人接,於是跑的過程中完全沒有可查的進度。
790
+ // recordRound 自己吞掉所有例外——進度查不到,也不該弄死一次已付費的派工。
791
+ if (event.type === "round")
792
+ recordRound(db, resultId, event.agent, event.round, event.usage, event.hasToolCalls);
231
793
  };
232
794
  // §10 步驟 8:信號量控制發起,Promise.allSettled 只負責收尾
233
795
  const settled = await Promise.allSettled(spokes.map(async (spoke) => {
234
796
  await semaphore.acquire();
235
797
  try {
236
- const adapter = createAdapterFor(spoke);
237
- const result = await runSpoke(spoke, adapter, ticketDir, {
798
+ const adapter = createAdapterFor(spoke, db);
799
+ const result = await runSpoke(spoke, adapter, ticket.ticketDir, {
238
800
  repoRoot,
239
801
  timeoutMs: options.timeoutSec * 1000,
240
802
  retries: options.retries,
@@ -250,8 +812,13 @@ async function main() {
250
812
  });
251
813
  // §13(一):每支 spoke 完成即落檔,不等 Promise.allSettled——多 spoke 並行、
252
814
  // 其中一支慢很多時,快的那支已付費的產出不因慢的還在跑而暴露在中斷風險下。
253
- if (outDirReady) {
254
- await persistSpokeResult(outDir, result, (msg) => console.error(msg), lang);
815
+ try {
816
+ persistSpokeResultDb(db, resultId, result);
817
+ }
818
+ catch (err) {
819
+ // 落檔失敗不得把已付費 result 變成 allSettled 的 rejected 假結果;結算後的
820
+ // 重試迴圈會再以同一份真實 result 補寫。仍須以 stderr 留痕,不能靜默吞掉。
821
+ console.error(m(lang, "dbResultPersistenceFailed", result.agent, maskString(String(err))));
255
822
  }
256
823
  return result;
257
824
  }
@@ -262,8 +829,8 @@ async function main() {
262
829
  const results = settled.map((s, i) => {
263
830
  if (s.status === "fulfilled")
264
831
  return s.value;
265
- // §13:spoke 本身的錯誤已在 runSpoke 內部收斂為 status:"failed";這裡是防禦,
266
- // 理論上不該發生(runSpoke 不對外拋錯)。
832
+ // §13:runSpoke 會把已知 spoke 錯誤收斂為 status:"failed";這裡仍保留給 adapter
833
+ // 建立等 runSpoke 之外的例外。每支的 DB 落檔錯誤已在上方接住並保留真實 result。
267
834
  const spoke = spokes[i];
268
835
  return {
269
836
  agent: spoke.agent,
@@ -304,21 +871,11 @@ async function main() {
304
871
  }));
305
872
  // §10 步驟 10:落檔 + stdout 摘要。這裡的迴圈定位已改變(v2.4 §13 規格二):
306
873
  // per-spoke 落檔已在 spokes.map 內完成,此處是「落檔失敗的重試」,不再是
307
- // rejected 分支的兜底——真實 result 重寫一次是冪等的(writeFile 覆蓋)。
308
- if (outDirReady) {
309
- for (const r of results) {
310
- await persistSpokeResult(outDir, r, (msg) => console.error(msg), lang);
311
- }
312
- await writeSummary(outDir, ticketId, results, audits, toolCallAudits, lang);
313
- await runLog?.flush();
314
- log.info("\n" + m(lang, "outDirWritten", outDir));
315
- }
316
- else {
317
- // 落檔目錄不可寫時,不讓已付費的結果消失——完整結果改印到 stderr(json 模式下
318
- // stdout 仍須維持只有最後那個單一 JSON 物件的契約,不能把這份診斷用資料混進去)。
319
- log.info("\n" + m(lang, "outDirFallbackStderr"));
320
- console.error(JSON.stringify(maskDeep(results), null, 2));
321
- }
874
+ // rejected 分支的兜底——真實 result 的完全相同重寫由 DB helper 視為冪等。
875
+ for (const r of results)
876
+ persistSpokeResultDb(db, resultId, r);
877
+ writeSummaryDb(db, resultId, results, audits, toolCallAudits, lang);
878
+ log.info("\n" + m(lang, "resultsSaved", resultId));
322
879
  const allFailed = results.every((r) => r.status === "failed");
323
880
  const exitCode = allFailed ? 4 : 0;
324
881
  if (allFailed) {
@@ -331,12 +888,34 @@ async function main() {
331
888
  log.info("\n" + buildStdoutSummary(results, lang));
332
889
  }
333
890
  }
334
- main().catch((err) => {
335
- if (err instanceof DispatchError) {
336
- console.error(maskString(err.message));
337
- process.exitCode = err.exitCode;
338
- return;
891
+ // 熱修補(票 key-visibility 驗收):這個守衛存在的理由是 B-0——測試要 import
892
+ // `executeKeyTest` 而不觸發 main()。但它原本用 `path.resolve` 比對,而 **resolve 不解析
893
+ // symlink**,`import.meta.url` 卻是 Node 解析過的真實路徑。於是經由 symlink 呼叫時兩邊
894
+ // 永遠不相等,main() 不執行——**CLI 靜默什麼都不做,還 exit 0**。
895
+ //
896
+ // 那正是 `npm install -g dowafu`(README 教的安裝方式)的形態:npm 在 node_modules/.bin
897
+ // 建的是 symlink。daemon 與 MCP stdio 都用絕對真實路徑 spawn,pnpm 的 shim 傳的又是帶 `..`
898
+ // 的相對路徑(resolve 後恰好相等),所以三個現有驗證面全都測不到它。
899
+ //
900
+ // realpathSync 會 throw(檔案不存在、權限不足),那時退回原本的 resolve 比對——
901
+ // 判斷不出來就寧可執行,不要靜默不執行。
902
+ function isDirectExecution(entry) {
903
+ const self = fileURLToPath(import.meta.url);
904
+ try {
905
+ return realpathSync(entry) === self;
906
+ }
907
+ catch {
908
+ return path.resolve(entry) === self;
339
909
  }
340
- console.error(maskString(String(err)));
341
- process.exitCode = 1;
342
- });
910
+ }
911
+ if (process.argv[1] && isDirectExecution(process.argv[1])) {
912
+ main().catch((err) => {
913
+ if (err instanceof DispatchError) {
914
+ console.error(maskString(err.message));
915
+ process.exitCode = err.exitCode;
916
+ return;
917
+ }
918
+ console.error(maskString(String(err)));
919
+ process.exitCode = 1;
920
+ });
921
+ }