dowafu 0.5.0 → 0.6.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/README.md CHANGED
@@ -208,7 +208,7 @@ not work: a reviewer's fixed closing line has to match the template the audit ch
208
208
  against.
209
209
 
210
210
  ```bash
211
- npx degit eyesofkids/dowafu/publish/en#v0.5.0 .claude-pack # or publish/zh-tw
211
+ npx degit eyesofkids/dowafu/publish/en#v0.6.0 .claude-pack # or publish/zh-tw
212
212
 
213
213
  cd .claude-pack
214
214
  TARGET=<your project>
@@ -244,8 +244,9 @@ There are two transports, and the difference that matters is who starts what.
244
244
  ### Both transports need `dowafu serve`
245
245
 
246
246
  `dowafu mcp` only answers tool calls — **it never runs a reviewer**. The worker that
247
- actually spends money lives in `dowafu serve`. Without one running, an approved job sits
248
- in the queue forever and nothing tells the agent why.
247
+ actually spends money lives in `dowafu serve`. Without one running, an approved job never
248
+ runs — `dispatch_submit`, `dispatch_approve` and `dispatch_status` each print a `⚠` line
249
+ saying so.
249
250
 
250
251
  ```
251
252
  stdio: your client ──spawn──→ dowafu mcp ──┐
@@ -255,9 +256,11 @@ stdio: your client ──spawn──→ dowafu mcp ──┐
255
256
  HTTP: your client ──x-api-key──→ dowafu serve (endpoint and worker, one process)
256
257
  ```
257
258
 
258
- So on stdio you keep `dowafu serve` running in a terminal next to the client. The order
259
- is forgiving there — start it before or after approving, and the daemon picks the job up
260
- on its next tick.
259
+ So on stdio you keep `dowafu serve` running in a terminal next to the client. **Start it
260
+ before you approve.** A daemon only runs approvals made while it is up: when it starts,
261
+ and again when it stops, any job still waiting in `approved` goes back to pending approval,
262
+ so an approval given before the daemon was running has to be given again. `dowafu serve`
263
+ prints how many it sent back.
261
264
 
262
265
  ### stdio
263
266
 
@@ -353,6 +356,25 @@ With the daemon always up the stdio path needs nothing extra — `dowafu mcp` fi
353
356
  worker already running. Typing `dowafu serve` yourself will then refuse with
354
357
  `daemon already running (pid N)`: that is the guard working, not a fault.
355
358
 
359
+ ### Stopping and restarting
360
+
361
+ `dowafu serve --stop` stops the daemon; `dowafu serve --restart` stops it and starts a new
362
+ one (or just starts one if none is running). When something would be affected, both ask
363
+ first — separately — about:
364
+
365
+ - **jobs still running** — stopping the daemon does not stop a reviewer already in flight.
366
+ It runs to completion, and its results and API cost are still recorded.
367
+ - **jobs approved but not yet started** — they go back to pending approval, so the next
368
+ daemon cannot run them without a fresh approval.
369
+
370
+ With no terminal to answer (an agent calling it, a script), a question counts as "no" and
371
+ nothing is stopped; pass `--yes` to go ahead.
372
+
373
+ If the daemon ignores SIGTERM, `--stop` says so and prints the pid to inspect. Do not reach
374
+ for SIGKILL: a reviewer's worker process can outlive the daemon and still write its results.
375
+ And if the daemon comes straight back after stopping, a service manager (the `KeepAlive` /
376
+ `Restart=always` above) is restarting it — stop it with `launchctl` or `systemctl` instead.
377
+
356
378
  ### Approval is still a separate step
357
379
 
358
380
  `dispatch_submit` queues a job and stops; nothing is billed until it is approved. Two
package/README_zh-tw.md CHANGED
@@ -199,7 +199,7 @@ dowafu providers import <path> # 匯入自己的設定檔
199
199
  `publish/` 提供兩種語言:`publish/en/` 與 `publish/zh-tw/`。**擇一複製,不可混裝**——審查者的固定收尾句必須與稽核檢查用的範本是同一種語言。
200
200
 
201
201
  ```bash
202
- npx degit eyesofkids/dowafu/publish/zh-tw#v0.5.0 .claude-pack # 或 publish/en
202
+ npx degit eyesofkids/dowafu/publish/zh-tw#v0.6.0 .claude-pack # 或 publish/en
203
203
 
204
204
  cd .claude-pack
205
205
  TARGET=<your project>
@@ -238,8 +238,8 @@ Agent 可以不走 shell,改用 MCP 驅動 `dowafu`。露出五個 tool:`dis
238
238
  ### 兩條路都需要 `dowafu serve`
239
239
 
240
240
  `dowafu mcp` 只回應 tool 呼叫,**它不執行任何 reviewer**。真正花錢的 worker 在
241
- `dowafu serve` 裡。沒有它,核准過的 job 會永遠留在佇列裡,而且沒有任何東西會告訴 agent
242
- 為什麼。
241
+ `dowafu serve` 裡。沒有它,核准過的 job 永遠不會執行——`dispatch_submit`、`dispatch_approve`、
242
+ `dispatch_status` 都會印一行 `⚠` 說明這件事。
243
243
 
244
244
  ```
245
245
  stdio: 你的 client ──spawn──→ dowafu mcp ──┐
@@ -249,8 +249,9 @@ stdio: 你的 client ──spawn──→ dowafu mcp ──┐
249
249
  HTTP: 你的 client ──x-api-key──→ dowafu serve (端點與 worker 同一個行程)
250
250
  ```
251
251
 
252
- 所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。這條路的順序很寬鬆
253
- ——核准之前或之後才起都可以,daemon 下一個 tick 就會把它撿走。
252
+ 所以走 stdio 時,要在 client 旁邊另開一個終端機讓 `dowafu serve` 跑著。**要先起 daemon,再核准。**
253
+ daemon 只執行它在線期間核准的 job:它啟動時、以及停止時,還停在「已核准」的 job 會被退回待核准,
254
+ 所以在 daemon 起來之前給的核准要再給一次。`dowafu serve` 會印出退回了幾張。
254
255
 
255
256
  ### stdio
256
257
 
@@ -339,6 +340,20 @@ daemon 常駐之後,stdio 那條路不需要任何額外動作——`dowafu mc
339
340
  這時自己再打 `dowafu serve` 會被擋下並回報 `daemon already running (pid N)`:
340
341
  **那是守衛在運作,不是故障。**
341
342
 
343
+ ### 停止與重啟
344
+
345
+ `dowafu serve --stop` 停掉 daemon;`dowafu serve --restart` 停掉之後再起一支新的
346
+ (沒在跑的話就直接起)。有東西會受影響時,兩者都會先分開問兩件事:
347
+
348
+ - **還在執行的 job**——停掉 daemon 不會停掉已經在跑的審查者,它會跑完,結果與 API 成本照樣記帳。
349
+ - **已核准但還沒開始的 job**——會退回待核准,下一支 daemon 沒有新的核准就不會跑它們。
350
+
351
+ 沒有終端機可以回答時(agent 代為呼叫、腳本),問題一律視為「不要」,什麼都不會停;要繼續就帶 `--yes`。
352
+
353
+ daemon 收到 SIGTERM 卻沒停下來時,`--stop` 會明說並印出要檢查的 pid。**不要改用 SIGKILL**:
354
+ 審查者的 worker 行程可能比 daemon 活得久,照樣會寫入結果。停掉之後 daemon 馬上又回來,
355
+ 代表有服務管理器(上面的 `KeepAlive`/`Restart=always`)在重拉它——那要用 `launchctl` 或 `systemctl` 停。
356
+
342
357
  ### 核准仍然是獨立的一步
