dowafu 0.7.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 CHANGED
@@ -5,6 +5,47 @@ release commits and internal records after the fact; from 0.7.0 on they are writ
5
5
  release time. Versions before 0.5.0 are not covered here — see the git tags (`v0.3.0`
6
6
  onwards).
7
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
+
8
49
  ## 0.7.0 — 2026-09-12
9
50
 
10
51
  ### Added
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
@@ -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
@@ -211,7 +221,7 @@ you already have. To pick up models added in a later release, import the shipped
211
221
  yourself:
212
222
 
213
223
  ```bash
214
- dowafu providers import "$(npm root -g)/dowafu/providers.json"
224
+ dowafu providers import --bundled
215
225
  dowafu providers list # confirm the new models are there
216
226
  ```
217
227
 
@@ -230,7 +240,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
230
240
  against.
231
241
 
232
242
  ```bash
233
- npx degit eyesofkids/dowafu/publish/en#v0.7.0 .claude-pack # or publish/zh-tw
243
+ npx degit eyesofkids/dowafu/publish/en#v0.8.0 .claude-pack # or publish/zh-tw
234
244
 
235
245
  cd .claude-pack
236
246
  TARGET=<your project>
@@ -252,7 +262,8 @@ in its own language.
252
262
 
253
263
  An agent can drive `dowafu` over MCP instead of through the shell. Five tools are
254
264
  exposed: `dispatch_tickets`, `dispatch_submit`, `dispatch_status`, `dispatch_result`
255
- 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.
256
267
 
257
268
  There are two transports, and the difference that matters is who starts what.
258
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
@@ -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 會直接比對——必須完全使用以下兩組
@@ -196,7 +204,7 @@ dowafu providers import <path> # 匯入自己的設定檔
196
204
  你既有的價格或啟用狀態。要取得後續版本新增的 Model,請自己匯入隨套件出貨的那一份:
197
205
 
198
206
  ```bash
199
- dowafu providers import "$(npm root -g)/dowafu/providers.json"
207
+ dowafu providers import --bundled
200
208
  dowafu providers list # 確認新的 Model 已經進來
201
209
  ```
202
210
 
@@ -217,7 +225,7 @@ dowafu providers list # 確認新的 Model 已經進來
217
225
  `publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
218
226
 
219
227
  ```bash
220
- npx degit eyesofkids/dowafu/publish/zh-tw#v0.7.0 .claude-pack # 或 publish/en
228
+ npx degit eyesofkids/dowafu/publish/zh-tw#v0.8.0 .claude-pack # 或 publish/en
221
229
 
222
230
  cd .claude-pack
223
231
  TARGET=<your project>
@@ -242,7 +250,7 @@ publish/zh-tw/README.md (或 publish/en/README.md)
242
250
  ## 接成 MCP server
243
251
 
244
252
  Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dispatch_tickets`、
245
- `dispatch_submit`、`dispatch_status`、`dispatch_result`、`dispatch_approve`。
253
+ `dispatch_submit`、`dispatch_status`、`dispatch_result`、`dispatch_approve`。`dispatch_submit` 只接受已定稿的工單;定稿只能從 CLI 做,沒有對應的 MCP tool。
246
254
 
247
255
  有兩條路,差別在**誰啟動誰**。
248
256
 
package/dist/cli.js CHANGED
@@ -34,7 +34,7 @@ import { parseArgs, formatEvent, resolveLang, resolveFallbackLang, buildStdoutSu
34
34
  import { m } from "./messages.js";
35
35
  import { defaultDbPath, openDb } from "./db.js";
36
36
  import { issueToken, listTokens, revokeToken } from "./api-token.js";
37
- 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";
38
38
  import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
39
39
  import { serve, stopDaemon } from "./daemon.js";
40
40
  import { daemonAlive, daemonStatePath } from "./liveness.js";
@@ -179,6 +179,20 @@ async function ticketCommand(argv, lang) {
179
179
  await importTicketDirectory(db, dir, suppliedName ?? path.basename(path.resolve(dir)), repoRoot, lang);
180
180
  return;
181
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
+ }
182
196
  case "show": {
183
197
  const [name] = rest;
184
198
  if (!name)
@@ -216,10 +230,25 @@ async function providersCommand(argv, lang) {
216
230
  return;
217
231
  }
218
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
+ }
219
245
  const [filePath] = rest;
220
246
  if (!filePath)
221
247
  throw new DispatchError(m(lang, "providersHelp", command), 2);
222
- mergeProviders(db, await readProvidersFile(path.resolve(filePath), lang), path.resolve(filePath) === bundledProvidersPath());
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());
223
252
  console.log(m(lang, "providersImported", filePath));
