dowafu 0.6.0 → 0.8.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/CHANGELOG.md +133 -0
- package/README.md +42 -9
- package/README_zh-tw.md +35 -9
- package/dist/cli-args.js +20 -4
- package/dist/cli-commands.js +12 -0
- package/dist/cli.js +61 -13
- package/dist/daemon.js +2 -2
- package/dist/db.js +18 -0
- package/dist/doctor.js +5 -3
- package/dist/job.js +8 -2
- package/dist/json-output.js +1 -0
- package/dist/mcp/protocol.js +12 -5
- package/dist/messages.js +32 -6
- package/dist/prompt.js +6 -0
- package/dist/providers-store.js +4 -1
- package/dist/runner.js +10 -1
- package/dist/ticket-store.js +78 -9
- package/dist/ticket.js +16 -3
- package/dist/validate.js +11 -2
- package/package.json +3 -2
- package/providers.json +12 -9
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes per released version. Entries for 0.5.0–0.6.0 were **reconstructed** from
|
|
4
|
+
release commits and internal records after the fact; from 0.7.0 on they are written at
|
|
5
|
+
release time. Versions before 0.5.0 are not covered here — see the git tags (`v0.3.0`
|
|
6
|
+
onwards).
|
|
7
|
+
|
|
8
|
+
## 0.8.0 — 2026-09-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Tickets now have two states: draft and sealed.** `dowafu ticket seal <ticket-id>
|
|
13
|
+
--repo-root <dir>` freezes a ticket, including each reviewer's lens definition as it stands
|
|
14
|
+
at that moment. Sealing needs at least one spoke and every lens file to exist; if anything
|
|
15
|
+
is missing, nothing is sealed. A sealed ticket cannot be edited and cannot be unsealed —
|
|
16
|
+
`dowafu ticket copy <sealed-ticket> <new-id>` gives you a new draft instead.
|
|
17
|
+
- **`dowafu providers import --bundled`** imports the model list shipped with the package
|
|
18
|
+
without needing its path, and always applies the shipped defaults to models you don't have
|
|
19
|
+
yet.
|
|
20
|
+
- `--doctor` prints where the shipped `providers.json` is; `--doctor --json` gains a
|
|
21
|
+
`bundledProvidersPath` field. No existing field changed.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **A draft cannot be run or queued.** Running one exits with code 5 before any spend;
|
|
26
|
+
`dispatch_submit` refuses it without creating a job; `dispatch_approve` and
|
|
27
|
+
`dowafu approve` refuse a job whose ticket is a draft. Dry runs work in both states.
|
|
28
|
+
- `dispatch_tickets` shows each ticket's state as a fixed English `[draft]` or `[sealed]`,
|
|
29
|
+
and a draft's line carries the exact command to seal it.
|
|
30
|
+
- `ticket show` prints the state on its first line; `--json` output gains `ticketStatus`.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **A queued job used the lens definitions of whatever directory the daemon was started
|
|
35
|
+
from.** The worker never received a repository root, so a daemon started in one project
|
|
36
|
+
silently ran another project's ticket with its own same-named lenses. A sealed ticket now
|
|
37
|
+
carries its lenses and never reads `.claude/agents/` at run time.
|
|
38
|
+
- **A ticket could still be changed after its job was approved**, so the worker could run
|
|
39
|
+
content — and a cost — that nobody had approved. Sealed content is frozen.
|
|
40
|
+
|
|
41
|
+
### Upgrading
|
|
42
|
+
|
|
43
|
+
- 🔴 **Every existing ticket becomes a draft.** The database is migrated on first use (schema
|
|
44
|
+
version 5 → 6). Seal a ticket again before dispatching it. A job already in the queue cannot
|
|
45
|
+
be approved while its ticket is a draft.
|
|
46
|
+
- To pick up the model list shipped with a release, run `dowafu providers import --bundled`.
|
|
47
|
+
Upgrading still never rewrites your prices or enabled flags on its own.
|
|
48
|
+
|
|
49
|
+
## 0.7.0 — 2026-09-12
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **`--ticket <name>`** names the ticket explicitly. The positional form still works, and
|
|
54
|
+
giving both is rejected rather than silently resolved. The queue worker now uses the
|
|
55
|
+
flag, which is what makes the fix below reachable for tickets that already exist.
|
|
56
|
+
- **Three models**, each verified against the live API before shipping — tool calling,
|
|
57
|
+
usage on every round, and the model name echoed back unchanged:
|
|
58
|
+
`gpt-6-astra`, `deepseek-flash`, `gemini-3.8-flash`.
|
|
59
|
+
- `dowafu result --help` and `dowafu approve --help` now print their usage. Previously
|
|
60
|
+
they treated `--help` as an id to look up, failed with "no results found", and opened
|
|
61
|
+
the database on the way.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- **`ticket create` and `ticket import` reject names the CLI reserves**: the eight
|
|
66
|
+
subcommands (`ticket`, `providers`, `token`, `key`, `result`, `approve`, `serve`,
|
|
67
|
+
`mcp`), `-h`, `-V`, and any name beginning with `--`. Such a ticket could be created
|
|
68
|
+
before but could never run, because the CLI parsed the name as a command — one named
|
|
69
|
+
`serve` started a daemon instead. Tickets already stored under a reserved name still
|
|
70
|
+
run via `--ticket <name>`.
|
|
71
|
+
- **The deepseek model enabled on a fresh install is now `deepseek-flash`.** DeepSeek
|
|
72
|
+
retired `deepseek-v4-flash` on 2026-09-10 and currently routes it to V4.1 Flash with no
|
|
73
|
+
stated end date; shipping it as the default meant new installs depended on that routing.
|
|
74
|
+
`deepseek-v4-flash` stays on the whitelist, disabled by default. **Existing databases
|
|
75
|
+
are not touched** — this only affects a database seeded after upgrading.
|
|
76
|
+
- Prices for every provider re-checked against the vendor pricing pages on 2026-09-12.
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
|
|
80
|
+
- **The install command printed inside each language pack's own README pointed at the
|
|
81
|
+
`v0.4.0` tag** — unchanged through four releases, so anyone following it fetched
|
|
82
|
+
reviewer definitions three versions old. All four places that carry the tag are now on
|
|
83
|
+
the released version.
|
|
84
|
+
- **A reviewer could not read its own ticket when the CLI ran with `--repo-root` from a
|
|
85
|
+
different working directory.** The ticket paths in its first message came out absolute
|
|
86
|
+
while the snapshot is keyed by a relative path, so every read of the ticket was refused
|
|
87
|
+
— and the audit still reported success. The result was a paid review written without
|
|
88
|
+
ever seeing the plan. Present since 0.5.0.
|
|
89
|
+
|
|
90
|
+
### Upgrading
|
|
91
|
+
|
|
92
|
+
Upgrading the package does not refresh the model list: seeding happens only when the model
|
|
93
|
+
table is empty, so your existing prices and enabled flags are never silently rewritten. To
|
|
94
|
+
pick up the three new models:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dowafu providers import "$(npm root -g)/dowafu/providers.json"
|
|
98
|
+
dowafu providers list
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 0.6.0 — 2026-09-10
|
|
102
|
+
|
|
103
|
+
*Reconstructed.*
|
|
104
|
+
|
|
105
|
+
- A daemon no longer runs jobs that were approved before it started; those are returned to
|
|
106
|
+
pending approval at startup, so a restart cannot silently execute an old approval.
|
|
107
|
+
- `--doctor` reports the job queue.
|
|
108
|
+
- `engines` corrected to `>=22.13.0`.
|
|
109
|
+
|
|
110
|
+
## 0.5.1 — 2026-09-09
|
|
111
|
+
|
|
112
|
+
*Reconstructed.*
|
|
113
|
+
|
|
114
|
+
- Reviewer output no longer carries template placeholders through to the saved report.
|
|
115
|
+
- Queued jobs choose their own language: `dispatch_submit` accepts `lang`.
|
|
116
|
+
|
|
117
|
+
## 0.5.0 — 2026-09-06
|
|
118
|
+
|
|
119
|
+
*Reconstructed. The largest release so far; this is a summary, not a full list.*
|
|
120
|
+
|
|
121
|
+
- **Tickets and reviewer output moved into SQLite.** No ticket directories or output files
|
|
122
|
+
on disk. What a reviewer reads is the snapshot taken when the ticket was written, not
|
|
123
|
+
the file as it stands at run time.
|
|
124
|
+
- **Job queue and a long-running daemon** (`dowafu serve`), with MCP over stdio
|
|
125
|
+
(`dowafu mcp`) and over HTTP bound to `127.0.0.1` only. Inbound tokens are stored
|
|
126
|
+
hashed, compared in constant time, and rate limited per token.
|
|
127
|
+
- **The model whitelist, per-model enabled flags and prices moved into the database**;
|
|
128
|
+
the `--providers` flag was removed. An empty database is seeded from the file shipped
|
|
129
|
+
with the package.
|
|
130
|
+
- **API keys come from the database only** (`dowafu key`); environment variables and
|
|
131
|
+
`.env` support were removed, along with the `dotenv` dependency.
|
|
132
|
+
- **Costs are stated in dollars** before you approve: a hard upper bound, plus the typical
|
|
133
|
+
spend of past runs when there are any.
|
package/README.md
CHANGED
|
@@ -103,6 +103,7 @@ A ticket lives in the database, not on disk. Build one with the CLI:
|
|
|
103
103
|
dowafu ticket create <ticket-id> <shared-file>
|
|
104
104
|
dowafu ticket add-spoke <ticket-id> <agent> <provider> <model> <body-file> [effort]
|
|
105
105
|
dowafu ticket add-allow <ticket-id> <agent> <repo-relative-path> --repo-root .
|
|
106
|
+
dowafu ticket seal <ticket-id> --repo-root .
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
The body argument of `create` and `add-spoke` takes a path — or `-`, which reads it from
|
|
@@ -117,7 +118,7 @@ dowafu ticket create auth-review - <<'EOF'
|
|
|
117
118
|
...
|
|
118
119
|
EOF
|
|
119
120
|
|
|
120
|
-
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-
|
|
121
|
+
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-flash - <<'EOF'
|
|
121
122
|
# Questions
|
|
122
123
|
1. Does the permission check described here hold under concurrent requests?
|
|
123
124
|
|
|
@@ -132,6 +133,15 @@ content as it stands at that moment — dispatch never re-reads it, so what a re
|
|
|
132
133
|
is that snapshot. `dowafu ticket show <ticket-id>` prints the models, the allowlists and
|
|
133
134
|
the estimates.
|
|
134
135
|
|
|
136
|
+
**A ticket must be sealed before it can run.** What you build above is a **draft**: you can
|
|
137
|
+
keep editing it and dry-run it, but `--yes` and `dispatch_submit` both refuse it.
|
|
138
|
+
`dowafu ticket seal <ticket-id> --repo-root .` freezes it — including each reviewer's lens
|
|
139
|
+
definition, read from `.claude/agents/` at that moment — so a sealed ticket runs the same way
|
|
140
|
+
no matter which directory the command or the daemon was started from. A sealed ticket cannot be
|
|
141
|
+
edited; `dowafu ticket copy <ticket-id> <new-id>` gives you a new draft to change, and the
|
|
142
|
+
original keeps matching the results it produced. `ticket show` prints `[draft]` or `[sealed]`
|
|
143
|
+
on its first line.
|
|
144
|
+
|
|
135
145
|
### Section headings
|
|
136
146
|
|
|
137
147
|
The headings inside the bodies above are **literal markers the parser matches** — use one
|
|
@@ -177,12 +187,24 @@ response for every call. Read a run back with `dowafu result <id>`.
|
|
|
177
187
|
|
|
178
188
|
## Models
|
|
179
189
|
|
|
180
|
-
| provider | model |
|
|
181
|
-
| --- | --- |
|
|
182
|
-
| `openai` | `gpt-5.6-luna
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
|
|
|
190
|
+
| provider | model | on a fresh install |
|
|
191
|
+
| --- | --- | :-: |
|
|
192
|
+
| `openai` | `gpt-5.6-luna` | **enabled** |
|
|
193
|
+
| | `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-6-astra` | disabled |
|
|
194
|
+
| `deepseek` | `deepseek-flash` | **enabled** |
|
|
195
|
+
| | `deepseek-v4-flash`, `deepseek-v4-pro` | disabled |
|
|
196
|
+
| `gemini` | `gemini-3.1-flash-lite`, `gemini-3.5-flash-lite` | **enabled** |
|
|
197
|
+
| | `gemini-3.6-flash`, `gemini-3.7-flash`, `gemini-3.8-flash` | disabled |
|
|
198
|
+
| `anthropic` | `claude-haiku-4-5-20251001` | **enabled** |
|
|
199
|
+
| | `claude-opus-5`, `claude-sonnet-5` | disabled |
|
|
200
|
+
|
|
201
|
+
Fifteen models, five of them enabled out of the box — the pricier ones start disabled so
|
|
202
|
+
that a typo cannot quietly dispatch to an expensive model. Dispatching to a disabled model
|
|
203
|
+
is rejected before any spend, and tells you the `providers enable` command to run.
|
|
204
|
+
|
|
205
|
+
`deepseek-v4-flash` was retired upstream on 2026-09-10 and is currently routed to V4.1
|
|
206
|
+
Flash with no stated end date. It stays on the list so existing tickets keep working;
|
|
207
|
+
**new tickets should use `deepseek-flash`**.
|
|
186
208
|
|
|
187
209
|
The list ships with the package as `providers.json` and is seeded into the database the
|
|
188
210
|
first time one is opened. From then on **the database is the source of truth**:
|
|
@@ -193,6 +215,16 @@ dowafu providers enable|disable <model> # flip one on or off
|
|
|
193
215
|
dowafu providers import <path> # load your own file
|
|
194
216
|
```
|
|
195
217
|
|
|
218
|
+
**Upgrading the package does not refresh the list.** Seeding happens only when the model
|
|
219
|
+
table is completely empty, so an upgrade never silently changes a price or an enabled flag
|
|
220
|
+
you already have. To pick up models added in a later release, import the shipped file
|
|
221
|
+
yourself:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
dowafu providers import --bundled
|
|
225
|
+
dowafu providers list # confirm the new models are there
|
|
226
|
+
```
|
|
227
|
+
|
|
196
228
|
There is no `--providers` flag — it was removed in 0.5.0, when the whitelist moved into
|
|
197
229
|
the database. Passing it is rejected as an unknown option.
|
|
198
230
|
|
|
@@ -208,7 +240,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
|
|
|
208
240
|
against.
|
|
209
241
|
|
|
210
242
|
```bash
|
|
211
|
-
npx degit eyesofkids/dowafu/publish/en#v0.
|
|
243
|
+
npx degit eyesofkids/dowafu/publish/en#v0.8.0 .claude-pack # or publish/zh-tw
|
|
212
244
|
|
|
213
245
|
cd .claude-pack
|
|
214
246
|
TARGET=<your project>
|
|
@@ -230,7 +262,8 @@ in its own language.
|
|
|
230
262
|
|
|
231
263
|
An agent can drive `dowafu` over MCP instead of through the shell. Five tools are
|
|
232
264
|
exposed: `dispatch_tickets`, `dispatch_submit`, `dispatch_status`, `dispatch_result`
|
|
233
|
-
and `dispatch_approve`.
|
|
265
|
+
and `dispatch_approve`. `dispatch_submit` accepts sealed tickets only, and sealing is done
|
|
266
|
+
from the CLI — there is no MCP tool for it.
|
|
234
267
|
|
|
235
268
|
There are two transports, and the difference that matters is who starts what.
|
|
236
269
|
|
package/README_zh-tw.md
CHANGED
|
@@ -84,6 +84,7 @@ dowafu --help # 查看所有旗標
|
|
|
84
84
|
dowafu ticket create <ticket-id> <shared-file>
|
|
85
85
|
dowafu ticket add-spoke <ticket-id> <agent> <provider> <model> <body-file> [effort]
|
|
86
86
|
dowafu ticket add-allow <ticket-id> <agent> <repo-relative-path> --repo-root .
|
|
87
|
+
dowafu ticket seal <ticket-id> --repo-root .
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
`create` 與 `add-spoke` 的本文參數可以是路徑,也可以是 `-`(從 stdin 讀)——所以用 heredoc
|
|
@@ -98,7 +99,7 @@ dowafu ticket create auth-review - <<'EOF'
|
|
|
98
99
|
...
|
|
99
100
|
EOF
|
|
100
101
|
|
|
101
|
-
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-
|
|
102
|
+
dowafu ticket add-spoke auth-review hole-finder-safety deepseek deepseek-flash - <<'EOF'
|
|
102
103
|
# 具體問題
|
|
103
104
|
1. 這裡描述的權限檢查在併發請求下還成立嗎?
|
|
104
105
|
|
|
@@ -111,6 +112,13 @@ EOF
|
|
|
111
112
|
`add-allow` 會立刻驗證路徑、拒絕 `_docs/`,並把該檔案**當下的內容**存起來——派工時不會再讀一次,
|
|
112
113
|
所以審查者看到的是那一刻的快照。用 `dowafu ticket show <ticket-id>` 檢視 Model、允許清單與估算。
|
|
113
114
|
|
|
115
|
+
**工單要先定稿才能執行。** 上面建出來的是**草稿**:可以繼續修改、可以乾跑,但 `--yes` 與
|
|
116
|
+
`dispatch_submit` 都會拒絕它。`dowafu ticket seal <ticket-id> --repo-root .` 把它凍結——
|
|
117
|
+
連同每位審查者的 lens 定義(在那一刻從 `.claude/agents/` 讀進來)——所以定稿之後,不論指令或
|
|
118
|
+
daemon 從哪個目錄啟動,執行結果都一樣。定稿的工單不能再改;要改就用
|
|
119
|
+
`dowafu ticket copy <ticket-id> <new-id>` 複製出一張新草稿,原本那張與它產出的結果維持對應。
|
|
120
|
+
`ticket show` 的第一行會印出 `[draft]` 或 `[sealed]`。
|
|
121
|
+
|
|
114
122
|
### 段落標題
|
|
115
123
|
|
|
116
124
|
上面本文裡的標題是**字面標記(literal markers)**,Parser 會直接比對——必須完全使用以下兩組
|
|
@@ -166,12 +174,22 @@ dowafu result <id>
|
|
|
166
174
|
|
|
167
175
|
## Models
|
|
168
176
|
|
|
169
|
-
| Provider | Model |
|
|
170
|
-
| --- | --- |
|
|
171
|
-
| `openai` | `gpt-5.6-luna
|
|
172
|
-
|
|
|
173
|
-
| `
|
|
174
|
-
|
|
|
177
|
+
| Provider | Model | 全新安裝時 |
|
|
178
|
+
| --- | --- | :-: |
|
|
179
|
+
| `openai` | `gpt-5.6-luna` | **啟用** |
|
|
180
|
+
| | `gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-6-astra` | 停用 |
|
|
181
|
+
| `deepseek` | `deepseek-flash` | **啟用** |
|
|
182
|
+
| | `deepseek-v4-flash`、`deepseek-v4-pro` | 停用 |
|
|
183
|
+
| `gemini` | `gemini-3.1-flash-lite`、`gemini-3.5-flash-lite` | **啟用** |
|
|
184
|
+
| | `gemini-3.6-flash`、`gemini-3.7-flash`、`gemini-3.8-flash` | 停用 |
|
|
185
|
+
| `anthropic` | `claude-haiku-4-5-20251001` | **啟用** |
|
|
186
|
+
| | `claude-opus-5`、`claude-sonnet-5` | 停用 |
|
|
187
|
+
|
|
188
|
+
共 15 個 Model,開箱只啟用五個——較貴的預設停用,讓打錯字不會安靜地派到昂貴的 Model 上。
|
|
189
|
+
派到停用的 Model 會在產生任何費用之前被拒絕,並告訴你該跑哪一條 `providers enable`。
|
|
190
|
+
|
|
191
|
+
`deepseek-v4-flash` 已於 2026-09-10 在上游退役,目前被轉址到 V4.1 Flash,官方未說明何時停止。
|
|
192
|
+
它留在清單上是為了讓既有工單還能運作,**新工單請改用 `deepseek-flash`**。
|
|
175
193
|
|
|
176
194
|
這份清單會隨套件以 `providers.json` 一起提供,並在第一次開啟資料庫時播種進去。
|
|
177
195
|
之後**資料庫就是唯一來源**:
|
|
@@ -182,6 +200,14 @@ dowafu providers enable|disable <model> # 開關其中一個
|
|
|
182
200
|
dowafu providers import <path> # 匯入自己的設定檔
|
|
183
201
|
```
|
|
184
202
|
|
|
203
|
+
**升級套件不會刷新這份清單。** 播種只在 Model 表完全為空時發生,所以升級永遠不會安靜地改掉
|
|
204
|
+
你既有的價格或啟用狀態。要取得後續版本新增的 Model,請自己匯入隨套件出貨的那一份:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
dowafu providers import --bundled
|
|
208
|
+
dowafu providers list # 確認新的 Model 已經進來
|
|
209
|
+
```
|
|
210
|
+
|
|
185
211
|
**沒有 `--providers` 旗標**——0.5.0 把白名單搬進資料庫時已移除,帶了會被當成未知選項拒絕。
|
|
186
212
|
|
|
187
213
|
## 從 Agent 驅動
|
|
@@ -199,7 +225,7 @@ dowafu providers import <path> # 匯入自己的設定檔
|
|
|
199
225
|
`publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
|
|
200
226
|
|
|
201
227
|
```bash
|
|
202
|
-
npx degit eyesofkids/dowafu/publish/zh-tw#v0.
|
|
228
|
+
npx degit eyesofkids/dowafu/publish/zh-tw#v0.8.0 .claude-pack # 或 publish/en
|
|
203
229
|
|
|
204
230
|
cd .claude-pack
|
|
205
231
|
TARGET=<your project>
|
|
@@ -224,7 +250,7 @@ publish/zh-tw/README.md (或 publish/en/README.md)
|
|
|
224
250
|
## 接成 MCP server
|
|
225
251
|
|
|
226
252
|
Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dispatch_tickets`、
|
|
227
|
-
`dispatch_submit`、`dispatch_status`、`dispatch_result`、`dispatch_approve
|
|
253
|
+
`dispatch_submit`、`dispatch_status`、`dispatch_result`、`dispatch_approve`。`dispatch_submit` 只接受已定稿的工單;定稿只能從 CLI 做,沒有對應的 MCP tool。
|
|
228
254
|
|
|
229
255
|
有兩條路,差別在**誰啟動誰**。
|
|
230
256
|
|
package/dist/cli-args.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DEFAULT_HTTP_PORT } from "./mcp/http.js";
|
|
|
10
10
|
import { getCommandName } from "./pkg-info.js";
|
|
11
11
|
import { DEFAULT_MAX_TOOL_CALLS } from "./cost.js";
|
|
12
12
|
import { m } from "./messages.js";
|
|
13
|
+
import { CLI_COMMANDS, CLI_SHORTCUTS } from "./cli-commands.js";
|
|
13
14
|
const DEFAULTS = {
|
|
14
15
|
out: "tmp/spoke",
|
|
15
16
|
concurrency: 2,
|
|
@@ -86,9 +87,19 @@ export function resolveLang(cliLangRaw, envLangRaw) {
|
|
|
86
87
|
export function parseArgs(argv, lang) {
|
|
87
88
|
const helpText = m(lang, "helpText", getCommandName());
|
|
88
89
|
// --help/--version 可出現在任何位置,且優先於其他一切解析——不要求先有 ticketDir。
|
|
89
|
-
|
|
90
|
+
// A named ticket is opaque, including legacy names such as --help or -V.
|
|
91
|
+
let ticketValue = false;
|
|
92
|
+
const controlArgs = argv.filter((arg) => {
|
|
93
|
+
if (ticketValue) {
|
|
94
|
+
ticketValue = false;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
ticketValue = arg === "--ticket";
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
if (controlArgs.includes("--help") || controlArgs.includes(CLI_SHORTCUTS.help))
|
|
90
101
|
return { mode: "help" };
|
|
91
|
-
if (
|
|
102
|
+
if (controlArgs.includes("--version") || controlArgs.includes(CLI_SHORTCUTS.version))
|
|
92
103
|
return { mode: "version" };
|
|
93
104
|
const options = { ...DEFAULTS };
|
|
94
105
|
// 熱修補(票 C 驗收):`DOWAFU_HTTP_PORT` 原本只寫在 `daemon.ts` 的 `serve()` 參數預設值裡,
|
|
@@ -115,6 +126,11 @@ export function parseArgs(argv, lang) {
|
|
|
115
126
|
};
|
|
116
127
|
};
|
|
117
128
|
const flagHandlers = {};
|
|
129
|
+
flagHandlers["ticket"] = (v) => {
|
|
130
|
+
if (ticketDir !== undefined)
|
|
131
|
+
throw new DispatchError(m(lang, "tooManyArgs", v, ticketDir, helpText), 2);
|
|
132
|
+
ticketDir = v;
|
|
133
|
+
};
|
|
118
134
|
flagHandlers["out"] = (v) => (options.out = v);
|
|
119
135
|
flagHandlers["repo-root"] = (v) => (options.repoRoot = v);
|
|
120
136
|
flagHandlers["db"] = (v) => (options.dbPath = v);
|
|
@@ -154,7 +170,7 @@ export function parseArgs(argv, lang) {
|
|
|
154
170
|
// (見迴圈後方)——這樣 --doctor 與 --lang 之類的已知旗標仍可同時給、彼此不衝突。
|
|
155
171
|
doctorMode = true;
|
|
156
172
|
}
|
|
157
|
-
else if (arg ===
|
|
173
|
+
else if (arg === CLI_COMMANDS.serve) {
|
|
158
174
|
serveMode = true;
|
|
159
175
|
}
|
|
160
176
|
else if (arg === "--stop") {
|
|
@@ -163,7 +179,7 @@ export function parseArgs(argv, lang) {
|
|
|
163
179
|
else if (arg === "--restart") {
|
|
164
180
|
serveRestart = true;
|
|
165
181
|
}
|
|
166
|
-
else if (arg ===
|
|
182
|
+
else if (arg === CLI_COMMANDS.mcp) {
|
|
167
183
|
mcpMode = true;
|
|
168
184
|
}
|
|
169
185
|
else if (arg.startsWith("--")) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// These spellings drive both dispatch and ticket-name validation.
|
|
2
|
+
export const CLI_COMMANDS = {
|
|
3
|
+
ticket: "ticket", providers: "providers", token: "token", key: "key",
|
|
4
|
+
result: "result", approve: "approve", serve: "serve", mcp: "mcp",
|
|
5
|
+
};
|
|
6
|
+
export const CLI_SHORTCUTS = { help: "-h", version: "-V" };
|
|
7
|
+
export function reservedTicketNames() {
|
|
8
|
+
return [...Object.values(CLI_COMMANDS), ...Object.values(CLI_SHORTCUTS), "--*"];
|
|
9
|
+
}
|
|
10
|
+
export function isReservedTicketName(name) {
|
|
11
|
+
return name.startsWith("--") || reservedTicketNames().includes(name);
|
|
12
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -13,10 +13,10 @@ import { loadProviders } from "./providers.js";
|
|
|
13
13
|
import { loadTicket } from "./ticket.js";
|
|
14
14
|
import { resolveSpokes } from "./validate.js";
|
|
15
15
|
import { estimateTokens, estimateAllowlistTokens, estimateSequentialRead, checkGateOne } from "./gate.js";
|
|
16
|
-
import {
|
|
16
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
17
17
|
import { buildReport, effectiveCap, effectiveCharsPerToken } from "./report.js";
|
|
18
18
|
import { checkGitignore } from "./gitignore-check.js";
|
|
19
|
-
import { runSpoke } from "./runner.js";
|
|
19
|
+
import { buildSpokeFirstUserText, runSpoke } from "./runner.js";
|
|
20
20
|
import { Semaphore } from "./semaphore.js";
|
|
21
21
|
import { createResponsesAdapter } from "./adapters/responses.js";
|
|
22
22
|
import { createGeminiAdapter } from "./adapters/gemini-native.js";
|
|
@@ -29,11 +29,12 @@ import { registerSecrets, maskString, maskDeep } from "./mask.js";
|
|
|
29
29
|
import { buildDoctorReportData, renderDoctorReport } from "./doctor.js";
|
|
30
30
|
import { bundledProvidersPath, getPackageVersion, getCommandName } from "./pkg-info.js";
|
|
31
31
|
import { buildJsonPayload, buildJsonPlan } from "./json-output.js";
|
|
32
|
+
import { CLI_COMMANDS } from "./cli-commands.js";
|
|
32
33
|
import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSummary } from "./cli-args.js";
|
|
33
34
|
import { m } from "./messages.js";
|
|
34
35
|
import { defaultDbPath, openDb } from "./db.js";
|
|
35
36
|
import { issueToken, listTokens, revokeToken } from "./api-token.js";
|
|
36
|
-
import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
|
|
37
|
+
import { addAllow, addSpoke, copyTicket, createTicket, formatTicket, getStoredTicket, hasResults, importTicketDirectory, sealTicket } from "./ticket-store.js";
|
|
37
38
|
import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
|
|
38
39
|
import { serve, stopDaemon } from "./daemon.js";
|
|
39
40
|
import { daemonAlive, daemonStatePath } from "./liveness.js";
|
|
@@ -178,6 +179,20 @@ async function ticketCommand(argv, lang) {
|
|
|
178
179
|
await importTicketDirectory(db, dir, suppliedName ?? path.basename(path.resolve(dir)), repoRoot, lang);
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
182
|
+
case "seal": {
|
|
183
|
+
const [name] = rest;
|
|
184
|
+
if (!name)
|
|
185
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
186
|
+
await sealTicket(db, name, repoRoot, lang);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
case "copy": {
|
|
190
|
+
const [sourceName, newName] = rest;
|
|
191
|
+
if (!sourceName || !newName)
|
|
192
|
+
throw new DispatchError(m(lang, "ticketHelp", command), 2);
|
|
193
|
+
copyTicket(db, sourceName, newName, lang);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
181
196
|
case "show": {
|
|
182
197
|
const [name] = rest;
|
|
183
198
|
if (!name)
|
|
@@ -215,10 +230,25 @@ async function providersCommand(argv, lang) {
|
|
|
215
230
|
return;
|
|
216
231
|
}
|
|
217
232
|
if (action === "import") {
|
|
233
|
+
const bundled = rest.includes("--bundled");
|
|
234
|
+
if (bundled) {
|
|
235
|
+
// `--bundled` is an explicit source choice, not a spelling of the path.
|
|
236
|
+
// In particular, do not infer this from a path comparison: callers who ask
|
|
237
|
+
// for the shipped file must get the bundled DEFAULT_ENABLED policy.
|
|
238
|
+
if (rest.length !== 1)
|
|
239
|
+
throw new DispatchError(m(lang, "providersHelp", command), 2);
|
|
240
|
+
const filePath = bundledProvidersPath();
|
|
241
|
+
mergeProviders(db, await readProvidersFile(filePath, lang), true);
|
|
242
|
+
console.log(m(lang, "providersImported", filePath));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
218
245
|
const [filePath] = rest;
|
|
219
246
|
if (!filePath)
|
|
220
247
|
throw new DispatchError(m(lang, "providersHelp", command), 2);
|
|
221
|
-
|
|
248
|
+
const resolvedPath = path.resolve(filePath);
|
|
249
|
+
// Keep the legacy path form's bundled recognition intact for callers that
|
|
250
|
+
// already pass the exact installed providers.json path.
|
|
251
|
+
mergeProviders(db, await readProvidersFile(resolvedPath, lang), resolvedPath === bundledProvidersPath());
|
|
222
252
|
console.log(m(lang, "providersImported", filePath));
|
|
223
253
|
return;
|
|
224
254
|
}
|
|
@@ -489,24 +519,28 @@ async function main() {
|
|
|
489
519
|
// 這個不會拋例外的簡化判定(resolveFallbackLang,見 cli-args.ts)。--lang 旗標的值在這個
|
|
490
520
|
// 時間點還沒被驗證,不查它(v1.3 §二之2 #5/#6:help/version 的語言不受 --lang 影響)。
|
|
491
521
|
const messageLang = resolveFallbackLang(process.env.DISPATCH_LANG);
|
|
492
|
-
if (process.argv[2] ===
|
|
522
|
+
if (process.argv[2] === CLI_COMMANDS.ticket) {
|
|
493
523
|
await ticketCommand(process.argv.slice(3), messageLang);
|
|
494
524
|
return;
|
|
495
525
|
}
|
|
496
|
-
if (process.argv[2] ===
|
|
526
|
+
if (process.argv[2] === CLI_COMMANDS.providers) {
|
|
497
527
|
await providersCommand(process.argv.slice(3), messageLang);
|
|
498
528
|
return;
|
|
499
529
|
}
|
|
500
|
-
if (process.argv[2] ===
|
|
530
|
+
if (process.argv[2] === CLI_COMMANDS.token) {
|
|
501
531
|
await tokenCommand(process.argv.slice(3), messageLang);
|
|
502
532
|
return;
|
|
503
533
|
}
|
|
504
|
-
if (process.argv[2] ===
|
|
534
|
+
if (process.argv[2] === CLI_COMMANDS.key) {
|
|
505
535
|
await keyCommand(process.argv.slice(3), messageLang);
|
|
506
536
|
return;
|
|
507
537
|
}
|
|
508
|
-
if (process.argv[2] ===
|
|
538
|
+
if (process.argv[2] === CLI_COMMANDS.result) {
|
|
509
539
|
const args = process.argv.slice(3);
|
|
540
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
541
|
+
console.log(m(messageLang, "resultHelp", getCommandName()));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
510
544
|
const positional = [];
|
|
511
545
|
let dbPath;
|
|
512
546
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -547,8 +581,12 @@ async function main() {
|
|
|
547
581
|
console.log(lines.join("\n\n"));
|
|
548
582
|
return;
|
|
549
583
|
}
|
|
550
|
-
if (process.argv[2] ===
|
|
584
|
+
if (process.argv[2] === CLI_COMMANDS.approve) {
|
|
551
585
|
const args = process.argv.slice(3);
|
|
586
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
587
|
+
console.log(m(messageLang, "approveHelp", getCommandName()));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
552
590
|
const positional = [];
|
|
553
591
|
let dbPath;
|
|
554
592
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -582,6 +620,9 @@ async function main() {
|
|
|
582
620
|
const before = pending[0];
|
|
583
621
|
if (!before)
|
|
584
622
|
throw new DispatchError(m(messageLang, "jobNotFound", prefix ?? ""), 2);
|
|
623
|
+
const ticket = getStoredTicket(db, before.ticket, messageLang);
|
|
624
|
+
if (ticket.status !== "sealed")
|
|
625
|
+
throw new DispatchError(m(messageLang, "ticketDraftRunBlocked", ticket.name, getCommandName()), 5);
|
|
585
626
|
// Render the complete paid-work summary before changing the gate state.
|
|
586
627
|
// A corrupted job therefore stays pending instead of becoming approved with
|
|
587
628
|
// no information shown to the human operator.
|
|
@@ -629,7 +670,7 @@ async function main() {
|
|
|
629
670
|
const reason = err instanceof DispatchError ? err.message : maskString(String(err));
|
|
630
671
|
providersResult = { ok: false, reason };
|
|
631
672
|
}
|
|
632
|
-
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue);
|
|
673
|
+
const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue, bundledProvidersPath());
|
|
633
674
|
console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
|
|
634
675
|
return;
|
|
635
676
|
}
|
|
@@ -703,6 +744,8 @@ async function main() {
|
|
|
703
744
|
const lang = resolveLang(options.langRaw, process.env.DISPATCH_LANG);
|
|
704
745
|
// v1.10 §9:--repo-root 只影響白名單邊界、.claude/agents 位置、_docs/ 拒絕判定;
|
|
705
746
|
// 工單目錄仍相對 cwd 解析,不要求位於 repoRoot 內(見 resolveSpokes 呼叫處)。
|
|
747
|
+
// (ticket-dir-cwd 補註:0.5.0 起 CLI 只走 DB 工單,ticket.ticketDir 是虛擬的 `ticket/<name>`,
|
|
748
|
+
// 不再相對 cwd 解析——見 runner.ts 的 buildSpokeFirstUserText。上一行是目錄工單時代的描述。)
|
|
706
749
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
707
750
|
const ticketId = ticketName;
|
|
708
751
|
const db = openDbSeeded(options.dbPath, lang);
|
|
@@ -713,11 +756,12 @@ async function main() {
|
|
|
713
756
|
// §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)。
|
|
714
757
|
// 兩個回呼都是必填:validate.ts 不持有 db handle,「這個型號開著嗎」與「這個 provider
|
|
715
758
|
// 有沒有 key」都由這裡接上——漏傳編不過(typecheck 涵蓋測試檔)。
|
|
716
|
-
const spokes = await resolveSpokes(ticket, providers, repoRoot, lang, (provider, model) => isModelEnabled(db, provider, model), (provider) => getProviderKey(db, provider) !== undefined);
|
|
759
|
+
const spokes = await resolveSpokes(ticket, providers, repoRoot, lang, (provider, model) => isModelEnabled(db, provider, model), (provider) => getProviderKey(db, provider) !== undefined, ticket.agentContent);
|
|
717
760
|
// §10 步驟 6/§14 閘門一:呼叫前估算
|
|
718
761
|
const estimates = spokes.map((spoke) => {
|
|
719
762
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
720
|
-
|
|
763
|
+
// ticket-dir-cwd 不變量 3:與 runSpoke 同一個組裝點——估算的就是實跑會送出的那則訊息。
|
|
764
|
+
const firstUserText = buildSpokeFirstUserText(spoke, ticket.ticketDir, repoRoot);
|
|
721
765
|
const charsPerToken = effectiveCharsPerToken(spoke, options);
|
|
722
766
|
return {
|
|
723
767
|
agent: spoke.agent,
|
|
@@ -747,6 +791,7 @@ async function main() {
|
|
|
747
791
|
const plan = buildJsonPlan(spokes, estimates, allowlistEstimates, options);
|
|
748
792
|
const buildPayload = (mode, results, audits, toolCallAudits, exitCode) => buildJsonPayload({
|
|
749
793
|
ticketId,
|
|
794
|
+
ticketStatus: ticket.status,
|
|
750
795
|
repoRoot,
|
|
751
796
|
outDir,
|
|
752
797
|
providersSource,
|
|
@@ -779,6 +824,9 @@ async function main() {
|
|
|
779
824
|
}
|
|
780
825
|
return;
|
|
781
826
|
}
|
|
827
|
+
if (ticket.status !== "sealed") {
|
|
828
|
+
throw new DispatchError(m(lang, "ticketDraftRunBlocked", ticketName, getCommandName()), 5);
|
|
829
|
+
}
|
|
782
830
|
// 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
|
|
783
831
|
// 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
|
|
784
832
|
// **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
|
package/dist/daemon.js
CHANGED
|
@@ -84,8 +84,8 @@ export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_D
|
|
|
84
84
|
// stdout use the same language as the daemon's own failure message.
|
|
85
85
|
const jobLang = job.lang ?? lang;
|
|
86
86
|
const args = sourceMode
|
|
87
|
-
? ["--import", "tsx", cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang]
|
|
88
|
-
: [cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang];
|
|
87
|
+
? ["--import", "tsx", cliPath, "--ticket", job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang]
|
|
88
|
+
: [cliPath, "--ticket", job.ticket, "--yes", "--job-id", job.id, "--db", dbPath, "--lang", jobLang];
|
|
89
89
|
return new Promise((resolve) => {
|
|
90
90
|
const child = deps.spawn(process.execPath, args, { stdio: ["inherit", "inherit", "pipe"], env: process.env });
|
|
91
91
|
let stderrTail = Buffer.alloc(0);
|
package/dist/db.js
CHANGED
|
@@ -191,6 +191,24 @@ const MIGRATIONS = [
|
|
|
191
191
|
db.exec("ALTER TABLE jobs ADD COLUMN lang TEXT");
|
|
192
192
|
},
|
|
193
193
|
},
|
|
194
|
+
// ticket-states-T1: old tickets were created before content had a freeze point.
|
|
195
|
+
// A v5 database therefore has no sealed content, so every existing ticket starts as
|
|
196
|
+
// draft and must be sealed explicitly before it can be dispatched.
|
|
197
|
+
{
|
|
198
|
+
version: 6,
|
|
199
|
+
up: (db) => {
|
|
200
|
+
const columns = new Set(db.prepare("PRAGMA table_info(tickets)").all().map((column) => column.name));
|
|
201
|
+
if (!columns.has("status"))
|
|
202
|
+
db.exec("ALTER TABLE tickets ADD COLUMN status TEXT");
|
|
203
|
+
if (!columns.has("sealed_at"))
|
|
204
|
+
db.exec("ALTER TABLE tickets ADD COLUMN sealed_at TEXT");
|
|
205
|
+
// 驗收熱修補:本行原本無條件覆寫,於是對「user_version 被退回 5、但資料已有定稿工單」
|
|
206
|
+
// 的降版 fixture 重跑時,會把 sealed 洗成 draft、sealed_at 洗成 NULL(實測確認)。
|
|
207
|
+
// 真實升級路徑上兩者等價——欄位是這一步才加的,全部是 NULL;加了條件之後重跑是 no-op。
|
|
208
|
+
// v5 的形狀本來就是「加欄位、資料不動」,這裡只是補回同一個紀律。
|
|
209
|
+
db.exec("UPDATE tickets SET status = 'draft', sealed_at = NULL WHERE status IS NULL");
|
|
210
|
+
},
|
|
211
|
+
},
|
|
194
212
|
];
|
|
195
213
|
const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
196
214
|
function migrate(db, lang) {
|
package/dist/doctor.js
CHANGED
|
@@ -120,7 +120,7 @@ function buildLensValue(lang, lenses) {
|
|
|
120
120
|
const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
|
|
121
121
|
return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
|
|
122
122
|
}
|
|
123
|
-
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
123
|
+
export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }, bundledProvidersPath) {
|
|
124
124
|
const dispatchHome = resolveDispatchHome(env, homedir);
|
|
125
125
|
const configDir = dispatchHome === null
|
|
126
126
|
? { source: "unresolved" }
|
|
@@ -153,6 +153,7 @@ export function buildDoctorReportData(cmd, providers, env = process.env, homedir
|
|
|
153
153
|
models,
|
|
154
154
|
daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
|
|
155
155
|
queue: { ...queue },
|
|
156
|
+
...(bundledProvidersPath ? { bundledProvidersPath } : {}),
|
|
156
157
|
lenses: buildLensData(probe, cwd),
|
|
157
158
|
};
|
|
158
159
|
}
|
|
@@ -164,6 +165,7 @@ export function renderDoctorReport(lang, cmd, data) {
|
|
|
164
165
|
// 先前 doctor 寫死走預設 DB,於是 `--doctor --db <別的>` 會對著另一個資料庫回答你。
|
|
165
166
|
...(data.dbPath ? [m(lang, "doctorDbLine", data.dbPath)] : []),
|
|
166
167
|
...(data.envFilePresent ? [m(lang, "doctorEnvLine", m(lang, "doctorEnvPresentValue"))] : []),
|
|
168
|
+
...(data.bundledProvidersPath ? [m(lang, "doctorBundledProvidersLine", data.bundledProvidersPath)] : []),
|
|
167
169
|
m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
|
|
168
170
|
m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
|
|
169
171
|
m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
|
|
@@ -174,6 +176,6 @@ export function renderDoctorReport(lang, cmd, data) {
|
|
|
174
176
|
];
|
|
175
177
|
return lines.join("\n");
|
|
176
178
|
}
|
|
177
|
-
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }) {
|
|
178
|
-
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath, queue));
|
|
179
|
+
export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath, queue = { pendingApproval: 0, approved: 0, running: 0 }, bundledProvidersPath) {
|
|
180
|
+
return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath, queue, bundledProvidersPath));
|
|
179
181
|
}
|
package/dist/job.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { now } from "./db.js";
|
|
3
|
+
import { m } from "./messages.js";
|
|
4
|
+
import { getCommandName } from "./pkg-info.js";
|
|
5
|
+
import { getStoredTicket } from "./ticket-store.js";
|
|
6
|
+
import { DispatchError } from "./types.js";
|
|
3
7
|
export const JOB_STATUSES = [
|
|
4
8
|
"pending_approval",
|
|
5
9
|
"approved",
|
|
@@ -42,8 +46,10 @@ export function countQueueJobs(db) {
|
|
|
42
46
|
return counts;
|
|
43
47
|
}
|
|
44
48
|
export function submitJob(db, ticket, id = randomUUID(), lang = null) {
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const messageLang = lang ?? "en";
|
|
50
|
+
const stored = getStoredTicket(db, ticket, messageLang);
|
|
51
|
+
if (stored.status !== "sealed")
|
|
52
|
+
throw new DispatchError(m(messageLang, "ticketDraftRunBlocked", ticket, getCommandName()), 5);
|
|
47
53
|
const createdAt = now();
|
|
48
54
|
db.prepare("INSERT INTO jobs (id, ticket, lang, status, created_at) VALUES (?, ?, ?, 'pending_approval', ?)").run(id, ticket, lang, createdAt);
|
|
49
55
|
return getJob(db, id);
|
package/dist/json-output.js
CHANGED
|
@@ -66,6 +66,7 @@ export function buildJsonSpoke(result, audit, toolCallAudit) {
|
|
|
66
66
|
export function buildJsonPayload(args) {
|
|
67
67
|
return {
|
|
68
68
|
ticketId: args.ticketId,
|
|
69
|
+
ticketStatus: args.ticketStatus,
|
|
69
70
|
repoRoot: args.repoRoot,
|
|
70
71
|
outDir: args.outDir,
|
|
71
72
|
providersSource: args.providersSource,
|
package/dist/mcp/protocol.js
CHANGED
|
@@ -7,17 +7,17 @@ import { m } from "../messages.js";
|
|
|
7
7
|
import { readResultOutcomes, readResults } from "../output.js";
|
|
8
8
|
import { listModels } from "../providers-store.js";
|
|
9
9
|
import { buildKeyStatusRows } from "../provider-key.js";
|
|
10
|
-
import { getPackageVersion } from "../pkg-info.js";
|
|
11
|
-
import { formatTicket,
|
|
10
|
+
import { getCommandName, getPackageVersion } from "../pkg-info.js";
|
|
11
|
+
import { formatTicket, getStoredTicket, getTicketHardCapUsd } from "../ticket-store.js";
|
|
12
12
|
export const TOOLS = [
|
|
13
13
|
{
|
|
14
14
|
name: "dispatch_tickets",
|
|
15
|
-
description: "List DB tickets
|
|
15
|
+
description: "List DB tickets and their status: draft tickets are editable but cannot be submitted; sealed tickets have frozen content and lenses, can be submitted repeatedly, and can only be sealed from the CLI.",
|
|
16
16
|
inputSchema: { type: "object", properties: {} },
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
name: "dispatch_submit",
|
|
20
|
-
description: "Queue a DB ticket by name.
|
|
20
|
+
description: "Queue a sealed DB ticket by name. Draft tickets are rejected without creating a job; sealing is only available from the CLI. A queued job remains pending human terminal approval and is not executed by this tool.",
|
|
21
21
|
inputSchema: {
|
|
22
22
|
type: "object",
|
|
23
23
|
properties: {
|
|
@@ -134,8 +134,12 @@ export function createProtocol(options) {
|
|
|
134
134
|
const names = options.db.prepare("SELECT name FROM tickets ORDER BY name").all();
|
|
135
135
|
const lines = names.length === 0 ? [m(options.lang, "mcpTicketsEmpty")] : [m(options.lang, "mcpTicketsTitle")];
|
|
136
136
|
for (const { name: ticket } of names) {
|
|
137
|
-
const
|
|
137
|
+
const stored = getStoredTicket(options.db, ticket, options.lang);
|
|
138
|
+
const doc = stored.doc;
|
|
138
139
|
lines.push(m(options.lang, "mcpTicketsRow", ticket, doc.spokes.length));
|
|
140
|
+
lines.push(` ${stored.status === "sealed"
|
|
141
|
+
? m(options.lang, "mcpTicketStatusSealed", stored.sealedAt ?? "")
|
|
142
|
+
: m(options.lang, "mcpTicketStatusDraft", ticket, getCommandName())}`);
|
|
139
143
|
const priorRuns = countTicketResultRuns(options.db, ticket);
|
|
140
144
|
if (priorRuns > 0)
|
|
141
145
|
lines.push(` ${m(options.lang, "mcpPriorResultsReminder", priorRuns)}`);
|
|
@@ -273,6 +277,9 @@ export function createProtocol(options) {
|
|
|
273
277
|
...found.ambiguous.map((candidate) => ` ${candidate.id} ${candidate.ticket} ${candidate.status}`)].join("\n"), true);
|
|
274
278
|
}
|
|
275
279
|
const job = found.job;
|
|
280
|
+
const ticket = getStoredTicket(options.db, job.ticket, options.lang);
|
|
281
|
+
if (ticket.status !== "sealed")
|
|
282
|
+
return text(m(options.lang, "ticketDraftRunBlocked", ticket.name, getCommandName()), true);
|
|
276
283
|
// 工單 §A-1 之二:與 formatTicket 共用同一份算法(getTicketHardCapUsd),不得在這裡
|
|
277
284
|
// 另算一份——變異 #5 鎖這件事。
|
|
278
285
|
const expected = getTicketHardCapUsd(options.db, job.ticket, options.lang);
|
package/dist/messages.js
CHANGED
|
@@ -25,7 +25,7 @@ const MESSAGES = {
|
|
|
25
25
|
unknownOption: (arg, helpText) => `未知選項:${arg}\n\n${helpText}`,
|
|
26
26
|
missingProviders: (path) => `找不到 providers.json:${path}`,
|
|
27
27
|
dryRunNotice: () => "--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。",
|
|
28
|
-
helpText: (cmd) => `用法:${cmd} <
|
|
28
|
+
helpText: (cmd) => `用法:${cmd} --ticket <name> [options](亦接受位置參數 <ticket-id>)
|
|
29
29
|
|
|
30
30
|
子指令(各自有自己的 --help/用法訊息):
|
|
31
31
|
${cmd} ticket ... 建立、匯入、檢視工單
|
|
@@ -39,6 +39,7 @@ const MESSAGES = {
|
|
|
39
39
|
${cmd} serve --restart [--yes] 停止後重新啟動 daemon,會走兩端清理
|
|
40
40
|
${cmd} mcp 以 stdio 提供 MCP server
|
|
41
41
|
|
|
42
|
+
--ticket <name> 指定工單名稱,不可與位置參數同時給
|
|
42
43
|
--lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
|
|
43
44
|
--repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
|
|
44
45
|
--db <path> SQLite 工單與產物資料庫,預設 DISPATCH_HOME/dowafu.db
|
|
@@ -137,6 +138,8 @@ const MESSAGES = {
|
|
|
137
138
|
mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
|
|
138
139
|
mcpTicketsTitle: () => "可派工單:",
|
|
139
140
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}:${spokeCount} 個 spoke`,
|
|
141
|
+
mcpTicketStatusDraft: (ticket, cmd) => `[draft] 草稿 — 尚不能送出;定稿請在 CLI 執行:${cmd} ticket seal ${ticket} --repo-root <repo 根>`,
|
|
142
|
+
mcpTicketStatusSealed: (sealedAt) => `[sealed] 定稿 ${sealedAt} — 內容與 lens 已凍結,可送出。`,
|
|
140
143
|
mcpTicketsSpoke: (agent, provider, model, allowCount) => ` ${agent}:${provider}/${model};允許清單檔 ${allowCount} 個`,
|
|
141
144
|
mcpKeysTitle: () => "API key 狀態:",
|
|
142
145
|
mcpKeysRow: (provider, status) => ` ${provider}:${status}`,
|
|
@@ -216,14 +219,23 @@ const MESSAGES = {
|
|
|
216
219
|
agentFileNotFound: (agentPath, agent) => `找不到 ${agentPath}(_dispatch.md 列了 agent "${agent}")`,
|
|
217
220
|
ticketNotFound: (name) => `找不到 DB 工單:${name}`,
|
|
218
221
|
ticketCorrupt: (name) => `DB 工單內容損毀:${name}`,
|
|
222
|
+
ticketNameReserved: (name, reserved) => `工單名稱「${name}」是 CLI 保留字。保留名稱:${reserved}(--* 表示所有 -- 開頭的名稱)`,
|
|
219
223
|
ticketNameInvalid: (name) => `工單名稱無效:${name}`,
|
|
220
224
|
ticketAlreadyExists: (name) => `DB 工單已存在:${name}`,
|
|
221
225
|
ticketSpokeNotFound: (name, agent) => `DB 工單 "${name}" 沒有 agent "${agent}"`,
|
|
226
|
+
ticketStatusInvalid: (name) => `DB 工單 "${name}" 的狀態無效或損毀。`,
|
|
227
|
+
ticketSealedWriteRejected: (name) => `DB 工單 "${name}" 已定稿、內容已凍結;要修改請用 dowafu ticket copy ${name} <新名稱>。`,
|
|
228
|
+
ticketSealNeedsSpoke: (name) => `DB 工單 "${name}" 至少要有一支 spoke 才能定稿。`,
|
|
229
|
+
ticketCopyNeedsSealed: (name) => `DB 工單 "${name}" 仍是草稿;只有已定稿工單可以複製。`,
|
|
230
|
+
ticketSealedLensMissing: (name, agent) => `DB 工單 "${name}" 已定稿但缺少 agent "${agent}" 的 lens 快照,已中止,未讀取磁碟 lens。`,
|
|
231
|
+
ticketDraftRunBlocked: (name, cmd) => `DB 工單 "${name}" 目前是草稿,lens 尚未凍結;未派工、未呼叫 API。請先執行:${cmd} ticket seal ${name} --repo-root .`,
|
|
232
|
+
ticketStatusDraft: () => `[draft] 草稿 — 可編輯;尚不能送出。`,
|
|
233
|
+
ticketStatusSealed: (sealedAt, repoRoot) => `[sealed] 定稿 ${sealedAt}(${repoRoot})— 內容與 lens 已凍結,可送出。`,
|
|
222
234
|
ticketShowHeader: (name) => `DB 工單 ${name}:`,
|
|
223
235
|
ticketEstimateTokens: (tokens) => `估算 ${tokens} tokens`,
|
|
224
236
|
ticketCompletedRuns: (runs, total, last) => `已完成 ${runs} 次;累計 $${total};最近一次 ${last}`,
|
|
225
237
|
ticketCompletedRunsNoPricing: (runs, last) => `已完成 ${runs} 次;累計無價目資料;最近一次 ${last}`,
|
|
226
|
-
ticketHelp: (cmd) => `${cmd} ticket create <name> <shared-file>\n${cmd} ticket add-spoke <name> <agent> <provider> <model> <body-file> [effort]\n${cmd} ticket add-allow <name> <agent> <repo-relative-path> [--repo-root <dir>]\n${cmd} ticket import <existing-ticket-dir> [name] [--repo-root <dir>]\n${cmd} ticket show <name>\n <shared-file>/<body-file> 可用 - 代表從 stdin 讀取本文(stdin 須為管道/重導向,不得是互動式終端機)`,
|
|
238
|
+
ticketHelp: (cmd) => `${cmd} ticket create <name> <shared-file>\n${cmd} ticket add-spoke <name> <agent> <provider> <model> <body-file> [effort]\n${cmd} ticket add-allow <name> <agent> <repo-relative-path> [--repo-root <dir>]\n${cmd} ticket import <existing-ticket-dir> [name] [--repo-root <dir>]\n${cmd} ticket seal <name> --repo-root <dir>\n${cmd} ticket copy <sealed-ticket> <new-name>\n${cmd} ticket show <name>\n draft:可編輯、不能送出;sealed:內容(含 lens)已凍結,可送出且可多次執行。\n <shared-file>/<body-file> 可用 - 代表從 stdin 讀取本文(stdin 須為管道/重導向,不得是互動式終端機)`,
|
|
227
239
|
ticketStdinRequiresPipe: () => `此指令用 - 讀取 stdin 本文,但目前 stdin 是互動式終端機,沒有東西被導進來。` +
|
|
228
240
|
`請改用管道/重導向提供內容(例如 echo "..." | dowafu ticket create <name> -),或改用一般檔案路徑。`,
|
|
229
241
|
agentDefNotFound: (path, agent) => `找不到 agent 定義檔 ${path}(_dispatch.md 列了 "${agent}")`,
|
|
@@ -256,7 +268,7 @@ const MESSAGES = {
|
|
|
256
268
|
modelDisabled: (model, cmd) => `型號 "${model}" 已停用;請先執行 ${cmd} providers enable ${model}`,
|
|
257
269
|
providerModelNotFound: (model) => `找不到型號:${model}`,
|
|
258
270
|
providerModelAmbiguous: (model, providers) => `型號 "${model}" 同時存在於:${providers}。請用 ${"dowafu"} providers enable <provider> ${model} 指定目標;可先看 providers list。`,
|
|
259
|
-
providersHelp: (cmd) => `${cmd} providers list [--db <path>]\n${cmd} providers enable <model> [--db <path>]\n${cmd} providers enable <provider> <model> [--db <path>]\n${cmd} providers disable <model> [--db <path>]\n${cmd} providers disable <provider> <model> [--db <path>]\n${cmd} providers import <path> [--db <path>]`,
|
|
271
|
+
providersHelp: (cmd) => `${cmd} providers list [--db <path>]\n${cmd} providers enable <model> [--db <path>]\n${cmd} providers enable <provider> <model> [--db <path>]\n${cmd} providers disable <model> [--db <path>]\n${cmd} providers disable <provider> <model> [--db <path>]\n${cmd} providers import <path> [--db <path>]\n${cmd} providers import --bundled [--db <path>]`,
|
|
260
272
|
providersListEmpty: () => "providers DB 尚無型號;請明確執行 providers import。",
|
|
261
273
|
providersListRow: (provider, model, input, output, enabled) => `${provider}\t${model}\tinput $${input}/M\toutput $${output}/M\t${enabled ? "enabled" : "disabled"}`,
|
|
262
274
|
providersImported: (path) => `已合流匯入 providers:${path}`,
|
|
@@ -342,6 +354,7 @@ const MESSAGES = {
|
|
|
342
354
|
doctorConfigDirSourceDefault: (dir) => `${dir}(來源:預設;DISPATCH_HOME 與 XDG_CONFIG_HOME 都沒設)`,
|
|
343
355
|
doctorConfigDirUnresolved: () => "無法解析(沒有 HOME)",
|
|
344
356
|
doctorDbLine: (dbPath) => ` 資料庫 ${dbPath}`,
|
|
357
|
+
doctorBundledProvidersLine: (filePath) => ` 出貨型號檔 ${filePath}`,
|
|
345
358
|
doctorEnvLine: (value) => ` .env ${value}`,
|
|
346
359
|
doctorEnvPresentValue: () => "有,但本版不讀取(API key 請用 `dowafu key` 設定進 DB)",
|
|
347
360
|
doctorApiKeyLine: (value) => ` API key ${value}`,
|
|
@@ -364,7 +377,7 @@ const MESSAGES = {
|
|
|
364
377
|
unknownOption: (arg, helpText) => `Unknown option: ${arg}\n\n${helpText}`,
|
|
365
378
|
missingProviders: (path) => `providers.json not found: ${path}`,
|
|
366
379
|
dryRunNotice: () => "--dry-run: parsed, validated, estimated, and reported only; no API calls were made.",
|
|
367
|
-
helpText: (cmd) => `Usage: ${cmd} <ticket-id>
|
|
380
|
+
helpText: (cmd) => `Usage: ${cmd} --ticket <name> [options] (positional <ticket-id> also accepted)
|
|
368
381
|
|
|
369
382
|
Subcommands (each has its own usage message):
|
|
370
383
|
${cmd} ticket ... Create, import and inspect tickets
|
|
@@ -378,6 +391,7 @@ Subcommands (each has its own usage message):
|
|
|
378
391
|
${cmd} serve --restart [--yes] Stop then start the daemon, running both cleanup phases
|
|
379
392
|
${cmd} mcp Serve MCP over stdio
|
|
380
393
|
|
|
394
|
+
--ticket <name> Select a ticket; cannot be combined with a positional ticket
|
|
381
395
|
--lang <en|zh-tw> Language for CLI output and spoke prompts, default en
|
|
382
396
|
--repo-root <dir> Root for the allowlist boundary and .claude/agents, default cwd
|
|
383
397
|
--db <path> SQLite ticket/result database, default DISPATCH_HOME/dowafu.db
|
|
@@ -474,6 +488,8 @@ Subcommands (each has its own usage message):
|
|
|
474
488
|
mcpTicketsEmpty: () => "No dispatchable DB tickets.",
|
|
475
489
|
mcpTicketsTitle: () => "Dispatchable tickets:",
|
|
476
490
|
mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}: ${spokeCount} spoke(s)`,
|
|
491
|
+
mcpTicketStatusDraft: (ticket, cmd) => `[draft] Draft — cannot be dispatched; seal it in the CLI: ${cmd} ticket seal ${ticket} --repo-root <repo root>`,
|
|
492
|
+
mcpTicketStatusSealed: (sealedAt) => `[sealed] Sealed ${sealedAt} — content and lenses are frozen; ready to dispatch.`,
|
|
477
493
|
mcpTicketsSpoke: (agent, provider, model, allowCount) => ` ${agent}: ${provider}/${model}; allowlist files=${allowCount}`,
|
|
478
494
|
mcpKeysTitle: () => "API key status:",
|
|
479
495
|
mcpKeysRow: (provider, status) => ` ${provider}: ${status}`,
|
|
@@ -556,14 +572,23 @@ Subcommands (each has its own usage message):
|
|
|
556
572
|
agentFileNotFound: (agentPath, agent) => `Not found: ${agentPath} (_dispatch.md lists agent "${agent}")`,
|
|
557
573
|
ticketNotFound: (name) => `DB ticket not found: ${name}`,
|
|
558
574
|
ticketCorrupt: (name) => `DB ticket is corrupt: ${name}`,
|
|
575
|
+
ticketNameReserved: (name, reserved) => `Ticket name "${name}" is reserved by the CLI. Reserved names: ${reserved} (--* means every name starting with --)`,
|
|
559
576
|
ticketNameInvalid: (name) => `Invalid ticket name: ${name}`,
|
|
560
577
|
ticketAlreadyExists: (name) => `DB ticket already exists: ${name}`,
|
|
561
578
|
ticketSpokeNotFound: (name, agent) => `DB ticket "${name}" has no agent "${agent}"`,
|
|
579
|
+
ticketStatusInvalid: (name) => `DB ticket "${name}" has an invalid or corrupt status.`,
|
|
580
|
+
ticketSealedWriteRejected: (name) => `DB ticket "${name}" is sealed and its content is frozen; to modify it, run dowafu ticket copy ${name} <new-name>.`,
|
|
581
|
+
ticketSealNeedsSpoke: (name) => `DB ticket "${name}" needs at least one spoke before it can be sealed.`,
|
|
582
|
+
ticketCopyNeedsSealed: (name) => `DB ticket "${name}" is still a draft; only a sealed ticket can be copied.`,
|
|
583
|
+
ticketSealedLensMissing: (name, agent) => `DB ticket "${name}" is sealed but lacks a lens snapshot for agent "${agent}"; aborted without reading a lens from disk.`,
|
|
584
|
+
ticketDraftRunBlocked: (name, cmd) => `DB ticket "${name}" is a draft and its lenses are not frozen; nothing was dispatched and no API was called. First run: ${cmd} ticket seal ${name} --repo-root .`,
|
|
585
|
+
ticketStatusDraft: () => `[draft] Draft — editable; cannot be dispatched yet.`,
|
|
586
|
+
ticketStatusSealed: (sealedAt, repoRoot) => `[sealed] Sealed ${sealedAt} (${repoRoot}) — content and lenses are frozen; ready to dispatch.`,
|
|
562
587
|
ticketShowHeader: (name) => `DB ticket ${name}:`,
|
|
563
588
|
ticketEstimateTokens: (tokens) => `est. ${tokens} tokens`,
|
|
564
589
|
ticketCompletedRuns: (runs, total, last) => `Completed ${runs} times; total $${total}; last ${last}`,
|
|
565
590
|
ticketCompletedRunsNoPricing: (runs, last) => `Completed ${runs} times; total No pricing data; last ${last}`,
|
|
566
|
-
ticketHelp: (cmd) => `${cmd} ticket create <name> <shared-file>\n${cmd} ticket add-spoke <name> <agent> <provider> <model> <body-file> [effort]\n${cmd} ticket add-allow <name> <agent> <repo-relative-path> [--repo-root <dir>]\n${cmd} ticket import <existing-ticket-dir> [name] [--repo-root <dir>]\n${cmd} ticket show <name>\n <shared-file>/<body-file> may be - to read the body from stdin (stdin must be piped/redirected, not an interactive terminal)`,
|
|
591
|
+
ticketHelp: (cmd) => `${cmd} ticket create <name> <shared-file>\n${cmd} ticket add-spoke <name> <agent> <provider> <model> <body-file> [effort]\n${cmd} ticket add-allow <name> <agent> <repo-relative-path> [--repo-root <dir>]\n${cmd} ticket import <existing-ticket-dir> [name] [--repo-root <dir>]\n${cmd} ticket seal <name> --repo-root <dir>\n${cmd} ticket copy <sealed-ticket> <new-name>\n${cmd} ticket show <name>\n draft: editable, cannot dispatch; sealed: content (including lenses) is frozen, can dispatch and run repeatedly.\n <shared-file>/<body-file> may be - to read the body from stdin (stdin must be piped/redirected, not an interactive terminal)`,
|
|
567
592
|
ticketStdinRequiresPipe: () => "This command reads the body from stdin via -, but stdin is an interactive terminal right now; nothing is being piped in. " +
|
|
568
593
|
'Pipe or redirect content instead (for example, echo "..." | dowafu ticket create <name> -), or use a regular file path.',
|
|
569
594
|
agentDefNotFound: (path, agent) => `Agent definition file not found: ${path} (_dispatch.md lists "${agent}")`,
|
|
@@ -599,7 +624,7 @@ Subcommands (each has its own usage message):
|
|
|
599
624
|
modelDisabled: (model, cmd) => `Model "${model}" is disabled; first run ${cmd} providers enable ${model}`,
|
|
600
625
|
providerModelNotFound: (model) => `Model not found: ${model}`,
|
|
601
626
|
providerModelAmbiguous: (model, providers) => `Model "${model}" exists in: ${providers}. Use dowafu providers enable <provider> ${model}; see providers list.`,
|
|
602
|
-
providersHelp: (cmd) => `${cmd} providers list [--db <path>]\n${cmd} providers enable <model> [--db <path>]\n${cmd} providers enable <provider> <model> [--db <path>]\n${cmd} providers disable <model> [--db <path>]\n${cmd} providers disable <provider> <model> [--db <path>]\n${cmd} providers import <path> [--db <path>]`,
|
|
627
|
+
providersHelp: (cmd) => `${cmd} providers list [--db <path>]\n${cmd} providers enable <model> [--db <path>]\n${cmd} providers enable <provider> <model> [--db <path>]\n${cmd} providers disable <model> [--db <path>]\n${cmd} providers disable <provider> <model> [--db <path>]\n${cmd} providers import <path> [--db <path>]\n${cmd} providers import --bundled [--db <path>]`,
|
|
603
628
|
providersListEmpty: () => "The providers DB has no models; explicitly run providers import.",
|
|
604
629
|
providersListRow: (provider, model, input, output, enabled) => `${provider}\t${model}\tinput $${input}/M\toutput $${output}/M\t${enabled ? "enabled" : "disabled"}`,
|
|
605
630
|
providersImported: (path) => `Providers merged from: ${path}`,
|
|
@@ -682,6 +707,7 @@ Subcommands (each has its own usage message):
|
|
|
682
707
|
doctorConfigDirSourceDefault: (dir) => `${dir} (source: default; neither DISPATCH_HOME nor XDG_CONFIG_HOME is set)`,
|
|
683
708
|
doctorConfigDirUnresolved: () => "could not resolve (no HOME)",
|
|
684
709
|
doctorDbLine: (dbPath) => ` Database ${dbPath}`,
|
|
710
|
+
doctorBundledProvidersLine: (filePath) => ` Bundled providers.json ${filePath}`,
|
|
685
711
|
doctorEnvLine: (value) => ` .env ${value}`,
|
|
686
712
|
doctorEnvPresentValue: () => "present, but this version does not read it (set API keys with `dowafu key`)",
|
|
687
713
|
doctorApiKeyLine: (value) => ` API keys ${value}`,
|
package/dist/prompt.js
CHANGED
|
@@ -90,6 +90,12 @@ function buildStep3(allowedReadsRelative, lang) {
|
|
|
90
90
|
function displayTicketDir(ticketDir, repoRoot) {
|
|
91
91
|
if (!repoRoot)
|
|
92
92
|
return ticketDir;
|
|
93
|
+
// ticket-dir-cwd:相對路徑一律原樣輸出,不交給 path.relative——後者會先以 cwd 解析它,
|
|
94
|
+
// cwd 不是 repoRoot 時(含 repoRoot 的子目錄)就變成另一個字串。DB 工單的 ticketDir 是虛擬的
|
|
95
|
+
// `ticket/<name>`,它就是快照查表鍵的前綴,spoke 必須逐字看到它。目錄工單由呼叫端
|
|
96
|
+
// (runner.ts 的 buildSpokeFirstUserText)先 resolve 成絕對路徑,不走這條。
|
|
97
|
+
if (!path.isAbsolute(ticketDir))
|
|
98
|
+
return ticketDir;
|
|
93
99
|
const rel = path.relative(repoRoot, ticketDir);
|
|
94
100
|
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
95
101
|
return ticketDir;
|
package/dist/providers-store.js
CHANGED
|
@@ -7,7 +7,10 @@ import { parseProvidersFile } from "./providers.js";
|
|
|
7
7
|
import { bundledProvidersPath } from "./pkg-info.js";
|
|
8
8
|
const DEFAULT_ENABLED = new Set([
|
|
9
9
|
"gpt-5.6-luna",
|
|
10
|
-
|
|
10
|
+
// 2026-09-12:預設改為 deepseek-flash。deepseek-v4-flash 已於 2026-09-10 退役,
|
|
11
|
+
// 目前只是暫時轉址到 V4.1 Flash 且官方未給截止日——預設開一個隨時會消失的型號,
|
|
12
|
+
// 壞法會是「新裝的人某天突然跑不動」。deepseek-flash 已 smoke 實跑通過。
|
|
13
|
+
"deepseek-flash",
|
|
11
14
|
"gemini-3.1-flash-lite",
|
|
12
15
|
"gemini-3.5-flash-lite",
|
|
13
16
|
"claude-haiku-4-5-20251001",
|
package/dist/runner.js
CHANGED
|
@@ -134,10 +134,19 @@ async function sendWithResilience(adapter, conv, sendOpts, cfg, ctx) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
// ticket-dir-cwd(log_v0_6_0_open.md #31):spoke 第一則訊息的唯一組裝點——實跑(runSpoke)與
|
|
138
|
+
// 乾跑估算(cli.ts)都走這裡,不得各自組裝。原本 runSpoke 先 path.resolve、乾跑不 resolve,
|
|
139
|
+
// cwd ≠ --repo-root 時實跑給出查表查不到的 `<cwd>/ticket/<name>`,乾跑卻一切正常,所以一路看不見。
|
|
140
|
+
// DB 工單(readableContent 存在)的 ticketDir 是快照查表鍵的前綴、不是檔案系統路徑,原樣傳遞——
|
|
141
|
+
// 判別條件與 executeToolCall 相同,「prompt 給的路徑」因此就是「查表用的鍵」。
|
|
142
|
+
// 目錄工單(僅解析器測試)維持原本「相對 cwd 解析」的語意。
|
|
143
|
+
export function buildSpokeFirstUserText(spoke, ticketDir, repoRoot) {
|
|
144
|
+
return buildFirstUserText(spoke.readableContent ? ticketDir : path.resolve(ticketDir), spoke.agent, spoke.allowedReadsRelative, repoRoot, spoke.lang);
|
|
145
|
+
}
|
|
137
146
|
export async function runSpoke(spoke, adapter, ticketDir, options) {
|
|
138
147
|
const startedAt = Date.now();
|
|
139
148
|
const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
|
|
140
|
-
const firstUserText =
|
|
149
|
+
const firstUserText = buildSpokeFirstUserText(spoke, ticketDir, options.repoRoot);
|
|
141
150
|
const conv = { systemPrompt, turns: [{ role: "user", text: firstUserText }] };
|
|
142
151
|
let toolCallCount = 0;
|
|
143
152
|
let cumulativeUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, available: true };
|
package/dist/ticket-store.js
CHANGED
|
@@ -3,12 +3,14 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DispatchError } from "./types.js";
|
|
5
5
|
import { m } from "./messages.js";
|
|
6
|
+
import { isReservedTicketName, reservedTicketNames } from "./cli-commands.js";
|
|
6
7
|
import { now } from "./db.js";
|
|
7
8
|
import { parseAgentTicket, parseDispatchTable } from "./ticket.js";
|
|
8
9
|
import { isModelEnabled, loadProviders } from "./providers-store.js";
|
|
9
10
|
import { getCommandName } from "./pkg-info.js";
|
|
10
11
|
import { formatModelPricing } from "./report.js";
|
|
11
12
|
import { DEFAULT_MAX_TOOL_CALLS, estimateHardCapUsd } from "./cost.js";
|
|
13
|
+
export const TICKET_STATUSES = ["draft", "sealed"];
|
|
12
14
|
function parseDoc(raw, name, lang) {
|
|
13
15
|
try {
|
|
14
16
|
const value = JSON.parse(raw);
|
|
@@ -21,19 +23,40 @@ function parseDoc(raw, name, lang) {
|
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
export function getTicketDoc(db, name, lang) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
return getStoredTicket(db, name, lang).doc;
|
|
27
|
+
}
|
|
28
|
+
function isTicketStatus(value) {
|
|
29
|
+
return typeof value === "string" && TICKET_STATUSES.includes(value);
|
|
30
|
+
}
|
|
31
|
+
export function getStoredTicket(db, name, lang) {
|
|
32
|
+
const row = db.prepare("SELECT name, doc, status, sealed_at FROM tickets WHERE name = ?").get(name);
|
|
33
|
+
if (!row?.doc || !row.name)
|
|
26
34
|
throw new DispatchError(m(lang, "ticketNotFound", name), 2);
|
|
27
|
-
|
|
35
|
+
if (!isTicketStatus(row.status))
|
|
36
|
+
throw new DispatchError(m(lang, "ticketStatusInvalid", name), 2);
|
|
37
|
+
return {
|
|
38
|
+
name: row.name,
|
|
39
|
+
doc: parseDoc(row.doc, name, lang),
|
|
40
|
+
status: row.status,
|
|
41
|
+
sealedAt: typeof row.sealed_at === "string" ? row.sealed_at : null,
|
|
42
|
+
};
|
|
28
43
|
}
|
|
29
|
-
function
|
|
44
|
+
function assertDraft(ticket, lang) {
|
|
45
|
+
if (ticket.status !== "draft")
|
|
46
|
+
throw new DispatchError(m(lang, "ticketSealedWriteRejected", ticket.name), 2);
|
|
47
|
+
}
|
|
48
|
+
function put(db, name, doc, lang) {
|
|
49
|
+
assertDraft(getStoredTicket(db, name, lang), lang);
|
|
30
50
|
db.prepare("UPDATE tickets SET doc = ? WHERE name = ?").run(JSON.stringify(doc), name);
|
|
31
51
|
}
|
|
32
52
|
export function createTicket(db, name, shared, lang) {
|
|
33
53
|
if (!name || name.includes(path.sep) || name === "." || name === "..")
|
|
34
54
|
throw new DispatchError(m(lang, "ticketNameInvalid", name), 2);
|
|
55
|
+
if (isReservedTicketName(name))
|
|
56
|
+
throw new DispatchError(m(lang, "ticketNameReserved", name, reservedTicketNames().join(", ")), 2);
|
|
35
57
|
try {
|
|
36
|
-
db.prepare("INSERT INTO tickets (name, doc, created_at) VALUES (?, ?, ?)")
|
|
58
|
+
db.prepare("INSERT INTO tickets (name, doc, status, sealed_at, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
59
|
+
.run(name, JSON.stringify({ shared, spokes: [] }), "draft", null, now());
|
|
37
60
|
}
|
|
38
61
|
catch {
|
|
39
62
|
throw new DispatchError(m(lang, "ticketAlreadyExists", name), 2);
|
|
@@ -57,7 +80,7 @@ export function addSpoke(db, name, spoke, lang) {
|
|
|
57
80
|
if (!isModelEnabled(db, spoke.provider, spoke.model))
|
|
58
81
|
throw new DispatchError(m(lang, "modelDisabled", spoke.model, getCommandName()), 6);
|
|
59
82
|
doc.spokes.push({ ...spoke, allow: [] });
|
|
60
|
-
put(db, name, doc);
|
|
83
|
+
put(db, name, doc, lang);
|
|
61
84
|
}
|
|
62
85
|
function isUnderDocs(repoRoot, target) {
|
|
63
86
|
const docs = path.resolve(repoRoot, "_docs");
|
|
@@ -83,7 +106,47 @@ export async function addAllow(db, name, agent, rel, repoRoot, lang) {
|
|
|
83
106
|
spoke.allow[i] = { path: rel, content };
|
|
84
107
|
else
|
|
85
108
|
spoke.allow.push({ path: rel, content });
|
|
86
|
-
put(db, name, doc);
|
|
109
|
+
put(db, name, doc, lang);
|
|
110
|
+
}
|
|
111
|
+
export async function sealTicket(db, name, repoRoot, lang) {
|
|
112
|
+
const ticket = getStoredTicket(db, name, lang);
|
|
113
|
+
assertDraft(ticket, lang);
|
|
114
|
+
if (ticket.doc.spokes.length === 0)
|
|
115
|
+
throw new DispatchError(m(lang, "ticketSealNeedsSpoke", name), 2);
|
|
116
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
117
|
+
const agentContent = {};
|
|
118
|
+
// Read every file before writing anything. A missing lens must leave both the status
|
|
119
|
+
// and JSON document untouched.
|
|
120
|
+
for (const spoke of ticket.doc.spokes) {
|
|
121
|
+
const agentPath = path.join(resolvedRepoRoot, ".claude", "agents", `${spoke.agent}.md`);
|
|
122
|
+
try {
|
|
123
|
+
agentContent[spoke.agent] = await readFile(agentPath, "utf8");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new DispatchError(m(lang, "agentDefNotFound", agentPath, spoke.agent), 2);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const sealedAt = now();
|
|
130
|
+
const sealedDoc = { ...ticket.doc, agentContent, sealedRepoRoot: resolvedRepoRoot };
|
|
131
|
+
db.exec("BEGIN");
|
|
132
|
+
try {
|
|
133
|
+
db.prepare("UPDATE tickets SET doc = ?, status = ?, sealed_at = ? WHERE name = ?")
|
|
134
|
+
.run(JSON.stringify(sealedDoc), "sealed", sealedAt, name);
|
|
135
|
+
db.exec("COMMIT");
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
db.exec("ROLLBACK");
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function copyTicket(db, sourceName, newName, lang) {
|
|
143
|
+
const source = getStoredTicket(db, sourceName, lang);
|
|
144
|
+
if (source.status !== "sealed")
|
|
145
|
+
throw new DispatchError(m(lang, "ticketCopyNeedsSealed", sourceName), 2);
|
|
146
|
+
createTicket(db, newName, source.doc.shared, lang);
|
|
147
|
+
// A new draft deliberately retains shared/spokes/allowlist snapshots but never lens
|
|
148
|
+
// snapshots or the old sealing root; it has to seal against its chosen repo again.
|
|
149
|
+
put(db, newName, { shared: source.doc.shared, spokes: source.doc.spokes }, lang);
|
|
87
150
|
}
|
|
88
151
|
export async function importTicketDirectory(db, dir, name, repoRoot, lang) {
|
|
89
152
|
const dispatch = await readFile(path.join(dir, "_dispatch.md"), "utf8");
|
|
@@ -154,8 +217,14 @@ export function getTicketHardCapUsd(db, name, lang, maxToolCalls = DEFAULT_MAX_T
|
|
|
154
217
|
return parts.some((p) => p === null) ? null : parts.reduce((sum, p) => sum + p, 0);
|
|
155
218
|
}
|
|
156
219
|
export function formatTicket(db, name, lang, maxToolCalls = DEFAULT_MAX_TOOL_CALLS) {
|
|
157
|
-
const
|
|
158
|
-
const
|
|
220
|
+
const ticket = getStoredTicket(db, name, lang);
|
|
221
|
+
const doc = ticket.doc;
|
|
222
|
+
const statusLine = ticket.status === "sealed"
|
|
223
|
+
? m(lang, "ticketStatusSealed", ticket.sealedAt ?? "—", doc.sealedRepoRoot ?? "—")
|
|
224
|
+
: m(lang, "ticketStatusDraft");
|
|
225
|
+
// Status is deliberately first: this is the human's decision point before any cost
|
|
226
|
+
// estimate or content detail.
|
|
227
|
+
const lines = [`${m(lang, "ticketShowHeader", name)} ${statusLine}`];
|
|
159
228
|
for (const spoke of doc.spokes) {
|
|
160
229
|
const chars = spoke.allow.reduce((sum, item) => sum + item.content.length, 0);
|
|
161
230
|
const details = getTicketPricing(db, spoke.provider, spoke.model);
|
package/dist/ticket.js
CHANGED
|
@@ -4,7 +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 {
|
|
7
|
+
import { getStoredTicket } from "./ticket-store.js";
|
|
8
8
|
const FORMAT_MARKER = "<!-- format: v1 -->";
|
|
9
9
|
function splitTableRow(line) {
|
|
10
10
|
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
@@ -206,11 +206,13 @@ export async function loadTicket(source, nameOrLang, maybeLang) {
|
|
|
206
206
|
return loadTicketDirectory(source, nameOrLang);
|
|
207
207
|
const name = nameOrLang;
|
|
208
208
|
const lang = maybeLang;
|
|
209
|
-
const
|
|
209
|
+
const stored = getStoredTicket(source, name, lang);
|
|
210
|
+
const doc = stored.doc;
|
|
210
211
|
const shared = parseSharedDoc(doc.shared, lang);
|
|
211
212
|
const rows = doc.spokes.map(({ agent, provider, model, effort }) => ({ agent, provider, model, effort }));
|
|
212
213
|
const perAgent = new Map();
|
|
213
214
|
const allowedContent = new Map();
|
|
215
|
+
const agentContent = doc.agentContent ? new Map(Object.entries(doc.agentContent)) : undefined;
|
|
214
216
|
const ticketDir = `ticket/${name}`;
|
|
215
217
|
allowedContent.set(`${ticketDir}/_shared.md`, doc.shared);
|
|
216
218
|
for (const spoke of doc.spokes) {
|
|
@@ -219,5 +221,16 @@ export async function loadTicket(source, nameOrLang, maybeLang) {
|
|
|
219
221
|
for (const allowed of spoke.allow)
|
|
220
222
|
allowedContent.set(allowed.path, allowed.content);
|
|
221
223
|
}
|
|
222
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
ticketDir,
|
|
226
|
+
ticketName: name,
|
|
227
|
+
rows,
|
|
228
|
+
shared,
|
|
229
|
+
perAgent,
|
|
230
|
+
allowedContent,
|
|
231
|
+
agentContent,
|
|
232
|
+
status: stored.status,
|
|
233
|
+
sealedAt: stored.sealedAt,
|
|
234
|
+
sealedRepoRoot: doc.sealedRepoRoot,
|
|
235
|
+
};
|
|
223
236
|
}
|
package/dist/validate.js
CHANGED
|
@@ -36,7 +36,10 @@ isModelEnabled,
|
|
|
36
36
|
// 而本模組不持有 db handle——比照 isModelEnabled 收一個回呼,由 cli.ts 接上。
|
|
37
37
|
// 原本這裡直接讀 `process.env[`${PROVIDER}_API_KEY`]`,key 搬進 DB 之後那段就成了
|
|
38
38
|
// 一道「更早的、只認環境變數」的閘門,會在 createAdapterFor 之前先攔下所有派工。
|
|
39
|
-
hasApiKey,
|
|
39
|
+
hasApiKey,
|
|
40
|
+
// Required on purpose: a caller that forgets to forward sealed lens snapshots must
|
|
41
|
+
// fail typechecking, never silently default to disk.
|
|
42
|
+
agentContent, agentsDir = path.join(repoRoot, ".claude", "agents")) {
|
|
40
43
|
const resolved = [];
|
|
41
44
|
// 熱修補(缺口三):同 ticket-store——空表要講「去匯入」,
|
|
42
45
|
// 不要讓 providerUndefinedInRow 把人帶去查工單裡的 provider 名稱。
|
|
@@ -112,7 +115,13 @@ hasApiKey, agentsDir = path.join(repoRoot, ".claude", "agents")) {
|
|
|
112
115
|
allowedReadsResolved.push(real);
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
|
-
const
|
|
118
|
+
const sealedAgent = agentContent?.get(row.agent);
|
|
119
|
+
if (ticket.status === "sealed" && sealedAgent === undefined) {
|
|
120
|
+
throw new DispatchError(m(lang, "ticketSealedLensMissing", ticket.ticketName ?? ticket.ticketDir, row.agent), 2);
|
|
121
|
+
}
|
|
122
|
+
const agentBody = sealedAgent === undefined
|
|
123
|
+
? await readAgentBody(agentsDir, row.agent, lang)
|
|
124
|
+
: stripFrontmatter(sealedAgent);
|
|
116
125
|
const sharedPath = path.join(ticket.ticketDir, "_shared.md");
|
|
117
126
|
const ownAgentPath = path.join(ticket.ticketDir, `${row.agent}.md`);
|
|
118
127
|
const allowSet = ticket.allowedContent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dowafu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
35
|
"providers.json",
|
|
36
|
-
"README_zh-tw.md"
|
|
36
|
+
"README_zh-tw.md",
|
|
37
|
+
"CHANGELOG.md"
|
|
37
38
|
],
|
|
38
39
|
"scripts": {
|
|
39
40
|
"verify:providers": "tsx scripts/verify-providers.ts",
|
package/providers.json
CHANGED
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
"allowed": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
11
11
|
"default": "high"
|
|
12
12
|
},
|
|
13
|
-
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
|
|
13
|
+
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-6-astra"],
|
|
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-09-
|
|
17
|
+
"pricingSource": { "url": "https://developers.openai.com/api/docs/pricing", "asOf": "2026-09-12", "note": "gpt-5.6-sol 為優惠價,官方頁措辭為「at least through 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": 4.00, "cachedInputPerM": 0.40, "outputPerM": 20.00 }
|
|
21
|
+
"gpt-5.6-sol": { "inputPerM": 4.00, "cachedInputPerM": 0.40, "outputPerM": 20.00 },
|
|
22
|
+
"gpt-6-astra": { "inputPerM": 10.00, "cachedInputPerM": 1.00, "outputPerM": 50.00 }
|
|
22
23
|
}
|
|
23
24
|
},
|
|
24
25
|
"deepseek": {
|
|
@@ -31,17 +32,18 @@
|
|
|
31
32
|
"allowed": ["low", "high", "max"],
|
|
32
33
|
"default": "high"
|
|
33
34
|
},
|
|
34
|
-
"models": ["deepseek-v4-flash", "deepseek-v4-pro"],
|
|
35
|
+
"models": ["deepseek-v4-flash", "deepseek-flash", "deepseek-v4-pro"],
|
|
35
36
|
"charsPerToken": 1.0,
|
|
36
37
|
"tpmLimit": null,
|
|
37
38
|
"maxSpokeTokens": null,
|
|
38
39
|
"pricingSource": {
|
|
39
40
|
"url": "https://api-docs.deepseek.com/quick_start/pricing/",
|
|
40
|
-
"asOf": "2026-09-
|
|
41
|
-
"note": "
|
|
41
|
+
"asOf": "2026-09-12",
|
|
42
|
+
"note": "離峰(尖峰時段為週一至週五 01:00-04:00 與 06:00-10:00 UTC,其餘時段皆為離峰)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。deepseek-v4-flash 已於 2026-09-10 退役、目前暫時轉址到 V4.1 Flash(deepseek-flash),官方未給截止日;其價格仍為退役前的舊值,故估算偏高。"
|
|
42
43
|
},
|
|
43
44
|
"pricing": {
|
|
44
45
|
"deepseek-v4-flash": { "inputPerM": 0.44, "cachedInputPerM": 0.014, "outputPerM": 1.32 },
|
|
46
|
+
"deepseek-flash": { "inputPerM": 0.30, "cachedInputPerM": 0.006, "outputPerM": 1.20 },
|
|
45
47
|
"deepseek-v4-pro": { "inputPerM": 1.32, "cachedInputPerM": 0.044, "outputPerM": 3.96 }
|
|
46
48
|
}
|
|
47
49
|
},
|
|
@@ -55,16 +57,17 @@
|
|
|
55
57
|
"allowed": ["minimal", "low", "medium", "high"],
|
|
56
58
|
"default": "high"
|
|
57
59
|
},
|
|
58
|
-
"models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash"],
|
|
60
|
+
"models": ["gemini-3.1-flash-lite", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"],
|
|
59
61
|
"charsPerToken": 1.0,
|
|
60
62
|
"tpmLimit": null,
|
|
61
63
|
"maxSpokeTokens": null,
|
|
62
|
-
"pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-09-
|
|
64
|
+
"pricingSource": { "url": "https://ai.google.dev/gemini-api/docs/pricing", "asOf": "2026-09-12", "note": "文字/圖片/影片價;音訊另計。output 價含 thinking tokens。gemini-3.6-flash/gemini-3.7-flash/gemini-3.8-flash 為優惠價,官方頁載明至 2026-12-31;2027-01-01 起漲為 $1.50 in/$7.50 out(三者皆是)" },
|
|
63
65
|
"pricing": {
|
|
64
66
|
"gemini-3.1-flash-lite": { "inputPerM": 0.25, "cachedInputPerM": 0.025, "outputPerM": 1.50 },
|
|
65
67
|
"gemini-3.5-flash-lite": { "inputPerM": 0.30, "cachedInputPerM": 0.03, "outputPerM": 2.50 },
|
|
66
68
|
"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 }
|
|
69
|
+
"gemini-3.7-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 },
|
|
70
|
+
"gemini-3.8-flash": { "inputPerM": 0.75, "cachedInputPerM": 0.075, "outputPerM": 3.75 }
|
|
68
71
|
}
|
|
69
72
|
},
|
|
70
73
|
"anthropic": {
|