343
358
 
344
359
  `dispatch_submit` 只把 job 排進佇列就停下,核准之前不會計費。兩種核准方式:在自己的終端機下
package/dist/cli-args.js CHANGED
@@ -104,6 +104,7 @@ export function parseArgs(argv, lang) {
104
104
  let doctorMode = false;
105
105
  let serveMode = false;
106
106
  let serveStop = false;
107
+ let serveRestart = false;
107
108
  let mcpMode = false;
108
109
  const numFlag = (name, apply) => {
109
110
  flagHandlers[name] = (v) => {
@@ -159,6 +160,9 @@ export function parseArgs(argv, lang) {
159
160
  else if (arg === "--stop") {
160
161
  serveStop = true;
161
162
  }
163
+ else if (arg === "--restart") {
164
+ serveRestart = true;
165
+ }
162
166
  else if (arg === "mcp") {
163
167
  mcpMode = true;
164
168
  }
@@ -190,16 +194,18 @@ export function parseArgs(argv, lang) {
190
194
  if (ticketDir !== undefined) {
191
195
  throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "--doctor", helpText), 2);
192
196
  }
193
- if (serveStop)
197
+ if (serveStop || serveRestart)
194
198
  throw new DispatchError(m(lang, "stopRequiresServe"), 2);
195
199
  return { mode: "doctor", options };
196
200
  }
197
201
  if (serveMode) {
198
202
  if (ticketDir !== undefined)
199
203
  throw new DispatchError(m(lang, "tooManyArgs", ticketDir, "serve", helpText), 2);
200
- return { mode: "serve", options, stop: serveStop };
204
+ if (serveStop && serveRestart)
205
+ throw new DispatchError(m(lang, "stopRestartConflict"), 2);
206
+ return { mode: "serve", options, stop: serveStop, restart: serveRestart };
201
207
  }
202
- if (serveStop)
208
+ if (serveStop || serveRestart)
203
209
  throw new DispatchError(m(lang, "stopRequiresServe"), 2);
204
210
  if (mcpMode) {
205
211
  if (ticketDir !== undefined)
package/dist/cli.js CHANGED
@@ -34,7 +34,7 @@ import { m } from "./messages.js";
34
34
  import { defaultDbPath, openDb } from "./db.js";
35
35
  import { issueToken, listTokens, revokeToken } from "./api-token.js";
36
36
  import { addAllow, addSpoke, createTicket, formatTicket, hasResults, importTicketDirectory } from "./ticket-store.js";
37
- import { approveJob, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
37
+ import { approveJob, countQueueJobs, findJobsByIdPrefix, getJob, listPendingApprovalJobs } from "./job.js";
38
38
  import { serve, stopDaemon } from "./daemon.js";
39
39
  import { daemonAlive, daemonStatePath } from "./liveness.js";
40
40
  import { startStdioServer } from "./mcp/stdio.js";
@@ -611,6 +611,9 @@ async function main() {
611
611
  // 工單 key-db §B-3 #6:逐 provider 印出實際生效的是哪一層——doctorDb 開不起來時就沒有
612
612
  // DB 可查,keyStatusRows 留空陣列(buildApiKeyValue 對空陣列的降級輸出)。
613
613
  let keyStatusRows = [];
614
+ // A database failure must leave --doctor usable. Its JSON contract still has
615
+ // numeric queue fields, so the unavailable DB degrades to zero counts.
616
+ let queue = { pendingApproval: 0, approved: 0, running: 0 };
614
617
  // A(gaps #1):整份報表——DB、金鑰、型號白名單、daemon——都要來自**這次真的會用到的**
615
618
  // 那個 DB。先前這裡寫死 undefined/defaultDbPath(),於是 `--doctor --db <別的>` 印的
616
619
  // 全是預設 DB 的狀況,而使用者指定的那個檔連建都不會建。
@@ -620,49 +623,60 @@ async function main() {
620
623
  const providers = loadProviders(doctorDb, messageLang);
621
624
  providersResult = { ok: true, providers, models: listModels(doctorDb) };
622
625
  keyStatusRows = buildKeyStatusRows(doctorDb);
626
+ queue = countQueueJobs(doctorDb);
623
627
  }
624
628
  catch (err) {
625
629
  const reason = err instanceof DispatchError ? err.message : maskString(String(err));
626
630
  providersResult = { ok: false, reason };
627
631
  }
628
- const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath);
632
+ const doctorData = buildDoctorReportData(getCommandName(), providersResult, process.env, undefined, undefined, undefined, daemonAlive(daemonStatePath(doctorDbPath)), keyStatusRows, doctorDbPath, queue);
629
633
  console.log(parsed.options.json ? JSON.stringify(doctorData) : renderDoctorReport(messageLang, getCommandName(), doctorData));
630
634
  return;
631
635
  }
632
636
  if (parsed.mode === "serve") {
633
- if (parsed.stop) {
637
+ if (parsed.stop || parsed.restart) {
634
638
  const outcome = await stopDaemon(parsed.options.dbPath, {
635
639
  force: parsed.options.yes,
636
640
  confirmRunningJobs: async (count) => {
637
641
  console.log(m(messageLang, "daemonStopRunningWarning", count));
638
642
  return confirm(m(messageLang, "daemonStopConfirmPrompt"), process.stdout);
639
643
  },
644
+ confirmApprovedJobs: async (count) => {
645
+ console.log(m(messageLang, "daemonStopApprovedWarning", count));
646
+ return confirm(m(messageLang, "daemonStopApprovedConfirmPrompt"), process.stdout);
647
+ },
640
648
  });
641
649
  switch (outcome.status) {
642
650
  case "not_running":
643
651
  console.log(m(messageLang, "daemonStopNotRunning", outcome.reason));
652
+ if (parsed.restart)
653
+ break;
644
654
  break;
645
655
  case "cancelled":
646
656
  console.log(m(messageLang, "daemonStopCancelled"));
647
- break;
657
+ return;
648
658
  case "pid_not_dowafu":
649
659
  console.log(m(messageLang, "daemonStopPidNotDowafu", outcome.pid));
650
- break;
660
+ return;
651
661
  case "signal_failed":
652
662
  console.log(m(messageLang, "daemonStopSignalFailed", outcome.pid));
653
- break;
663
+ console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
664
+ return;
654
665
  case "stopped":
655
666
  console.log(m(messageLang, "daemonStopSucceeded", outcome.pid));
667
+ console.log(m(messageLang, "daemonStopApprovedCleaned", outcome.requeuedApprovedJobs));
656
668
  break;
657
669
  case "restarted":
658
670
  console.log(m(messageLang, "daemonStopRestarted", outcome.pid));
659
- break;
671
+ return;
660
672
  }
661
- return;
673
+ if (parsed.stop)
674
+ return;
662
675
  }
663
676
  try {
664
677
  const daemon = await serve(parsed.options.dbPath, undefined, parsed.options.httpPort, messageLang);
665
- console.log(m(messageLang, "daemonStarted"));
678
+ console.log(m(messageLang, parsed.restart ? "daemonRestartStarted" : "daemonStarted"));
679
+ console.log(m(messageLang, "daemonStartApprovedCleaned", daemon.requeuedApprovedJobs));
666
680
  console.log(m(messageLang, "httpServerStarted", daemon.httpPort));
667
681
  // B-1 #6:端點活著但沒發過 token,任何請求都進不來——失敗長得像成功,必須警告。
668
682
  if (!daemon.hasActiveToken)
package/dist/daemon.js CHANGED
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { hasAnyActiveToken } from "./api-token.js";
4
4
  import { defaultDbPath, openDb } from "./db.js";
5
- import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs } from "./job.js";
5
+ import { claimNextJob, finishJob, heartbeatJob, reapInterruptedJobs, requeueApprovedJobs } from "./job.js";
6
6
  import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, writeHeartbeat } from "./liveness.js";
7
7
  import { createHttpServer, DEFAULT_HTTP_PORT } from "./mcp/http.js";
8
8
  import { maskString } from "./mask.js";
@@ -79,9 +79,13 @@ export function workerFailureMessage(lang, code, signal, stderr) {
79
79
  export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_DEPS) {
80
80
  const sourceMode = fileURLToPath(import.meta.url).endsWith(".ts");
81
81
  const cliPath = fileURLToPath(new URL(sourceMode ? "./cli.ts" : "./cli.js", import.meta.url));
82
+ // v5 jobs carry an explicit language; rows queued by an older client keep NULL and retain
83
+ // the daemon-startup fallback. Pass the resolved value to the child so its prompt and
84
+ // stdout use the same language as the daemon's own failure message.
85
+ const jobLang = job.lang ?? lang;
82
86
  const args = sourceMode
83
- ? ["--import", "tsx", cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath]
84
- : [cliPath, job.ticket, "--yes", "--job-id", job.id, "--db", dbPath];
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];
85
89
  return new Promise((resolve) => {
86
90
  const child = deps.spawn(process.execPath, args, { stdio: ["inherit", "inherit", "pipe"], env: process.env });
87
91
  let stderrTail = Buffer.alloc(0);
@@ -90,12 +94,12 @@ export function runCliJob(job, dbPath, lang = "en", deps = DEFAULT_RUN_CLI_JOB_D
90
94
  deps.stderr.write(chunk);
91
95
  stderrTail = Buffer.concat([stderrTail, chunk]).subarray(-WORKER_STDERR_TAIL_BYTES);
92
96
  });
93
- child.once("error", (err) => resolve({ error: m(lang, "daemonWorkerSpawnFailed", maskString(String(err))) }));
97
+ child.once("error", (err) => resolve({ error: m(jobLang, "daemonWorkerSpawnFailed", maskString(String(err))) }));
94
98
  child.once("exit", (code, signal) => {
95
99
  if (code === 0)
96
100
  resolve({});
97
101
  else
98
- resolve({ error: workerFailureMessage(lang, code, signal, stderrTail.toString("utf8")) });
102
+ resolve({ error: workerFailureMessage(jobLang, code, signal, stderrTail.toString("utf8")) });
99
103
  });
100
104
  });
101
105
  }
@@ -108,6 +112,11 @@ const DEFAULT_STOP_DAEMON_DEPS = {
108
112
  const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'running'").get();
109
113
  return row.count;
110
114
  },
115
+ countApprovedJobs: (dbPath) => {
116
+ const row = openDb(dbPath).prepare("SELECT COUNT(*) AS count FROM jobs WHERE status = 'approved'").get();
117
+ return row.count;
118
+ },
119
+ requeueApprovedJobs: (dbPath) => requeueApprovedJobs(openDb(dbPath)),
111
120
  processCommand: (pid) => {
112
121
  try {
113
122
  return execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -116,6 +125,15 @@ const DEFAULT_STOP_DAEMON_DEPS = {
116
125
  return null;
117
126
  }
118
127
  },
128
+ processExists: (pid) => {
129
+ try {
130
+ process.kill(pid, 0);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ },
119
137
  kill: (pid, signal) => process.kill(pid, signal),
120
138
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
121
139
  };
@@ -128,28 +146,33 @@ export async function stopDaemon(dbPath = defaultDbPath(), options = {}) {
128
146
  const initial = deps.liveness(statePath);
129
147
  if (!initial.alive)
130
148
  return { status: "not_running", reason: initial.reason };
131
- const runningJobs = deps.countRunningJobs(dbPath);
132
- if (runningJobs > 0 && !options.force) {
133
- const confirmed = await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
134
- if (!confirmed)
135
- return { status: "cancelled", runningJobs };
136
- }
137
149
  if (!isDowafuProcess(deps.processCommand(initial.pid)))
138
150
  return { status: "pid_not_dowafu", pid: initial.pid };
151
+ const runningJobs = deps.countRunningJobs(dbPath);
152
+ const approvedJobs = deps.countApprovedJobs(dbPath);
153
+ const confirmedRunning = runningJobs === 0 || options.force || await (options.confirmRunningJobs?.(runningJobs) ?? Promise.resolve(false));
154
+ // Ask independently even when the stop confirmation was declined. A running
155
+ // worker warning and an old-approval cleanup warning describe different risks.
156
+ const confirmedApproved = approvedJobs === 0 || options.force || await (options.confirmApprovedJobs?.(approvedJobs) ?? Promise.resolve(false));
157
+ if (!confirmedRunning || !confirmedApproved)
158
+ return { status: "cancelled", runningJobs, approvedJobs };
159
+ const requeuedApprovedJobs = deps.requeueApprovedJobs(dbPath);
139
160
  try {
140
161
  deps.kill(initial.pid, "SIGTERM");
141
162
  }
142
163
  catch {
143
- return { status: "signal_failed", pid: initial.pid };
164
+ return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
144
165
  }
145
166
  for (let attempt = 0; attempt < STOP_CHECK_ATTEMPTS; attempt++) {
146
167
  await deps.wait(STOP_CHECK_INTERVAL_MS);
147
- if (!deps.liveness(statePath).alive) {
168
+ if (!deps.liveness(statePath).alive && !deps.processExists(initial.pid)) {
148
169
  await deps.wait(STOP_RESTART_WAIT_MS);
149
- return deps.liveness(statePath).alive ? { status: "restarted", pid: initial.pid } : { status: "stopped", pid: initial.pid };
170
+ return deps.liveness(statePath).alive
171
+ ? { status: "restarted", pid: initial.pid }
172
+ : { status: "stopped", pid: initial.pid, requeuedApprovedJobs };
150
173
  }
151
174
  }
152
- return { status: "signal_failed", pid: initial.pid };
175
+ return { status: "signal_failed", pid: initial.pid, requeuedApprovedJobs };
153
176
  }
154
177
  function bindHttp(db, dbPath, httpPort) {
155
178
  return new Promise((resolve, reject) => {
@@ -182,7 +205,9 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
182
205
  const db = openDb(dbPath);
183
206
  return bindHttp(db, dbPath, httpPort).then((httpServer) => {
184
207
  let daemon;
208
+ let requeuedApprovedJobs;
185
209
  try {
210
+ requeuedApprovedJobs = requeueApprovedJobs(db);
186
211
  daemon = createDaemon(db, { dbPath, maxConcurrent, execute: (job) => runCliJob(job, dbPath, lang) });
187
212
  }
188
213
  catch (err) {
@@ -204,6 +229,7 @@ export function serve(dbPath = defaultDbPath(), maxConcurrent = Number(process.e
204
229
  },
205
230
  httpPort: boundPort,
206
231
  hasActiveToken: hasAnyActiveToken(db),
232
+ requeuedApprovedJobs,
207
233
  };
208
234
  });
209
235
  }
package/dist/db.js CHANGED
@@ -180,6 +180,17 @@ const MIGRATIONS = [
180
180
  db.exec("ALTER TABLE provider_keys ADD COLUMN last_test_succeeded INTEGER");
181
181
  },
182
182
  },
183
+ // queue-lang-rerun §A: old queued rows deliberately remain NULL so they keep the daemon's
184
+ // startup-language behavior. Inspect columns even when a downgraded fixture already has
185
+ // user_version 5; SQLite has no ADD COLUMN IF NOT EXISTS form for this migration.
186
+ {
187
+ version: 5,
188
+ up: (db) => {
189
+ const columns = new Set(db.prepare("PRAGMA table_info(jobs)").all().map((column) => column.name));
190
+ if (!columns.has("lang"))
191
+ db.exec("ALTER TABLE jobs ADD COLUMN lang TEXT");
192
+ },
193
+ },
183
194
  ];
184
195
  const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
185
196
  function migrate(db, lang) {
package/dist/doctor.js CHANGED
@@ -64,6 +64,9 @@ function buildDaemonValue(lang, daemon) {
64
64
  return m(lang, "doctorDaemonAlive", daemon.pid);
65
65
  return m(lang, "doctorDaemonMissing", daemon?.reason ?? "heartbeat_missing");
66
66
  }
67
+ function buildQueueValue(lang, queue) {
68
+ return m(lang, "doctorQueueValue", queue.pendingApproval, queue.approved, queue.running);
69
+ }
67
70
  function buildConfigDirValue(lang, configDir) {
68
71
  if (configDir.source === "unresolved")
69
72
  return m(lang, "doctorConfigDirUnresolved");
@@ -117,7 +120,7 @@ function buildLensValue(lang, lenses) {
117
120
  const closingNames = lenses.withClosing.length > 0 ? lenses.withClosing.join(joiner) : m(lang, "noneLabel");
118
121
  return m(lang, "doctorLensFoundValue", lenses.dirPath, lenses.withClosing.length + lenses.withoutClosing.length, lenses.withClosing.length, closingNames, noClosingSuffix);
119
122
  }
120
- export function buildDoctorReportData(cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
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 }) {
121
124
  const dispatchHome = resolveDispatchHome(env, homedir);
122
125
  const configDir = dispatchHome === null
123
126
  ? { source: "unresolved" }
@@ -149,6 +152,7 @@ export function buildDoctorReportData(cmd, providers, env = process.env, homedir
149
152
  apiKeys: keyStatusRows.map((row) => ({ ...row })),
150
153
  models,
151
154
  daemon: daemon ?? { alive: false, reason: "heartbeat_missing" },
155
+ queue: { ...queue },
152
156
  lenses: buildLensData(probe, cwd),
153
157
  };
154
158
  }
@@ -163,12 +167,13 @@ export function renderDoctorReport(lang, cmd, data) {
163
167
  m(lang, "doctorApiKeyLine", buildApiKeyValue(lang, data.apiKeys)),
164
168
  m(lang, "doctorModelListLine", buildModelListValue(lang, data.models)),
165
169
  m(lang, "doctorDaemonLine", buildDaemonValue(lang, data.daemon)),
170
+ m(lang, "doctorQueueLine", buildQueueValue(lang, data.queue)),
166
171
  m(lang, "doctorLensLine", buildLensValue(lang, data.lenses)),
167
172
  "",
168
173
  m(lang, "doctorFooter"),
169
174
  ];
170
175
  return lines.join("\n");
171
176
  }
172
- export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd(), daemon, keyStatusRows = [], dbPath) {
173
- return renderDoctorReport(lang, cmd, buildDoctorReportData(cmd, providers, env, homedir, probe, cwd, daemon, keyStatusRows, dbPath));
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));
174
179
  }
package/dist/job.js CHANGED
@@ -21,19 +21,65 @@ export function findJobsByIdPrefix(db, prefix) {
21
21
  export function listPendingApprovalJobs(db) {
22
22
  return db.prepare("SELECT * FROM jobs WHERE status = 'pending_approval' ORDER BY created_at, id").all();
23
23
  }
24
- export function submitJob(db, ticket, id = randomUUID()) {
24
+ // Keep this to one indexed aggregate query rather than loading jobs and counting
25
+ // in JavaScript. `idx_jobs_status` covers both the WHERE predicate and status.
26
+ export function countQueueJobs(db) {
27
+ const rows = db
28
+ .prepare(`SELECT status, COUNT(*) AS count
29
+ FROM jobs
30
+ WHERE status IN ('pending_approval', 'approved', 'running')
31
+ GROUP BY status`)
32
+ .all();
33
+ const counts = { pendingApproval: 0, approved: 0, running: 0 };
34
+ for (const row of rows) {
35
+ if (row.status === "pending_approval")
36
+ counts.pendingApproval = Number(row.count);
37
+ else if (row.status === "approved")
38
+ counts.approved = Number(row.count);
39
+ else
40
+ counts.running = Number(row.count);
41
+ }
42
+ return counts;
43
+ }
44
+ export function submitJob(db, ticket, id = randomUUID(), lang = null) {
25
45
  if (!db.prepare("SELECT 1 FROM tickets WHERE name = ?").get(ticket))
26
46
  throw new Error(`ticket does not exist: ${ticket}`);
27
47
  const createdAt = now();
28
- db.prepare("INSERT INTO jobs (id, ticket, status, created_at) VALUES (?, ?, 'pending_approval', ?)").run(id, ticket, createdAt);
48
+ db.prepare("INSERT INTO jobs (id, ticket, lang, status, created_at) VALUES (?, ?, ?, 'pending_approval', ?)").run(id, ticket, lang, createdAt);
29
49
  return getJob(db, id);
30
50
  }
51
+ // A dispatch can leave one __summary__ row plus one row per spoke. Count distinct result
52
+ // keys rather than rows so a single run remains one reminder even when its summary is absent
53
+ // or it has many spokes. The UNION covers both historical CLI keys (ticket name) and queued
54
+ // job keys (jobs.id for the same ticket) without double-counting a pathological overlap.
55
+ export function countTicketResultRuns(db, ticket) {
56
+ const row = db
57
+ .prepare(`SELECT COUNT(*) AS count FROM (
58
+ SELECT job_id FROM results WHERE job_id = ? GROUP BY job_id
59
+ UNION
60
+ SELECT r.job_id
61
+ FROM jobs j JOIN results r ON r.job_id = j.id
62
+ WHERE j.ticket = ?
63
+ GROUP BY r.job_id
64
+ )`)
65
+ .get(ticket, ticket);
66
+ return Number(row.count);
67
+ }
31
68
  export function approveJob(db, id) {
32
69
  return one(db, `UPDATE jobs
33
70
  SET status = 'approved', approved_at = ?
34
71
  WHERE id = ? AND status = 'pending_approval'
35
72
  RETURNING *`, now(), id);
36
73
  }
74
+ // A daemon owns only approvals made after it starts. Before a new daemon begins
75
+ // claiming work, return any older approvals to the explicit human-approval gate.
76
+ // Keep this as a single guarded UPDATE: concurrent approval is allowed to remain
77
+ // approved for the new daemon, and no other lifecycle state is touched.
78
+ export function requeueApprovedJobs(db) {
79
+ return Number(db.prepare(`UPDATE jobs
80
+ SET status = 'pending_approval', approved_at = NULL
81
+ WHERE status = 'approved'`).run().changes);
82
+ }
37
83
  export function rejectJob(db, id) {
38
84
  return one(db, `UPDATE jobs
39
85
  SET status = 'rejected', finished_at = ?
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { defaultDbPath, openDb } from "../db.js";
3
- import { approveJob, findJobsByIdPrefix, getJob, submitJob } from "../job.js";
3
+ import { approveJob, countTicketResultRuns, findJobsByIdPrefix, getJob, submitJob } from "../job.js";
4
4
  import { DAEMON_STALE_MS, daemonAlive, daemonStatePath, daemonWarning } from "../liveness.js";
5
5
  import { readProgress } from "../progress.js";
6
6
  import { m } from "../messages.js";
@@ -20,7 +20,10 @@ export const TOOLS = [
20
20
  description: "Queue a DB ticket by name. It remains pending human terminal approval and is not executed by this tool.",
21
21
  inputSchema: {
22
22
  type: "object",
23
- properties: { ticket: { type: "string", description: "DB ticket name, not a filesystem path" } },
23
+ properties: {
24
+ ticket: { type: "string", description: "DB ticket name, not a filesystem path" },
25
+ lang: { type: "string", enum: ["en", "zh-tw", "zh"], description: "Language for this queued job's CLI output and spoke prompts" },
26
+ },
24
27
  required: ["ticket"],
25
28
  },
26
29
  },
@@ -70,6 +73,17 @@ function stringParam(params, name) {
70
73
  const value = record(params)?.[name];
71
74
  return typeof value === "string" && value.length > 0 ? value : null;
72
75
  }
76
+ // Keep this acceptance and normalization identical to the CLI's --lang contract without
77
+ // importing cli-args.ts: cli-args imports the MCP HTTP transport, so sharing it here would
78
+ // create a protocol -> CLI args -> HTTP -> protocol runtime cycle.
79
+ function parseSubmitLang(value) {
80
+ const normalized = value.trim().toLowerCase();
81
+ if (normalized === "en")
82
+ return "en";
83
+ if (normalized === "zh-tw" || normalized === "zh")
84
+ return "zh";
85
+ return null;
86
+ }
73
87
  function keyStatusText(lang, row) {
74
88
  if (row.maskedTail === null)
75
89
  return m(lang, "keyStatusMissing");
@@ -122,6 +136,9 @@ export function createProtocol(options) {
122
136
  for (const { name: ticket } of names) {
123
137
  const doc = getTicketDoc(options.db, ticket, options.lang);
124
138
  lines.push(m(options.lang, "mcpTicketsRow", ticket, doc.spokes.length));
139
+ const priorRuns = countTicketResultRuns(options.db, ticket);
140
+ if (priorRuns > 0)
141
+ lines.push(` ${m(options.lang, "mcpPriorResultsReminder", priorRuns)}`);
125
142
  for (const spoke of doc.spokes) {
126
143
  lines.push(m(options.lang, "mcpTicketsSpoke", spoke.agent, spoke.provider, spoke.model, spoke.allow.length));
127
144
  }
@@ -139,11 +156,23 @@ export function createProtocol(options) {
139
156
  const ticket = stringParam(args, "ticket");
140
157
  if (!ticket)
141
158
  return text("dispatch_submit requires a non-empty ticket name", true);
159
+ const suppliedLang = record(args)?.lang;
160
+ let jobLang = null;
161
+ if (suppliedLang !== undefined) {
162
+ if (typeof suppliedLang !== "string")
163
+ return text(m(options.lang, "mcpSubmitInvalidLang", "(not a string)", m(options.lang, "availableLangValues")), true);
164
+ jobLang = parseSubmitLang(suppliedLang);
165
+ if (jobLang === null)
166
+ return text(m(options.lang, "mcpSubmitInvalidLang", suppliedLang, m(options.lang, "availableLangValues")), true);
167
+ }
142
168
  try {
143
- const job = submitJob(options.db, ticket);
169
+ const priorRuns = countTicketResultRuns(options.db, ticket);
170
+ const job = submitJob(options.db, ticket, undefined, jobLang);
144
171
  const cliPath = path.resolve(process.argv[1] ?? "cli.js");
145
172
  const approveCommand = `node ${JSON.stringify(cliPath)} approve ${job.id.slice(0, 8)} --db ${JSON.stringify(options.dbPath)}`;
146
173
  const lines = [m(options.lang, "mcpSubmitQueued", job.id), m(options.lang, "mcpApproveCommand", approveCommand), formatTicket(options.db, ticket, options.lang)];
174
+ if (priorRuns > 0)
175
+ lines.push(m(options.lang, "mcpPriorResultsReminder", priorRuns));
147
176
  const offline = warning();
148
177
  if (offline)
149
178
  lines.push(offline);
package/dist/messages.js CHANGED
@@ -27,6 +27,18 @@ const MESSAGES = {
27
27
  dryRunNotice: () => "--dry-run:僅解析/驗證/估算/印報表,未呼叫任何 API。",
28
28
  helpText: (cmd) => `用法:${cmd} <ticket-id> [options]
29
29
 
30
+ 子指令(各自有自己的 --help/用法訊息):
31
+ ${cmd} ticket ... 建立、匯入、檢視工單
32
+ ${cmd} result <id> 印出一次派工的結果(各 spoke 原文與稽核表)
33
+ ${cmd} key 設定 provider API key(互動選單)
34
+ ${cmd} providers ... 型號白名單與啟用狀態
35
+ ${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
36
+ ${cmd} approve [jobId] 核准佇列中等待的派工
37
+ ${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
38
+ ${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 或舊核准 job 時會先確認
39
+ ${cmd} serve --restart [--yes] 停止後重新啟動 daemon,會走兩端清理
40
+ ${cmd} mcp 以 stdio 提供 MCP server
41
+
30
42
  --lang <en|zh-tw> CLI 輸出與 spoke prompt 的語言,預設 en
31
43
  --repo-root <dir> 白名單邊界與 .claude/agents 的根,預設 cwd
32
44
  --db <path> SQLite 工單與產物資料庫,預設 DISPATCH_HOME/dowafu.db
@@ -47,18 +59,7 @@ const MESSAGES = {
47
59
  --yes 略過派工確認。非互動環境(stdin 不是 TTY)沒帶就中止
48
60
  --doctor 印出設定自檢(不呼叫 API、不花錢)後結束(exit 0)
49
61
  --help, -h 印本說明後結束(exit 0)
50
- --version, -V 印版本號後結束(exit 0)
51
-
52
- 子指令(各自有自己的 --help/用法訊息):
53
- ${cmd} ticket ... 建立、匯入、檢視工單
54
- ${cmd} result <id> 印出一次派工的結果(各 spoke 原文與稽核表)
55
- ${cmd} key 設定 provider API key(互動選單)
56
- ${cmd} providers ... 型號白名單與啟用狀態
57
- ${cmd} token ... 入站 HTTP 憑證的發/列/撤銷
58
- ${cmd} approve [jobId] 核准佇列中等待的派工
59
- ${cmd} serve 啟動常駐 daemon(含 MCP 的 HTTP binding)
60
- ${cmd} serve --stop [--yes] 停止常駐 daemon;有執行中的 job 時會先確認
61
- ${cmd} mcp 以 stdio 提供 MCP server`,
62
+ --version, -V 印版本號後結束(exit 0)`,
62
63
  availableLangValues: () => "en、zh-tw、zh",
63
64
  availableValuesSuffix: (values) => `(可用值:${values})`,
64
65
  numberFlagInvalid: (name, value) => `--${name} 需要數字,收到:${value}`,
@@ -107,7 +108,8 @@ const MESSAGES = {
107
108
  mcpJobPrefixAmbiguous: (prefix, count) => `前綴 ${prefix} 命中 ${count} 個 job,請改用完整 id:`,
108
109
  jobNotApprovable: (jobId, status) => `job ${jobId} 目前是 ${status},不能核准`,
109
110
  jobApproved: (jobId) => `已核准 job:${jobId}`,
110
- daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出;進行中的 job 下次啟動會被回收。",
111
+ daemonStarted: () => "daemon 已啟動。收到 SIGINT 時會乾淨退出。",
112
+ daemonStartApprovedCleaned: (count) => `啟動前已將 ${count} 張舊的已核准 job 退回待核准。`,
111
113
  daemonStartFailed: (detail) => `daemon 無法啟動:${detail}`,
112
114
  daemonWorkerSpawnFailed: (detail) => `worker 無法啟動:${detail}`,
113
115
  daemonWorkerExited: (exitCode) => `worker 以 exit code ${exitCode} 結束`,
@@ -115,18 +117,23 @@ const MESSAGES = {
115
117
  daemonWorkerTerminated: (signal) => `worker 被訊號 ${signal} 終止`,
116
118
  daemonWorkerTerminatedDetail: (signal, detail) => `worker 被訊號 ${signal} 終止:${detail}`,
117
119
  stopRequiresServe: () => "--stop 只能與 serve 一起使用。",
120
+ stopRestartConflict: () => "--stop 與 --restart 不能同時使用。",
118
121
  daemonStopRunningWarning: (count) => `目前有 ${count} 張 job 正在執行;停止 daemon 不會停止正在跑的 spoke,它們會繼續跑完,之後結果與 API 成本仍會被認列。`,
119
122
  daemonStopConfirmPrompt: () => "確定要停止 daemon 嗎?[y/N] ",
123
+ daemonStopApprovedWarning: (count) => `目前有 ${count} 張已核准 job 尚未執行;停止前會退回待核准,避免下一支 daemon 未經本輪核准就執行。`,
124
+ daemonStopApprovedConfirmPrompt: () => "確定要清理這些已核准 job 嗎?[y/N] ",
120
125
  daemonStopCancelled: () => "已取消,daemon 仍在執行。",
121
126
  daemonStopNotRunning: (reason) => `daemon 未在執行(${reason})。`,
122
127
  daemonStopPidNotDowafu: (pid) => `pid ${pid} 的行程不是 dowafu,為避免誤殺未送出訊號。`,
123
- daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出停止訊號,但它沒有停止。`,
128
+ daemonStopSignalFailed: (pid) => `已向 pid ${pid} 送出 SIGTERM,但它沒有停止。請手動檢查:ps -p ${pid} -o pid,ppid,command=;確認後可再送 SIGTERM。不要用 SIGKILL,worker 子行程可能仍會繼續寫入。`,
124
129
  daemonStopSucceeded: (pid) => `daemon(pid ${pid})已停止。`,
125
130
  daemonStopRestarted: (pid) => `daemon(原 pid ${pid})停止後又恢復運行;看起來有 OS 層服務管理在重拉它。要真的停下來請用 launchctl 或 systemctl。`,
131
+ daemonStopApprovedCleaned: (count) => `已將 ${count} 張已核准 job 退回待核准。`,
132
+ daemonRestartStarted: () => "daemon 已重新啟動。",
126
133
  doctorDaemonLine: (value) => ` daemon ${value}`,
127
134
  doctorDaemonAlive: (pid) => `運行中(pid ${pid})`,
128
135
  doctorDaemonMissing: (reason) => `未運行(${reason})`,
129
- daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先執行 dowafu serve。`,
136
+ daemonOfflineWarning: (reason) => `⚠ worker daemon 未運行(${reason});job 已排入但不會執行。請先起 dowafu serve 再核准——daemon 沒在跑時給的核准,會在它啟動時退回待核准。`,
130
137
  mcpTicketsEmpty: () => "沒有可派的 DB 工單。",
131
138
  mcpTicketsTitle: () => "可派工單:",
132
139
  mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}:${spokeCount} 個 spoke`,
@@ -136,6 +143,8 @@ const MESSAGES = {
136
143
  mcpEnabledModelsTitle: () => "已啟用型號白名單:",
137
144
  mcpEnabledModelRow: (provider, model) => ` ${provider}/${model}`,
138
145
  mcpSubmitQueued: (jobId) => `已排入佇列,job id = ${jobId},狀態 pending_approval。尚未執行,須由人在終端機核准。`,
146
+ mcpSubmitInvalidLang: (value, available) => `dispatch_submit 的 lang 無效:${value}(可用值:${available})。未建立 job。`,
147
+ mcpPriorResultsReminder: (runs) => `提醒:這張工單已有 ${runs} 次結果;這不是錯誤,仍可照常排入佇列。`,
139
148
  mcpApproveCommand: (command) => `請在終端機執行:${command}`,
140
149
  mcpJobStarted: (at) => `開始於 ${at}`,
141
150
  mcpJobFinished: (at) => `結束於 ${at}`,
@@ -273,8 +282,8 @@ const MESSAGES = {
273
282
  observationCountCell: (display) => `觀察:${display}`,
274
283
  cannotCountObservations: () => "無法計數",
275
284
  cannotVerifySectionCell: (passFail) => `無法驗證欄:${passFail}`,
276
- templatePlaceholderEntry: (placeholder, count) => `${placeholder}×${count}`,
277
- templatePlaceholdersCell: (detail) => `佔位符:${detail}`,
285
+ templatePlaceholdersPassCell: () => "佔位符:pass",
286
+ templatePlaceholdersFailCell: (count) => `佔位符:fail(內容可用,但含 ${count} 處佔位符標記,交付前請清理)`,
278
287
  auditUnavailable: () => "(無法稽核)",
279
288
  summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
280
289
 
@@ -344,8 +353,10 @@ const MESSAGES = {
344
353
  doctorModelListValue: (enabled, total, mostExpensive) => `啟用 ${enabled}/${total};最貴啟用型號 ${mostExpensive};來源 SQLite DB`,
345
354
  doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
346
355
  doctorLensLine: (value) => ` lens 定義 ${value}`,
356
+ doctorQueueLine: (value) => ` 佇列 ${value}`,
357
+ doctorQueueValue: (pendingApproval, approved, running) => `待核准 ${pendingApproval};已核准 ${approved};執行中 ${running}`,
347
358
  doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} 找到 ${total} 個定義檔,其中 ${closingCount} 個具備固定收尾句:\n${" ".repeat(14)}${closingNames}${noClosingSuffix}`,
348
- doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另 ${count} 個無收尾句:${names})`,
359
+ doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另有 ${count} 個不是 lens:${names})`,
349
360
  doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
350
361
  doctorFooter: () => "本指令不呼叫任何 API,不會花錢。缺的項目怎麼補,見 README 的〈API keys〉一節。",
351
362
  },
@@ -355,6 +366,18 @@ const MESSAGES = {
355
366
  dryRunNotice: () => "--dry-run: parsed, validated, estimated, and reported only; no API calls were made.",
356
367
  helpText: (cmd) => `Usage: ${cmd} <ticket-id> [options]
357
368
 
369
+ Subcommands (each has its own usage message):
370
+ ${cmd} ticket ... Create, import and inspect tickets
371
+ ${cmd} result <id> Print the results of one dispatch (spoke output and audit table)
372
+ ${cmd} key Set provider API keys (interactive menu)
373
+ ${cmd} providers ... Model whitelist and enabled state
374
+ ${cmd} token ... Issue, list and revoke inbound HTTP tokens
375
+ ${cmd} approve [jobId] Approve a dispatch waiting in the queue
376
+ ${cmd} serve Run the daemon (includes the MCP HTTP binding)
377
+ ${cmd} serve --stop [--yes] Stop the daemon; asks first about running or old approved jobs
378
+ ${cmd} serve --restart [--yes] Stop then start the daemon, running both cleanup phases
379
+ ${cmd} mcp Serve MCP over stdio
380
+
358
381
  --lang <en|zh-tw> Language for CLI output and spoke prompts, default en
359
382
  --repo-root <dir> Root for the allowlist boundary and .claude/agents, default cwd
360
383
  --db <path> SQLite ticket/result database, default DISPATCH_HOME/dowafu.db
@@ -375,18 +398,7 @@ const MESSAGES = {
375
398
  --yes Skip the dispatch confirmation. Aborts in non-interactive environments (stdin not a TTY) unless given
376
399
  --doctor Print the configuration self-check (no API call, no cost) and exit (exit 0)
377
400
  --help, -h Print this help and exit (exit 0)
378
- --version, -V Print the version and exit (exit 0)
379
-
380
- Subcommands (each has its own usage message):
381
- ${cmd} ticket ... Create, import and inspect tickets
382
- ${cmd} result <id> Print the results of one dispatch (spoke output and audit table)
383
- ${cmd} key Set provider API keys (interactive menu)
384
- ${cmd} providers ... Model whitelist and enabled state
385
- ${cmd} token ... Issue, list and revoke inbound HTTP tokens
386
- ${cmd} approve [jobId] Approve a dispatch waiting in the queue
387
- ${cmd} serve Run the daemon (includes the MCP HTTP binding)
388
- ${cmd} serve --stop [--yes] Stop the daemon; asks first if jobs are running
389
- ${cmd} mcp Serve MCP over stdio`,
401
+ --version, -V Print the version and exit (exit 0)`,
390
402
  availableLangValues: () => "en, zh-tw, zh",
391
403
  availableValuesSuffix: (values) => ` (available: ${values})`,
392
404
  numberFlagInvalid: (name, value) => `--${name} requires a number, got: ${value}`,
@@ -433,7 +445,8 @@ Subcommands (each has its own usage message):
433
445
  mcpJobPrefixAmbiguous: (prefix, count) => `Prefix ${prefix} matches ${count} jobs; use the full id:`,
434
446
  jobNotApprovable: (jobId, status) => `Job ${jobId} is ${status} and cannot be approved`,
435
447
  jobApproved: (jobId) => `Approved job: ${jobId}`,
436
- daemonStarted: () => "Daemon started. SIGINT exits cleanly; running jobs are reaped on the next start.",
448
+ daemonStarted: () => "Daemon started. SIGINT exits cleanly.",
449
+ daemonStartApprovedCleaned: (count) => `${count} older approved job(s) returned to pending approval before startup.`,
437
450
  daemonStartFailed: (detail) => `Could not start daemon: ${detail}`,
438
451
  daemonWorkerSpawnFailed: (detail) => `Could not start worker: ${detail}`,
439
452
  daemonWorkerExited: (exitCode) => `worker exited ${exitCode}`,
@@ -441,18 +454,23 @@ Subcommands (each has its own usage message):
441
454
  daemonWorkerTerminated: (signal) => `worker terminated by ${signal}`,
442
455
  daemonWorkerTerminatedDetail: (signal, detail) => `worker terminated by ${signal}: ${detail}`,
443
456
  stopRequiresServe: () => "--stop can only be used with serve.",
457
+ stopRestartConflict: () => "--stop and --restart cannot be used together.",
444
458
  daemonStopRunningWarning: (count) => `${count} job(s) are running; stopping the daemon does not stop their active spokes, which continue to completion and will still have their results and API costs recorded.`,
445
459
  daemonStopConfirmPrompt: () => "Stop the daemon? [y/N] ",
460
+ daemonStopApprovedWarning: (count) => `${count} approved job(s) have not started; stopping returns them to pending approval so the next daemon cannot run them without a new approval.`,
461
+ daemonStopApprovedConfirmPrompt: () => "Clear these approved jobs? [y/N] ",
446
462
  daemonStopCancelled: () => "Cancelled; the daemon is still running.",
447
463
  daemonStopNotRunning: (reason) => `Daemon is not running (${reason}).`,
448
464
  daemonStopPidNotDowafu: (pid) => `pid ${pid} is not a dowafu process; no signal was sent to avoid killing the wrong process.`,
449
- daemonStopSignalFailed: (pid) => `A stop signal was sent to pid ${pid}, but it did not stop.`,
465
+ daemonStopSignalFailed: (pid) => `SIGTERM was sent to pid ${pid}, but it did not stop. Inspect it manually: ps -p ${pid} -o pid,ppid,command=; after confirming, you can send SIGTERM again. Do not use SIGKILL: worker children may still write results.`,
450
466
  daemonStopSucceeded: (pid) => `Daemon (pid ${pid}) stopped.`,
451
467
  daemonStopRestarted: (pid) => `Daemon (previous pid ${pid}) came back after stopping; an OS-level service manager appears to be restarting it. Use launchctl or systemctl to stop it permanently.`,
468
+ daemonStopApprovedCleaned: (count) => `${count} approved job(s) returned to pending approval.`,
469
+ daemonRestartStarted: () => "Daemon restarted.",
452
470
  doctorDaemonLine: (value) => ` Daemon ${value}`,
453
471
  doctorDaemonAlive: (pid) => `running (pid ${pid})`,
454
472
  doctorDaemonMissing: (reason) => `not running (${reason})`,
455
- daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start it with dowafu serve.`,
473
+ daemonOfflineWarning: (reason) => `⚠ The worker daemon is not running (${reason}); the job is queued but will not execute. Start dowafu serve before approving — approvals given while no daemon is running are returned to pending approval when it starts.`,
456
474
  mcpTicketsEmpty: () => "No dispatchable DB tickets.",
457
475
  mcpTicketsTitle: () => "Dispatchable tickets:",
458
476
  mcpTicketsRow: (ticket, spokeCount) => `- ${ticket}: ${spokeCount} spoke(s)`,
@@ -462,6 +480,8 @@ Subcommands (each has its own usage message):
462
480
  mcpEnabledModelsTitle: () => "Enabled model allowlist:",
463
481
  mcpEnabledModelRow: (provider, model) => ` ${provider}/${model}`,
464
482
  mcpSubmitQueued: (jobId) => `Job queued: ${jobId}; status pending_approval. It has not started and needs terminal approval by a human.`,
483
+ mcpSubmitInvalidLang: (value, available) => `dispatch_submit lang is invalid: ${value} (available values: ${available}). No job was created.`,
484
+ mcpPriorResultsReminder: (runs) => `Reminder: this ticket already has ${runs} result run(s). This is not an error; it can still be queued.`,
465
485
  mcpApproveCommand: (command) => `Run this in a terminal: ${command}`,
466
486
  mcpJobStarted: (at) => `Started at ${at}`,
467
487
  mcpJobFinished: (at) => `Finished at ${at}`,
@@ -606,8 +626,8 @@ Subcommands (each has its own usage message):
606
626
  observationCountCell: (display) => `Observations:${display}`,
607
627
  cannotCountObservations: () => "uncountable",
608
628
  cannotVerifySectionCell: (passFail) => `Cannot-verify section:${passFail}`,
609
- templatePlaceholderEntry: (placeholder, count) => `${placeholder}×${count}`,
610
- templatePlaceholdersCell: (detail) => `Template placeholders:${detail}`,
629
+ templatePlaceholdersPassCell: () => "Template placeholders:pass",
630
+ templatePlaceholdersFailCell: (count) => `Template placeholders:fail (content is usable, but contains ${count} placeholder marker(s); clean them before delivery)`,
611
631
  auditUnavailable: () => "(audit unavailable)",
612
632
  summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
613
633
 
@@ -673,8 +693,10 @@ Subcommands (each has its own usage message):
673
693
  doctorModelListValue: (enabled, total, mostExpensive) => `enabled ${enabled}/${total}; most expensive enabled ${mostExpensive}; source SQLite DB`,
674
694
  doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
675
695
  doctorLensLine: (value) => ` Lens defs ${value}`,
696
+ doctorQueueLine: (value) => ` Queue ${value}`,
697
+ doctorQueueValue: (pendingApproval, approved, running) => `pending approval ${pendingApproval}; approved ${approved}; running ${running}`,
676
698
  doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} holds ${total} definition file(s), ${closingCount} with a fixed closing line:\n${" ".repeat(15)}${closingNames}${noClosingSuffix}`,
677
- doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} more without a closing line: ${names})`,
699
+ doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} other agent file(s), not lenses: ${names})`,
678
700
  doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
679
701
  doctorFooter: () => 'This command calls no API and costs nothing. To fill in what is missing, see "API keys" in the README.',
680
702
  },
package/dist/output.js CHANGED
@@ -23,12 +23,12 @@ function formatFinishReasonCell(r, lang) {
23
23
  return null;
24
24
  return m(lang, "finishReasonWarningCell", r.finishReason ?? "unknown");
25
25
  }
26
- // 工單 X1 v1.1 §二:模板佔位符若被留在回報裡,列出是哪幾個、各幾次;沒有命中維持既有
27
- // 風格印「無」。
26
+ // 工單 report-format §三 B:污染標記只改稽核欄的 pass/fail 語義,絕不改 SpokeRunResult
27
+ // (其 status 仍由執行器/job 狀態機決定)。失敗格給可操作的總命中數,細節仍在 JSON audit。
28
28
  function formatPlaceholdersCell(hits, lang) {
29
29
  if (hits.length === 0)
30
- return m(lang, "noneLabel");
31
- return hits.map((h) => m(lang, "templatePlaceholderEntry", h.placeholder, h.count)).join(", ");
30
+ return m(lang, "templatePlaceholdersPassCell");
31
+ return m(lang, "templatePlaceholdersFailCell", hits.reduce((total, hit) => total + hit.count, 0));
32
32
  }
33
33
  export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang) {
34
34
  const rows = results.map((r) => {
@@ -54,7 +54,7 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits,
54
54
  if (a) {
55
55
  cells.push(m(lang, "closingLineCell", a.finalLinePass ? "pass" : "fail"),
56
56
  // v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
57
- m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"), m(lang, "templatePlaceholdersCell", formatPlaceholdersCell(a.templatePlaceholdersFound, lang)));
57
+ m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"), formatPlaceholdersCell(a.templatePlaceholdersFound, lang));
58
58
  }
59
59
  const auditCell = cells.length > 0 ? cells.join(" / ") : m(lang, "auditUnavailable");
60
60
  // plan_fixes_v1.0.md §4:無價目資料須與「估出來是 $0」區分,不能印成空白或 0——
package/dist/prompt.js CHANGED
@@ -8,9 +8,10 @@ import path from "node:path";
8
8
  // 結束,因為稽核靠那句話判斷回報有沒有寫完。翻譯時刻意保留祈使句與「原文是主要依據、
9
9
  // 行號是輔助」這兩處措辭:實測顯示 spoke 對句型敏感,描述句會被當成建議略過。
10
10
  const REPORT_TEMPLATE_EN = `# Observations
11
- 1. <observation>
11
+ 1. Example: a retry after a timeout can repeat a write operation.
12
12
  Evidence: <file:line, or explicit reasoning>
13
13
  Quote: <when citing a file, copy that one line verbatim; write "reasoning" when the evidence is reasoning>
14
+ —— follow this shape for every observation; replace every angle-bracket marker with actual content and do not leave any behind.
14
15
  —— when citing a file, the path must match the string in "Allowed reads" character for character; do not abbreviate it or write the filename alone.
15
16
  —— **the quote is the primary evidence, the line number is secondary**: the hub locates the
16
17
  real position by matching the quote, so quote verbatim. Copy only the line you are sure
@@ -21,9 +22,10 @@ const REPORT_TEMPLATE_EN = `# Observations
21
22
 
22
23
  These are observations and questions. Whether to adopt them is for the hub and the user to decide.`;
23
24
  const REPORT_TEMPLATE = `# 觀察
24
- 1. <觀察>
25
+ 1. 範例:逾時後的重試作業可能重複執行一次寫入。
25
26
  依據:<檔案:行號 或 明確推理>
26
27
  原文:<引用檔案時,逐字複製該處的一行原文;依據為推理時寫「推理」>
28
+ ——每條觀察均依此形狀撰寫;所有角括號標記都要換成實際內容,不要保留。
27
29
  ——引用檔案時,路徑須與「允許讀取」清單中的字串逐字相同,不得縮寫或只寫檔名。
28
30
  ——**原文是主要依據,行號是輔助**:hub 會用原文比對出實際位置,所以原文必須逐字,
29
31
  寧可只複製確定的那一行,也不要憑印象重寫。
@@ -32,9 +34,9 @@ const REPORT_TEMPLATE = `# 觀察
32
34
  - <需要但讀不到的檔案,或清單不足之處>;沒有則寫「無」
33
35
 
34
36
  以上為觀察與問題,採用與否由 hub 與使用者裁決。`;
35
- // 工單 X1 v1.1 §二:模板要求 spoke 填空,卻沒有檢查空有沒有被填——四格產物實測中過招
36
- // (`<觀察>` 字面留在回報裡)。清單從這裡匯出,audit.ts 不得重打一份字串,否則模板改了
37
- // 檢查就會跟著失效。
37
+ // 工單 report-format §二:這是「已知污染標記」清單,不再與模板雙向同源。模板現有的尖括號
38
+ // 標記仍須收錄(prompt.test.ts 防漏),而已移除的 <觀察>/<observation> 也必須保留:真實
39
+ // spoke 曾把它們原樣帶進交付物。清單從這裡匯出,audit.ts 不得重打一份字串。
38
40
  export const REPORT_PLACEHOLDERS = [
39
41
  "<觀察>",
40
42
  "<檔案:行號 或 明確推理>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "type": "module",
27
27
  "engines": {
28
- "node": ">=20"
28
+ "node": ">=22.13.0"
29
29
  },
30
30
  "bin": {
31
31
  "dowafu": "dist/cli.js"