224
253
  return;
225
254
  }
@@ -591,6 +620,9 @@ async function main() {
591
620
  const before = pending[0];
592
621
  if (!before)
593
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);
594
626
  // Render the complete paid-work summary before changing the gate state.
595
627
  // A corrupted job therefore stays pending instead of becoming approved with
596
628
  // no information shown to the human operator.
@@ -638,7 +670,7 @@ async function main() {
638
670
  const reason = err instanceof DispatchError ? err.message : maskString(String(err));
639
671
  providersResult = { ok: false, reason };
640
672
  }
641
- 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());
642
674
  console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
643
675
  return;
644
676
  }
@@ -724,7 +756,7 @@ async function main() {
724
756
  // §10 步驟 4–5:effort 值域、API key 齊備、models 白名單(resolveSpokes 內部)。
725
757
  // 兩個回呼都是必填:validate.ts 不持有 db handle,「這個型號開著嗎」與「這個 provider
726
758
  // 有沒有 key」都由這裡接上——漏傳編不過(typecheck 涵蓋測試檔)。
727
- 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);
728
760
  // §10 步驟 6/§14 閘門一:呼叫前估算
729
761
  const estimates = spokes.map((spoke) => {
730
762
  const systemPrompt = buildSystemPrompt(spoke.agentBody, spoke.lang);
@@ -759,6 +791,7 @@ async function main() {
759
791
  const plan = buildJsonPlan(spokes, estimates, allowlistEstimates, options);
760
792
  const buildPayload = (mode, results, audits, toolCallAudits, exitCode) => buildJsonPayload({
761
793
  ticketId,
794
+ ticketStatus: ticket.status,
762
795
  repoRoot,
763
796
  outDir,
764
797
  providersSource,
@@ -791,6 +824,9 @@ async function main() {
791
824
  }
792
825
  return;
793
826
  }
827
+ if (ticket.status !== "sealed") {
828
+ throw new DispatchError(m(lang, "ticketDraftRunBlocked", ticketName, getCommandName()), 5);
829
+ }
794
830
  // 擋在 confirm 之前:不要先問「要不要花錢」再中止。exitCode 5 是「派工前被守則擋下、
795
831
  // 未花任何錢」,與 3(成本閘門超限)分開,才分得出是哪一種擋。
796
832
  // **刻意不提供 --overwrite 之類的旗標**:有旗標,「加上去」就會變成最便宜的滿足方式,
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
- if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
46
- throw new Error(`ticket does not exist: ${ticket}`);
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);
@@ -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,
@@ -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, getTicketDoc, getTicketHardCapUsd } from "../ticket-store.js";
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 that can be submitted, including their spokes and allowlist file counts.",
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. It remains pending human terminal approval and is not executed by this tool.",
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 doc = getTicketDoc(options.db, ticket, options.lang);
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
@@ -138,6 +138,8 @@ const MESSAGES = {
138
138
  mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
139
139
  mcpTicketsTitle: () => "可派工單:",
140
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 已凍結,可送出。`,
141
143
  mcpTicketsSpoke: (agent, provider, model, allowCount) => ` ${agent}:${provider}/${model};允許清單檔 ${allowCount} 個`,
142
144
  mcpKeysTitle: () => "API key 狀態:",
143
145
  mcpKeysRow: (provider, status) => ` ${provider}:${status}`,
@@ -221,11 +223,19 @@ const MESSAGES = {
221
223
  ticketNameInvalid: (name) => `工單名稱無效:${name}`,
222
224
  ticketAlreadyExists: (name) => `DB 工單已存在:${name}`,
223
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 已凍結,可送出。`,
224
234
  ticketShowHeader: (name) => `DB 工單 ${name}:`,
225
235
  ticketEstimateTokens: (tokens) => `估算 ${tokens} tokens`,
226
236
  ticketCompletedRuns: (runs, total, last) => `已完成 ${runs} 次;累計 $${total};最近一次 ${last}`,
227
237
  ticketCompletedRunsNoPricing: (runs, last) => `已完成 ${runs} 次;累計無價目資料;最近一次 ${last}`,
228
- 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 須為管道/重導向,不得是互動式終端機)`,
229
239
  ticketStdinRequiresPipe: () => `此指令用 - 讀取 stdin 本文,但目前 stdin 是互動式終端機,沒有東西被導進來。` +
230
240
  `請改用管道/重導向提供內容(例如 echo "..." | dowafu ticket create <name> -),或改用一般檔案路徑。`,
231
241
  agentDefNotFound: (path, agent) => `找不到 agent 定義檔 ${path}(_dispatch.md 列了 "${agent}")`,
@@ -258,7 +268,7 @@ const MESSAGES = {
258
268
  modelDisabled: (model, cmd) => `型號 "${model}" 已停用;請先執行 ${cmd} providers enable ${model}`,
259
269
  providerModelNotFound: (model) => `找不到型號:${model}`,
260
270
  providerModelAmbiguous: (model, providers) => `型號 "${model}" 同時存在於:${providers}。請用 ${"dowafu"} providers enable <provider> ${model} 指定目標;可先看 providers list。`,
261
- 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>]`,
262
272
  providersListEmpty: () => "providers DB 尚無型號;請明確執行 providers import。",
263
273
  providersListRow: (provider, model, input, output, enabled) => `${provider}\t${model}\tinput $${input}/M\toutput $${output}/M\t${enabled ? "enabled" : "disabled"}`,
264
274
  providersImported: (path) => `已合流匯入 providers:${path}`,
@@ -344,6 +354,7 @@ const MESSAGES = {
344
354
  doctorConfigDirSourceDefault: (dir) => `${dir}(來源:預設;DISPATCH_HOME 與 XDG_CONFIG_HOME 都沒設)`,
345
355
  doctorConfigDirUnresolved: () => "無法解析(沒有 HOME)",
346
356
  doctorDbLine: (dbPath) => ` 資料庫 ${dbPath}`,
357
+ doctorBundledProvidersLine: (filePath) => ` 出貨型號檔 ${filePath}`,
347
358
  doctorEnvLine: (value) => ` .env ${value}`,
348
359
  doctorEnvPresentValue: () => "有,但本版不讀取(API key 請用 `dowafu key` 設定進 DB)",
349
360
  doctorApiKeyLine: (value) => ` API key ${value}`,
@@ -477,6 +488,8 @@ Subcommands (each has its own usage message):
477
488
  mcpTicketsEmpty: () => "No dispatchable DB tickets.",
478
489
  mcpTicketsTitle: () => "Dispatchable tickets:",
479
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.`,
480
493
  mcpTicketsSpoke: (agent, provider, model, allowCount) => ` ${agent}: ${provider}/${model}; allowlist files=${allowCount}`,
481
494
  mcpKeysTitle: () => "API key status:",
482
495
  mcpKeysRow: (provider, status) => ` ${provider}: ${status}`,
@@ -563,11 +576,19 @@ Subcommands (each has its own usage message):
563
576
  ticketNameInvalid: (name) => `Invalid ticket name: ${name}`,
564
577
  ticketAlreadyExists: (name) => `DB ticket already exists: ${name}`,
565
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.`,
566
587
  ticketShowHeader: (name) => `DB ticket ${name}:`,
567
588
  ticketEstimateTokens: (tokens) => `est. ${tokens} tokens`,
568
589
  ticketCompletedRuns: (runs, total, last) => `Completed ${runs} times; total $${total}; last ${last}`,
569
590
  ticketCompletedRunsNoPricing: (runs, last) => `Completed ${runs} times; total No pricing data; last ${last}`,
570
- 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)`,
571
592
  ticketStdinRequiresPipe: () => "This command reads the body from stdin via -, but stdin is an interactive terminal right now; nothing is being piped in. " +
572
593
  'Pipe or redirect content instead (for example, echo "..." | dowafu ticket create <name> -), or use a regular file path.',
573
594
  agentDefNotFound: (path, agent) => `Agent definition file not found: ${path} (_dispatch.md lists "${agent}")`,
@@ -603,7 +624,7 @@ Subcommands (each has its own usage message):
603
624
  modelDisabled: (model, cmd) => `Model "${model}" is disabled; first run ${cmd} providers enable ${model}`,
604
625
  providerModelNotFound: (model) => `Model not found: ${model}`,
605
626
  providerModelAmbiguous: (model, providers) => `Model "${model}" exists in: ${providers}. Use dowafu providers enable <provider> ${model}; see providers list.`,
606
- 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>]`,
607
628
  providersListEmpty: () => "The providers DB has no models; explicitly run providers import.",
608
629
  providersListRow: (provider, model, input, output, enabled) => `${provider}\t${model}\tinput $${input}/M\toutput $${output}/M\t${enabled ? "enabled" : "disabled"}`,
609
630
  providersImported: (path) => `Providers merged from: ${path}`,
@@ -686,6 +707,7 @@ Subcommands (each has its own usage message):
686
707
  doctorConfigDirSourceDefault: (dir) => `${dir} (source: default; neither DISPATCH_HOME nor XDG_CONFIG_HOME is set)`,
687
708
  doctorConfigDirUnresolved: () => "could not resolve (no HOME)",
688
709
  doctorDbLine: (dbPath) => ` Database ${dbPath}`,
710
+ doctorBundledProvidersLine: (filePath) => ` Bundled providers.json ${filePath}`,
689
711
  doctorEnvLine: (value) => ` .env ${value}`,
690
712
  doctorEnvPresentValue: () => "present, but this version does not read it (set API keys with `dowafu key`)",
691
713
  doctorApiKeyLine: (value) => ` API keys ${value}`,
@@ -10,6 +10,7 @@ import { isModelEnabled, loadProviders } from "./providers-store.js";
10
10
  import { getCommandName } from "./pkg-info.js";
11
11
  import { formatModelPricing } from "./report.js";
12
12
  import { DEFAULT_MAX_TOOL_CALLS, estimateHardCapUsd } from "./cost.js";
13
+ export const TICKET_STATUSES = ["draft", "sealed"];
13
14
  function parseDoc(raw, name, lang) {
14
15
  try {
15
16
  const value = JSON.parse(raw);
@@ -22,12 +23,30 @@ function parseDoc(raw, name, lang) {
22
23
  }
23
24
  }
24
25
  export function getTicketDoc(db, name, lang) {
25
- const row = db.prepare("SELECT doc FROM tickets WHERE name = ?").get(name);
26
- if (!row?.doc)
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)
27
34
  throw new DispatchError(m(lang, "ticketNotFound", name), 2);
28
- return parseDoc(row.doc, name, lang);
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
+ };
29
43
  }
30
- function put(db, name, doc) {
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);
31
50
  db.prepare("UPDATE tickets SET doc = ? WHERE name = ?").run(JSON.stringify(doc), name);
32
51
  }
