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/report.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // 實際並行數,靜態估算無法精確預測。
4
4
  import { stripFrontmatter } from "./ticket.js";
5
5
  import { FIXED_CLOSING_LINE, FIXED_CLOSING_LINE_EN } from "./audit.js";
6
+ import { estimateHardCapUsd } from "./cost.js";
6
7
  import { m } from "./messages.js";
7
8
  export function effectiveCap(spoke, cli) {
8
9
  return spoke.providerConfig.maxSpokeTokens ?? cli.maxSpokeTokens;
@@ -11,9 +12,7 @@ export function effectiveCharsPerToken(spoke, cli) {
11
12
  return spoke.providerConfig.charsPerToken ?? cli.charsPerToken;
12
13
  }
13
14
  function formatProvidersSource(src, lang) {
14
- if (src.kind === "bundled")
15
- return m(lang, "providersBundled", src.formatVersion);
16
- return m(lang, "providersExplicit", src.path ?? "", src.formatVersion);
15
+ return m(lang, "providersDatabase");
17
16
  }
18
17
  // v1.10 §10:三態不得合併——「未忽略」是需要處理的狀態,「無法判定」不是,措辭須區分。
19
18
  //
@@ -21,13 +20,16 @@ function formatProvidersSource(src, lang) {
21
20
  // 已訂輸出目錄屬呼叫端 cwd,checkGitignore 檢查的也是 cwd 所屬的 repo——cwd 與
22
21
  // --repo-root 指的目標專案是兩回事(v1.11 §10 修的正是這個基準不一致)。「目標專案」
23
22
  // 這個詞在 cwd ≠ repoRoot 時會指錯對象,故改為不預設兩者相同的措辭。
24
- function formatGitignoreWarning(status, outDir, lang) {
23
+ // 票 cost-cap §E #1:先前檢查的是 outDir(`db:<id>` 這種假路徑,git 永遠判不出結果,
24
+ // 於是被寫死成 "unknown"、印出編出來的理由)。產物已搬進 DB(v1.11 §24.5),真正該檢查
25
+ // 的是資料庫檔案本身的位置——呼叫端(cli.ts)改傳實際解析出的 DB 路徑。
26
+ function formatGitignoreWarning(status, dbPath, lang) {
25
27
  if (status === "ignored")
26
28
  return null;
27
29
  if (status === "not_ignored") {
28
- return m(lang, "gitignoreNotIgnored", outDir);
30
+ return m(lang, "gitignoreNotIgnored", dbPath);
29
31
  }
30
- return m(lang, "gitignoreUnknown", outDir);
32
+ return m(lang, "gitignoreUnknown", dbPath);
31
33
  }
32
34
  // plan_i18n_v1.2.md §6.3:呼叫前提示 lens 收尾句落在哪一個 FIXED_CLOSING_LINE*——只提示不擋
33
35
  // (同既有 gitignore 警告),讓 lens 檔案本身的收尾句指示(如「回報最後一行固定為:...」)
@@ -63,13 +65,12 @@ function formatLensClosingLineNotice(spoke, lang) {
63
65
  // **這一行讓那個檔不必被找到。** 順帶也解決 `--providers` 覆寫之後 hub 讀到錯的那一份。
64
66
  // 缺價目時明講「無價目資料」而不是印 0——與 cost.ts 的 null 語意一致(§4:「無法估算」
65
67
  // 與「估算出來是零」不是同一件事)。
66
- function formatModelPricing(spoke, lang) {
67
- const pricing = spoke.providerConfig.pricing?.[spoke.model];
68
+ export function formatModelPricing(pricing, pricingAsOf, lang) {
68
69
  if (!pricing)
69
- return m(lang, "modelPricingMissing", spoke.model);
70
+ return m(lang, "noPricingData");
70
71
  const cached = pricing.cachedInputPerM !== undefined ? m(lang, "modelPricingCachedSuffix", pricing.cachedInputPerM) : "";
71
- const asOf = spoke.providerConfig.pricingAsOf
72
- ? m(lang, "modelPricingAsOfSuffix", spoke.providerConfig.pricingAsOf)
72
+ const asOf = pricingAsOf
73
+ ? m(lang, "modelPricingAsOfSuffix", pricingAsOf)
73
74
  : "";
74
75
  return m(lang, "modelPricing", pricing.inputPerM, pricing.outputPerM, cached, asOf);
75
76
  }
@@ -88,7 +89,19 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
88
89
  `[${spoke.providerConfig.api}] effort=${spoke.effort ?? "—"} lang=${spoke.lang} ` +
89
90
  `store=${spoke.providerConfig.store === false ? "false" : "n/a"} ` +
90
91
  `est. ${est.toLocaleString()} cap ${cap.toLocaleString()}`);
91
- lines.push(` ${formatModelPricing(spoke, lang)}`);
92
+ const pricing = spoke.providerConfig.pricing?.[spoke.model];
93
+ lines.push(` ${pricing ? formatModelPricing(pricing, spoke.providerConfig.pricingAsOf, lang) : m(lang, "modelPricingMissing", spoke.model)}`);
94
+ if (spoke.allowedReadsRelative.length === 0) {
95
+ lines.push(m(lang, "allowedReadsListEmpty"));
96
+ }
97
+ else {
98
+ for (const path of spoke.allowedReadsRelative.slice(0, 8)) {
99
+ lines.push(m(lang, "allowedReadsListEntry", path));
100
+ }
101
+ const remaining = spoke.allowedReadsRelative.length - 8;
102
+ if (remaining > 0)
103
+ lines.push(m(lang, "allowedReadsListFolded", remaining));
104
+ }
92
105
  const closingLineNotice = formatLensClosingLineNotice(spoke, lang);
93
106
  if (closingLineNotice)
94
107
  lines.push(` ${closingLineNotice}`);
@@ -134,6 +147,17 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
134
147
  lines.push(m(lang, "sequentialReadCostNote"));
135
148
  }
136
149
  lines.push(m(lang, "worstCaseTotal", worstTotal.toLocaleString()));
150
+ // 票 cost-cap §B/§C:各 spoke 的絕對上界(B-2 算式)加總,不是取最大(B-3 #2)。
151
+ // 缺任一 spoke 的價目資料就整體回 null(不得把「不知道」的那部分當 $0 相加)。
152
+ const hardCapParts = spokes.map((spoke) => {
153
+ const est = estByAgent.get(spoke.agent) ?? 0;
154
+ const pricing = spoke.providerConfig.pricing?.[spoke.model];
155
+ return estimateHardCapUsd(est, cli.maxToolCalls, pricing);
156
+ });
157
+ const totalHardCapUsd = hardCapParts.some((p) => p === null)
158
+ ? null
159
+ : hardCapParts.reduce((s, p) => s + p, 0);
160
+ lines.push(totalHardCapUsd !== null ? m(lang, "hardCapLine", totalHardCapUsd.toFixed(4)) : m(lang, "hardCapMissingPricing"));
137
161
  lines.push(m(lang, "concurrencyLine", cli.concurrency));
138
162
  const byProvider = new Map();
139
163
  for (const spoke of spokes) {
@@ -149,13 +173,13 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
149
173
  lines.push(m(lang, "tpmPeakCaveat"));
150
174
  }
151
175
  const allowedCount = spokes.reduce((s, spoke) => s + spoke.allowedReadsResolved.length, 0);
152
- lines.push(m(lang, "allowedReadsSummary", allowedCount, outDir));
176
+ lines.push(m(lang, "allowedReadsSummary", allowedCount));
153
177
  // 2026-08-15:待審段落疑似被切斷的警告,排在 gitignore 警告之前——後者關係到產物落在哪,
154
178
  // 前者關係到這次派工有沒有意義(付全額、審半份)。
155
179
  if (meta.strayHeadings.length > 0) {
156
180
  lines.push(m(lang, "strayHeadingsWarning", meta.reviewTextChars, meta.strayHeadings));
157
181
  }
158
- const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir, lang);
182
+ const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, meta.dbPath ?? outDir, lang);
159
183
  if (gitignoreWarning)
160
184
  lines.push(gitignoreWarning);
161
185
  return lines.join("\n");
package/dist/runner.js CHANGED
@@ -26,6 +26,24 @@ function sleep(ms) {
26
26
  async function executeToolCall(call, spoke, repoRoot) {
27
27
  const requestedPath = typeof call.args.path === "string" ? call.args.path : "";
28
28
  const startedAt = Date.now();
29
+ // D1: DB tickets carry a content snapshot. Do this before checkAllowlist so no
30
+ // runtime path resolution or file read can leak back into the ticket workflow.
31
+ if (spoke.readableContent) {
32
+ const stored = spoke.readableContent.get(requestedPath);
33
+ if (stored === undefined) {
34
+ return {
35
+ resultText: allowlistRejectMessage(spoke.lang),
36
+ log: { path: requestedPath, allowed: false, reason: "not_in_allowlist", startedAt, durationMs: Date.now() - startedAt },
37
+ };
38
+ }
39
+ const buf = Buffer.from(stored, "utf8");
40
+ let content = stored;
41
+ if (buf.byteLength > MAX_FILE_BYTES) {
42
+ const suffix = spoke.lang === "en" ? FILE_TRUNCATED_SUFFIX_EN : FILE_TRUNCATED_SUFFIX;
43
+ content = buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + suffix;
44
+ }
45
+ return { resultText: content, log: { path: requestedPath, allowed: true, startedAt, durationMs: Date.now() - startedAt } };
46
+ }
29
47
  const check = checkAllowlist(spoke.allowSet, requestedPath, repoRoot);
30
48
  if (!check.allowed) {
31
49
  return {
@@ -0,0 +1,195 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { DispatchError } from "./types.js";
5
+ import { m } from "./messages.js";
6
+ import { now } from "./db.js";
7
+ import { parseAgentTicket, parseDispatchTable } from "./ticket.js";
8
+ import { isModelEnabled, loadProviders } from "./providers-store.js";
9
+ import { getCommandName } from "./pkg-info.js";
10
+ import { formatModelPricing } from "./report.js";
11
+ import { DEFAULT_MAX_TOOL_CALLS, estimateHardCapUsd } from "./cost.js";
12
+ function parseDoc(raw, name, lang) {
13
+ try {
14
+ const value = JSON.parse(raw);
15
+ if (!value || typeof value !== "object" || !Array.isArray(value.spokes) || typeof value.shared !== "string")
16
+ throw new Error();
17
+ return value;
18
+ }
19
+ catch {
20
+ throw new DispatchError(m(lang, "ticketCorrupt", name), 2);
21
+ }
22
+ }
23
+ export function getTicketDoc(db, name, lang) {
24
+ const row = db.prepare("SELECT doc FROM tickets WHERE name = ?").get(name);
25
+ if (!row?.doc)
26
+ throw new DispatchError(m(lang, "ticketNotFound", name), 2);
27
+ return parseDoc(row.doc, name, lang);
28
+ }
29
+ function put(db, name, doc) {
30
+ db.prepare("UPDATE tickets SET doc = ? WHERE name = ?").run(JSON.stringify(doc), name);
31
+ }
32
+ export function createTicket(db, name, shared, lang) {
33
+ if (!name || name.includes(path.sep) || name === "." || name === "..")
34
+ throw new DispatchError(m(lang, "ticketNameInvalid", name), 2);
35
+ try {
36
+ db.prepare("INSERT INTO tickets (name, doc, created_at) VALUES (?, ?, ?)").run(name, JSON.stringify({ shared, spokes: [] }), now());
37
+ }
38
+ catch {
39
+ throw new DispatchError(m(lang, "ticketAlreadyExists", name), 2);
40
+ }
41
+ }
42
+ export function addSpoke(db, name, spoke, lang) {
43
+ const doc = getTicketDoc(db, name, lang);
44
+ if (doc.spokes.some((item) => item.agent === spoke.agent))
45
+ throw new DispatchError(m(lang, "duplicateAgentInDispatchTable", spoke.agent, 2), 2);
46
+ parseAgentTicket(spoke.body, lang);
47
+ const providers = loadProviders(db, lang);
48
+ // 熱修補(缺口三):表整個是空的時候,「白名單為空」這句話會把人帶去查白名單,
49
+ // 而該做的是把出貨檔匯進來。兩種成因要分開講。
50
+ if (Object.keys(providers).length === 0) {
51
+ throw new DispatchError(m(lang, "providersDbEmpty", getCommandName()), 2);
52
+ }
53
+ const provider = providers[spoke.provider];
54
+ if (!provider || !provider.models.includes(spoke.model)) {
55
+ throw new DispatchError(m(lang, "modelNotWhitelisted", spoke.agent, spoke.model, spoke.provider, provider?.models.join(", ") ?? ""), 2);
56
+ }
57
+ if (!isModelEnabled(db, spoke.provider, spoke.model))
58
+ throw new DispatchError(m(lang, "modelDisabled", spoke.model, getCommandName()), 6);
59
+ doc.spokes.push({ ...spoke, allow: [] });
60
+ put(db, name, doc);
61
+ }
62
+ function isUnderDocs(repoRoot, target) {
63
+ const docs = path.resolve(repoRoot, "_docs");
64
+ return target === docs || target.startsWith(docs + path.sep);
65
+ }
66
+ export async function addAllow(db, name, agent, rel, repoRoot, lang) {
67
+ const doc = getTicketDoc(db, name, lang);
68
+ const spoke = doc.spokes.find((item) => item.agent === agent);
69
+ if (!spoke)
70
+ throw new DispatchError(m(lang, "ticketSpokeNotFound", name, agent), 2);
71
+ const resolved = path.resolve(repoRoot, rel);
72
+ if (isUnderDocs(repoRoot, resolved))
73
+ throw new DispatchError(m(lang, "allowedReadsUnderDocs", agent, rel), 2);
74
+ try {
75
+ fs.realpathSync(resolved);
76
+ }
77
+ catch {
78
+ throw new DispatchError(m(lang, "allowedReadsPathNotFound", agent, rel), 2);
79
+ }
80
+ const content = await readFile(resolved, "utf8");
81
+ const i = spoke.allow.findIndex((item) => item.path === rel);
82
+ if (i >= 0)
83
+ spoke.allow[i] = { path: rel, content };
84
+ else
85
+ spoke.allow.push({ path: rel, content });
86
+ put(db, name, doc);
87
+ }
88
+ export async function importTicketDirectory(db, dir, name, repoRoot, lang) {
89
+ const dispatch = await readFile(path.join(dir, "_dispatch.md"), "utf8");
90
+ const shared = await readFile(path.join(dir, "_shared.md"), "utf8");
91
+ const rows = parseDispatchTable(dispatch, lang);
92
+ createTicket(db, name, shared, lang);
93
+ try {
94
+ for (const row of rows) {
95
+ const body = await readFile(path.join(dir, `${row.agent}.md`), "utf8");
96
+ addSpoke(db, name, { ...row, body }, lang);
97
+ const parsed = parseAgentTicket(body, lang);
98
+ for (const rel of parsed.allowedReads)
99
+ await addAllow(db, name, row.agent, rel, repoRoot, lang);
100
+ }
101
+ }
102
+ catch (err) {
103
+ db.prepare("DELETE FROM tickets WHERE name = ?").run(name);
104
+ throw err;
105
+ }
106
+ }
107
+ function getTicketPricing(db, provider, model) {
108
+ const row = db.prepare(`SELECT p.chars_per_token, p.pricing_source, m.input_per_m, m.cached_input_per_m, m.cache_write_per_m, m.output_per_m
109
+ FROM providers p LEFT JOIN models m ON m.provider = p.name AND m.model = ?
110
+ WHERE p.name = ?`).get(model, provider);
111
+ if (!row)
112
+ return { charsPerToken: null, pricing: undefined, pricingAsOf: undefined };
113
+ const hasPrice = row.input_per_m !== null && row.input_per_m !== undefined && row.output_per_m !== null && row.output_per_m !== undefined;
114
+ let pricingAsOf;
115
+ try {
116
+ const source = row.pricing_source === null ? null : JSON.parse(String(row.pricing_source));
117
+ if (source && typeof source === "object" && typeof source.asOf === "string") {
118
+ pricingAsOf = source.asOf;
119
+ }
120
+ }
121
+ catch {
122
+ // A ticket summary must still disclose that this model lacks usable price metadata.
123
+ }
124
+ return {
125
+ charsPerToken: row.chars_per_token === null || row.chars_per_token === undefined ? null : Number(row.chars_per_token),
126
+ pricing: hasPrice
127
+ ? {
128
+ inputPerM: Number(row.input_per_m),
129
+ outputPerM: Number(row.output_per_m),
130
+ ...(row.cached_input_per_m === null || row.cached_input_per_m === undefined ? {} : { cachedInputPerM: Number(row.cached_input_per_m) }),
131
+ ...(row.cache_write_per_m === null || row.cache_write_per_m === undefined ? {} : { cacheWritePerM: Number(row.cache_write_per_m) }),
132
+ }
133
+ : undefined,
134
+ pricingAsOf,
135
+ };
136
+ }
137
+ // 票 cost-cap §B 的估算輸入沿用「估算 X tokens」那個既有數字(允許清單內容大小,不是真正
138
+ // 的 systemPrompt+firstUserText)——formatTicket 純吃 DB doc,沒有 repoRoot/lens 檔案系統
139
+ // 可讀,算不出 report.ts 那份真正的初始 prompt 估算。兩邊金額因此不會精確相等(這裡通常較大、
140
+ // 較保守),使用者已裁示接受這個落差(2026-09-06,「這估算都是高估為主的參考值」「本來就
141
+ // 不可能精確的一個值」)。
142
+ function estimateSpokeHardCapUsd(db, spoke, maxToolCalls) {
143
+ const chars = spoke.allow.reduce((sum, item) => sum + item.content.length, 0);
144
+ const details = getTicketPricing(db, spoke.provider, spoke.model);
145
+ const estimatedTokens = Math.ceil(chars / (details.charsPerToken ?? 1));
146
+ return estimateHardCapUsd(estimatedTokens, maxToolCalls, details.pricing);
147
+ }
148
+ // 工單 approve-tool §A-1 之二:硬上限的數值出口——formatTicket 與 MCP 的 dispatch_approve
149
+ // 共用同一份算法,不得各算各的(否則「摘要上寫 $1.77、核准時卻說金額不符」,使用者無從判斷
150
+ // 誰對)。各 spoke 相加,缺任一 spoke 的價目資料就整體回 null,不得把「不知道」當 $0 相加。
151
+ export function getTicketHardCapUsd(db, name, lang, maxToolCalls = DEFAULT_MAX_TOOL_CALLS) {
152
+ const doc = getTicketDoc(db, name, lang);
153
+ const parts = doc.spokes.map((spoke) => estimateSpokeHardCapUsd(db, spoke, maxToolCalls));
154
+ return parts.some((p) => p === null) ? null : parts.reduce((sum, p) => sum + p, 0);
155
+ }
156
+ export function formatTicket(db, name, lang, maxToolCalls = DEFAULT_MAX_TOOL_CALLS) {
157
+ const doc = getTicketDoc(db, name, lang);
158
+ const lines = [m(lang, "ticketShowHeader", name)];
159
+ for (const spoke of doc.spokes) {
160
+ const chars = spoke.allow.reduce((sum, item) => sum + item.content.length, 0);
161
+ const details = getTicketPricing(db, spoke.provider, spoke.model);
162
+ const estimatedTokens = Math.ceil(chars / (details.charsPerToken ?? 1));
163
+ lines.push(` ${spoke.agent} ${spoke.provider}/${spoke.model} allow=${spoke.allow.map((a) => a.path).join(", ") || "—"} ${m(lang, "ticketEstimateTokens", estimatedTokens)}`);
164
+ lines.push(` ${details.pricing ? formatModelPricing(details.pricing, details.pricingAsOf, lang) : m(lang, "modelPricingMissing", spoke.model)}`);
165
+ if (!details.pricing)
166
+ lines.push(` ${m(lang, "noPricingData")}`);
167
+ }
168
+ const reruns = db.prepare(`SELECT count(*) AS runs, sum(cost_usd) AS total, max(finished_at) AS last
169
+ FROM jobs WHERE ticket = ? AND status = 'completed'`).get(name);
170
+ if (Number(reruns.runs) > 0) {
171
+ if (reruns.total === null)
172
+ lines.push(m(lang, "ticketCompletedRunsNoPricing", Number(reruns.runs), reruns.last ?? "—"));
173
+ else
174
+ lines.push(m(lang, "ticketCompletedRuns", Number(reruns.runs), reruns.total.toFixed(4), reruns.last ?? "—"));
175
+ }
176
+ const totalHardCapUsd = getTicketHardCapUsd(db, name, lang, maxToolCalls);
177
+ if (totalHardCapUsd !== null) {
178
+ // C-2:新工單(runs=0)或歷史 completed 全無可用價目資料(total===null)時,「典型」
179
+ // 整段不出現,只印硬上限——不留任何代表 0 的佔位符。
180
+ const runs = Number(reruns.runs);
181
+ if (runs > 0 && reruns.total !== null) {
182
+ lines.push(m(lang, "hardCapWithTypicalLine", (reruns.total / runs).toFixed(4), runs, totalHardCapUsd.toFixed(4)));
183
+ }
184
+ else {
185
+ lines.push(m(lang, "hardCapLine", totalHardCapUsd.toFixed(4)));
186
+ }
187
+ }
188
+ else {
189
+ lines.push(m(lang, "hardCapMissingPricing"));
190
+ }
191
+ return lines.join("\n");
192
+ }
193
+ export function hasResults(db, ticket) {
194
+ return Boolean(db.prepare("SELECT 1 FROM results WHERE job_id = ? LIMIT 1").get(ticket));
195
+ }
package/dist/ticket.js CHANGED
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { DispatchError } from "./types.js";
6
6
  import { m } from "./messages.js";
7
+ import { getTicketDoc } from "./ticket-store.js";
7
8
  const FORMAT_MARKER = "<!-- format: v1 -->";
8
9
  function splitTableRow(line) {
9
10
  const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
@@ -167,7 +168,7 @@ export function parseAgentTicket(markdown, lang) {
167
168
  return { questions, allowedReads };
168
169
  }
169
170
  // 檔案系統存取層:讀工單目錄、組出完整 Ticket。
170
- export async function loadTicket(ticketDir, lang) {
171
+ async function loadTicketDirectory(ticketDir, lang) {
171
172
  const dispatchPath = path.join(ticketDir, "_dispatch.md");
172
173
  const sharedPath = path.join(ticketDir, "_shared.md");
173
174
  let dispatchText;
@@ -200,3 +201,23 @@ export async function loadTicket(ticketDir, lang) {
200
201
  }
201
202
  return { ticketDir, rows, shared, perAgent };
202
203
  }
204
+ export async function loadTicket(source, nameOrLang, maybeLang) {
205
+ if (typeof source === "string")
206
+ return loadTicketDirectory(source, nameOrLang);
207
+ const name = nameOrLang;
208
+ const lang = maybeLang;
209
+ const doc = getTicketDoc(source, name, lang);
210
+ const shared = parseSharedDoc(doc.shared, lang);
211
+ const rows = doc.spokes.map(({ agent, provider, model, effort }) => ({ agent, provider, model, effort }));
212
+ const perAgent = new Map();
213
+ const allowedContent = new Map();
214
+ const ticketDir = `ticket/${name}`;
215
+ allowedContent.set(`${ticketDir}/_shared.md`, doc.shared);
216
+ for (const spoke of doc.spokes) {
217
+ perAgent.set(spoke.agent, parseAgentTicket(spoke.body, lang));
218
+ allowedContent.set(`${ticketDir}/${spoke.agent}.md`, spoke.body);
219
+ for (const allowed of spoke.allow)
220
+ allowedContent.set(allowed.path, allowed.content);
221
+ }
222
+ return { ticketDir, ticketName: name, rows, shared, perAgent, allowedContent };
223
+ }
package/dist/validate.js CHANGED
@@ -8,9 +8,7 @@ import { DispatchError } from "./types.js";
8
8
  import { stripFrontmatter } from "./ticket.js";
9
9
  import { buildAllowSet } from "./whitelist.js";
10
10
  import { m } from "./messages.js";
11
- function apiKeyEnvFor(provider) {
12
- return `${provider.toUpperCase()}_API_KEY`;
13
- }
11
+ import { getCommandName } from "./pkg-info.js";
14
12
  async function readAgentBody(agentsDir, agent, lang) {
15
13
  const agentDefPath = path.join(agentsDir, `${agent}.md`);
16
14
  let text;
@@ -29,22 +27,38 @@ function isUnderDocsDir(repoRoot, resolved) {
29
27
  export async function resolveSpokes(ticket, providers, repoRoot,
30
28
  // plan_i18n_v1.3.md §一之3 第 3 點:必填、不得有預設值——有預設值的話呼叫端漏傳會
31
29
  // 靜默回退成舊來源,連紅字都沒有,等同「傳了但傳的是舊來源」那種型別系統看不見的錯誤。
32
- lang, agentsDir = path.join(repoRoot, ".claude", "agents")) {
30
+ lang,
31
+ // 熱修補:本參數原本有 `= () => true` 的預設值,於是 cli.ts 的接線被拿掉之後
32
+ // 463 條測試全綠——「函式對」不代表「有人在對的時機叫它」。上面那段講 lang 的理由
33
+ // 一字不改地適用於這裡,故改為必填並移到 agentsDir 之前(必填參數不得跟在選填之後)。
34
+ isModelEnabled,
35
+ // 同上:必填、不得有預設值。0.5.0 起 API key 的唯一來源是 DB 的 `provider_keys` 表,
36
+ // 而本模組不持有 db handle——比照 isModelEnabled 收一個回呼,由 cli.ts 接上。
37
+ // 原本這裡直接讀 `process.env[`${PROVIDER}_API_KEY`]`,key 搬進 DB 之後那段就成了
38
+ // 一道「更早的、只認環境變數」的閘門,會在 createAdapterFor 之前先攔下所有派工。
39
+ hasApiKey, agentsDir = path.join(repoRoot, ".claude", "agents")) {
33
40
  const resolved = [];
41
+ // 熱修補(缺口三):同 ticket-store——空表要講「去匯入」,
42
+ // 不要讓 providerUndefinedInRow 把人帶去查工單裡的 provider 名稱。
43
+ if (Object.keys(providers).length === 0) {
44
+ throw new DispatchError(m(lang, "providersDbEmpty", getCommandName()), 2);
45
+ }
34
46
  for (const row of ticket.rows) {
35
47
  const providerConfig = providers[row.provider];
36
48
  if (!providerConfig) {
37
49
  throw new DispatchError(m(lang, "providerUndefinedInRow", row.agent, row.provider), 2);
38
50
  }
39
51
  // §18:只檢查本次工單用到的 provider,缺即中止。
40
- const envName = apiKeyEnvFor(row.provider);
41
- if (!process.env[envName]) {
42
- throw new DispatchError(m(lang, "missingEnvVar", envName, row.agent, row.provider), 2);
52
+ if (!hasApiKey(row.provider)) {
53
+ throw new DispatchError(m(lang, "apiKeyNotConfigured", row.agent, row.provider, getCommandName()), 2);
43
54
  }
44
- // §5:model 須在 providers.json 的 models 白名單內;白名單為空 = 不做型號檢查。
45
- if (providerConfig.models.length > 0 && !providerConfig.models.includes(row.model)) {
55
+ // A-1: parser guarantees this whitelist is non-empty; never leave a bypass.
56
+ if (!providerConfig.models.includes(row.model)) {
46
57
  throw new DispatchError(m(lang, "modelNotWhitelisted", row.agent, row.model, row.provider, providerConfig.models.join(", ")), 2);
47
58
  }
59
+ if (!isModelEnabled(row.provider, row.model)) {
60
+ throw new DispatchError(m(lang, "modelDisabled", row.model, getCommandName()), 6);
61
+ }
48
62
  // §4:effort 填了但不在該 provider 的 allowed 內即中止(含 allowed 為空陣列)。
49
63
  if (row.effort !== undefined && !providerConfig.reasoning.allowed.includes(row.effort)) {
50
64
  const list = providerConfig.reasoning.allowed.length > 0
@@ -65,24 +79,45 @@ lang, agentsDir = path.join(repoRoot, ".claude", "agents")) {
65
79
  }
66
80
  // §4「針對 hub 會寫錯」:_docs/ 一律拒絕;允許清單逐一驗證存在,任一不存在即中止並指名。
67
81
  const allowedReadsResolved = [];
68
- for (const rel of agentTicket.allowedReads) {
69
- const resolvedPath = path.resolve(repoRoot, rel);
70
- if (isUnderDocsDir(repoRoot, resolvedPath)) {
71
- throw new DispatchError(m(lang, "allowedReadsUnderDocs", row.agent, rel), 2);
82
+ const allowedReadsContent = [];
83
+ const readableContent = ticket.allowedContent ? new Map() : undefined;
84
+ if (ticket.allowedContent) {
85
+ const dbContent = readableContent;
86
+ for (const rel of agentTicket.allowedReads) {
87
+ const content = ticket.allowedContent.get(rel);
88
+ if (content === undefined)
89
+ throw new DispatchError(m(lang, "allowedReadsPathNotFound", row.agent, rel), 2);
90
+ allowedReadsResolved.push(rel);
91
+ allowedReadsContent.push(content);
92
+ dbContent.set(rel, content);
72
93
  }
73
- let real;
74
- try {
75
- real = fs.realpathSync(resolvedPath);
76
- }
77
- catch {
78
- throw new DispatchError(m(lang, "allowedReadsPathNotFound", row.agent, rel), 2);
94
+ const sharedKey = `${ticket.ticketDir}/_shared.md`;
95
+ const ownKey = `${ticket.ticketDir}/${row.agent}.md`;
96
+ dbContent.set(sharedKey, ticket.allowedContent.get(sharedKey) ?? "");
97
+ dbContent.set(ownKey, ticket.allowedContent.get(ownKey) ?? "");
98
+ }
99
+ else {
100
+ for (const rel of agentTicket.allowedReads) {
101
+ const resolvedPath = path.resolve(repoRoot, rel);
102
+ if (isUnderDocsDir(repoRoot, resolvedPath)) {
103
+ throw new DispatchError(m(lang, "allowedReadsUnderDocs", row.agent, rel), 2);
104
+ }
105
+ let real;
106
+ try {
107
+ real = fs.realpathSync(resolvedPath);
108
+ }
109
+ catch {
110
+ throw new DispatchError(m(lang, "allowedReadsPathNotFound", row.agent, rel), 2);
111
+ }
112
+ allowedReadsResolved.push(real);
79
113
  }
80
- allowedReadsResolved.push(real);
81
114
  }
82
115
  const agentBody = await readAgentBody(agentsDir, row.agent, lang);
83
116
  const sharedPath = path.join(ticket.ticketDir, "_shared.md");
84
117
  const ownAgentPath = path.join(ticket.ticketDir, `${row.agent}.md`);
85
- const allowSet = buildAllowSet([sharedPath, ownAgentPath, ...allowedReadsResolved]);
118
+ const allowSet = ticket.allowedContent
119
+ ? new Set(ticket.allowedContent.keys())
120
+ : buildAllowSet([sharedPath, ownAgentPath, ...allowedReadsResolved]);
86
121
  resolved.push({
87
122
  agent: row.agent,
88
123
  provider: row.provider,
@@ -94,6 +129,8 @@ lang, agentsDir = path.join(repoRoot, ".claude", "agents")) {
94
129
  allowSet,
95
130
  allowedReadsResolved,
96
131
  allowedReadsRelative: agentTicket.allowedReads,
132
+ allowedReadsContent: ticket.allowedContent ? allowedReadsContent : undefined,
133
+ readableContent,
97
134
  lang,
98
135
  });
99
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Send a section of your design doc to external LLMs for review. They read only the files you whitelist, and nothing is billed until you confirm.",
5
5
  "keywords": [
6
6
  "llm",
@@ -46,7 +46,6 @@
46
46
  "prepublishOnly": "npm run test && npm run lint && npm run typecheck && npm run build"
47
47
  },
48
48
  "dependencies": {
49
- "dotenv": "^16.4.5",
50
49
  "openai": "^4.68.4"
51
50
  },
52
51
  "devDependencies": {
package/providers.json CHANGED
@@ -14,11 +14,11 @@
14
14
  "charsPerToken": 1.0,
15
15
  "tpmLimit": 200000,
16
16
  "maxSpokeTokens": null,
17
- "pricingSource": { "url": "https://developers.openai.com/api/docs/pricing", "asOf": "2026-08-09" },
17
+ "pricingSource": { "url": "https://developers.openai.com/api/docs/pricing", "asOf": "2026-09-02", "note": "gpt-5.6-sol 為優惠價,官方頁載明至 2026-11-21" },
18
18
  "pricing": {
19
19
  "gpt-5.6-luna": { "inputPerM": 0.20, "cachedInputPerM": 0.02, "outputPerM": 1.20 },
20
20
  "gpt-5.6-terra": { "inputPerM": 2.00, "cachedInputPerM": 0.20, "outputPerM": 12.00 },
21
- "gpt-5.6-sol": { "inputPerM": 5.00, "cachedInputPerM": 0.50, "outputPerM": 30.00 }
21
+ "gpt-5.6-sol": { "inputPerM": 4.00, "cachedInputPerM": 0.40, "outputPerM": 20.00 }
22
22
  }
23
23
  },
24
24
  "deepseek": {
@@ -37,8 +37,8 @@
37
37
  "maxSpokeTokens": null,
38
38
  "pricingSource": {
39
39
  "url": "https://api-docs.deepseek.com/quick_start/pricing/",
40
- "asOf": "2026-08-14",
41
- "note": "本表填的是 2026-08-16 16:00 UTC 起生效的尖峰價。在此之前官方仍收舊價(flash 0.0028/0.14/0.28、pro 0.003625/0.435/0.87),所以生效前本表偏高。離峰(尖峰時段 01:00-04:00 與 06:00-10:00 UTC 以外)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。"
40
+ "asOf": "2026-09-02",
41
+ "note": "離峰(尖峰時段 01:00-04:00 與 06:00-10:00 UTC 以外)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。"
42
42
  },
43
43
  "pricing": {
44
44
  "deepseek-v4-flash": { "inputPerM": 0.44, "cachedInputPerM": 0.014, "outputPerM": 1.32 },
@@ -55,15 +55,16 @@
55
55
  "allowed": ["minimal", "low", "medium", "high"],
56
56
  "default": "high"
57
57
  },
58
- "models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash"],
58
+ "models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash"],
59
59
  "charsPerToken": 1.0,
60
60
  "tpmLimit": null,
61
61
  "maxSpokeTokens": null,
62
- "pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-08-09", "note": "文字/圖片/影片價;音訊另計。output 價含 thinking tokens" },
62
+ "pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-09-02", "note": "文字/圖片/影片價;音訊另計。output 價含 thinking tokens。gemini-3.6-flash/gemini-3.7-flash 為優惠價,官方頁載明至 2026-12-31;2027-01-01 起漲為 $1.50 in/$7.50 out(兩者皆是)" },
63
63
  "pricing": {
64
64
  "gemini-3.1-flash-lite": { "inputPerM": 0.25, "cachedInputPerM": 0.025, "outputPerM": 1.50 },
65
65
  "gemini-3.5-flash-lite": { "inputPerM": 0.30, "cachedInputPerM": 0.03, "outputPerM": 2.50 },
66
- "gemini-3.6-flash": { "inputPerM": 1.50, "cachedInputPerM": 0.15, "outputPerM": 7.50 }
66
+ "gemini-3.6-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 },
67
+ "gemini-3.7-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 }
67
68
  }
68
69
  },
69
70
  "anthropic": {
@@ -76,14 +77,15 @@
76
77
  "allowed": ["low", "medium", "high", "xhigh", "max"],
77
78
  "default": "high"
78
79
  },
79
- "models": ["claude-opus-5", "claude-sonnet-5"],
80
+ "models": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"],
80
81
  "charsPerToken": 1.0,
81
82
  "tpmLimit": null,
82
83
  "maxSpokeTokens": null,
83
- "pricingSource": { "url": "https://platform.claude.com/docs/en/about-claude/pricing", "asOf": "2026-08-09", "note": "sonnet-5 為 2026-08-31 前的優惠價,之後為 3.00/15.00" },
84
+ "pricingSource": { "url": "https://platform.claude.com/docs/en/about-claude/pricing", "asOf": "2026-09-02", "note": "claude-sonnet-5 的 $2/$10 原為上市優惠價(原訂 2026-08-31 到期、2026-09-01 漲為 $3/$15);官方頁已載明該調漲不會發生,此價已成常態價。" },
84
85
  "pricing": {
85
86
  "claude-opus-5": { "inputPerM": 5.00, "cachedInputPerM": 0.50, "cacheWritePerM": 6.25, "outputPerM": 25.00 },
86
- "claude-sonnet-5": { "inputPerM": 2.00, "cachedInputPerM": 0.20, "cacheWritePerM": 2.50, "outputPerM": 10.00 }
87
+ "claude-sonnet-5": { "inputPerM": 2.00, "cachedInputPerM": 0.20, "cacheWritePerM": 2.50, "outputPerM": 10.00 },
88
+ "claude-haiku-4-5-20251001": { "inputPerM": 1.00, "cachedInputPerM": 0.10, "cacheWritePerM": 1.25, "outputPerM": 5.00 }
87
89
  }
88
90
  }
89
91
  }