33
52
  export function createTicket(db, name, shared, lang) {
@@ -36,7 +55,8 @@ export function createTicket(db, name, shared, lang) {
36
55
  if (isReservedTicketName(name))
37
56
  throw new DispatchError(m(lang, "ticketNameReserved", name, reservedTicketNames().join(", ")), 2);
38
57
  try {
39
- db.prepare("INSERT INTO tickets (name, doc, created_at) VALUES (?, ?, ?)").run(name, JSON.stringify({ shared, spokes: [] }), now());
58
+ db.prepare("INSERT INTO tickets (name, doc, status, sealed_at, created_at) VALUES (?, ?, ?, ?, ?)")
59
+ .run(name, JSON.stringify({ shared, spokes: [] }), "draft", null, now());
40
60
  }
41
61
  catch {
42
62
  throw new DispatchError(m(lang, "ticketAlreadyExists", name), 2);
@@ -60,7 +80,7 @@ export function addSpoke(db, name, spoke, lang) {
60
80
  if (!isModelEnabled(db, spoke.provider, spoke.model))
61
81
  throw new DispatchError(m(lang, "modelDisabled", spoke.model, getCommandName()), 6);
62
82
  doc.spokes.push({ ...spoke, allow: [] });
63
- put(db, name, doc);
83
+ put(db, name, doc, lang);
64
84
  }
65
85
  function isUnderDocs(repoRoot, target) {
66
86
  const docs = path.resolve(repoRoot, "_docs");
@@ -86,7 +106,47 @@ export async function addAllow(db, name, agent, rel, repoRoot, lang) {
86
106
  spoke.allow[i] = { path: rel, content };
87
107
  else
88
108
  spoke.allow.push({ path: rel, content });
89
- 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);
90
150
  }
91
151
  export async function importTicketDirectory(db, dir, name, repoRoot, lang) {
92
152
  const dispatch = await readFile(path.join(dir, "_dispatch.md"), "utf8");
@@ -157,8 +217,14 @@ export function getTicketHardCapUsd(db, name, lang, maxToolCalls = DEFAULT_MAX_T
157
217
  return parts.some((p) => p === null) ? null : parts.reduce((sum, p) => sum + p, 0);
158
218
  }
159
219
  export function formatTicket(db, name, lang, maxToolCalls = DEFAULT_MAX_TOOL_CALLS) {
160
- const doc = getTicketDoc(db, name, lang);
161
- const lines = [m(lang, "ticketShowHeader", name)];
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}`];
162
228
  for (const spoke of doc.spokes) {
163
229
  const chars = spoke.allow.reduce((sum, item) => sum + item.content.length, 0);
164
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 { getTicketDoc } from "./ticket-store.js";
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 doc = getTicketDoc(source, name, lang);
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 { ticketDir, ticketName: name, rows, shared, perAgent, allowedContent };
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, agentsDir = path.join(repoRoot, ".claude", "agents")) {
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 agentBody = await readAgentBody(agentsDir, row.agent, lang);
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.7.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",