dowafu 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/output.js CHANGED
@@ -114,28 +114,12 @@ function formatStatusCell(r, lang) {
114
114
  const flag = r.budgetTrigger === "reasoning_round" ? m(lang, "anomalySpikeFlag") : "";
115
115
  return `${r.status}(${flag}${label})`;
116
116
  }
117
- // plan_dispatch_v2.0.md §15(二):清單外引用附出現章節與疑似縮寫來源,讓讀者不必自己去猜
118
- // (issue_log_v2.0.md 2026-08-07:曾有 hub 因為裸字串誤判成稽核器的 bug,推錯了方向)。
119
- function formatOutsideAllowlistCell(detail, lang) {
120
- if (detail.length === 0)
117
+ // 工單 X1 v1.1 §二:模板佔位符若被留在回報裡,列出是哪幾個、各幾次;沒有命中維持既有
118
+ // 風格印「無」。
119
+ function formatPlaceholdersCell(hits, lang) {
120
+ if (hits.length === 0)
121
121
  return m(lang, "noneLabel");
122
- return detail
123
- .map((d) => {
124
- const sectionPart = d.section ? m(lang, "outsideAllowlistSection", d.section) : m(lang, "outsideAllowlistNoSection");
125
- const suffixPart = d.suffixOf ? m(lang, "outsideAllowlistSuffixNote", d.suffixOf) : "";
126
- return m(lang, "outsideAllowlistEntry", d.path, `${sectionPart}${suffixPart}`);
127
- })
128
- .join(",");
129
- }
130
- // plan_i18n_v1.2.md §4.1:SUSPECT_PHRASES 中英兩套並存比對,summary.md 分開標示是哪一套
131
- // 命中——日後調整這份清單時才有資料可依據,不必再猜一次。
132
- function formatSuspectPhrasesDetail(a, lang) {
133
- const parts = [];
134
- if (a.suspectPhrasesZh.length > 0)
135
- parts.push(`zh:${a.suspectPhrasesZh.join(",")}`);
136
- if (a.suspectPhrasesEn.length > 0)
137
- parts.push(`en:${a.suspectPhrasesEn.join(",")}`);
138
- return parts.length > 0 ? parts.join(" / ") : m(lang, "noneLabel");
122
+ return hits.map((h) => m(lang, "templatePlaceholderEntry", h.placeholder, h.count)).join(", ");
139
123
  }
140
124
  export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits, lang) {
141
125
  const rows = results.map((r) => {
@@ -157,7 +141,7 @@ export function buildSummaryMarkdown(ticketId, results, audits, toolCallAudits,
157
141
  if (a) {
158
142
  cells.push(m(lang, "closingLineCell", a.finalLinePass ? "pass" : "fail"),
159
143
  // v1.9 §15:null(數不出來)與 0(明確為零)須可區分,不得混印
160
- m(lang, "observationCountCell", a.observationCount !== null ? String(a.observationCount) : m(lang, "cannotCountObservations")), m(lang, "outsideAllowlistCell", formatOutsideAllowlistCell(a.citedPathsOutsideAllowlistDetail, lang)), m(lang, "cannotVerifySectionCell", a.cannotVerifySectionPresent ? "pass" : "fail"), m(lang, "suspectPhrasesCell", formatSuspectPhrasesDetail(a, lang)));
144
+ 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)));
161
145
  }
162
146
  const auditCell = cells.length > 0 ? cells.join(" / ") : m(lang, "auditUnavailable");
163
147
  // plan_fixes_v1.0.md §4:無價目資料須與「估出來是 $0」區分,不能印成空白或 0——
package/dist/prompt.js CHANGED
@@ -32,6 +32,19 @@ const REPORT_TEMPLATE = `# 觀察
32
32
  - <需要但讀不到的檔案,或清單不足之處>;沒有則寫「無」
33
33
 
34
34
  以上為觀察與問題,採用與否由 hub 與使用者裁決。`;
35
+ // 工單 X1 v1.1 §二:模板要求 spoke 填空,卻沒有檢查空有沒有被填——四格產物實測中過招
36
+ // (`<觀察>` 字面留在回報裡)。清單從這裡匯出,audit.ts 不得重打一份字串,否則模板改了
37
+ // 檢查就會跟著失效。
38
+ export const REPORT_PLACEHOLDERS = [
39
+ "<觀察>",
40
+ "<檔案:行號 或 明確推理>",
41
+ "<引用檔案時,逐字複製該處的一行原文;依據為推理時寫「推理」>",
42
+ "<需要但讀不到的檔案,或清單不足之處>",
43
+ "<observation>",
44
+ "<file:line, or explicit reasoning>",
45
+ '<when citing a file, copy that one line verbatim; write "reasoning" when the evidence is reasoning>',
46
+ "<files you needed but could not read, or gaps in the list>",
47
+ ];
35
48
  // plan_dispatch_v2.1.md §8(二):工具說明只提「工單」,從未說允許清單的程式碼也要讀——
36
49
  // 零讀取的第二個成因(issue_log_v2.0.md 2026-08-07「provider 端完整 log」)。
37
50
  const TOOL_NOTE = "你有一個工具 `read_file(path)`。工單與允許讀取的程式碼檔案都不在本 prompt 中,\n須自行讀取;未讀過的檔案不得出現在「依據」中。";
@@ -70,6 +83,8 @@ function buildStep3(allowedReadsRelative, lang) {
70
83
  //
71
84
  // 但工單目錄**不保證位於 repoRoot 內**——cli.ts 明文「工單目錄仍相對 cwd 解析,不要求位於
72
85
  // repoRoot 內」。在外時轉相對會得到 ../.. 這種更難讀、也更容易被誤用的字串,故維持絕對。
86
+ // export:稽核端(cli.ts → auditSpoke)要組出「spoke 實際會看到的那兩個工單檔路徑」,
87
+ // 必須與這裡給 spoke 的字串同源,否則豁免會對不上(issue_log_v2.7.md 2026-08-14)。
73
88
  function displayTicketDir(ticketDir, repoRoot) {
74
89
  if (!repoRoot)
75
90
  return ticketDir;
package/dist/report.js CHANGED
@@ -33,7 +33,9 @@ function formatGitignoreWarning(status, outDir, lang) {
33
33
  // (同既有 gitignore 警告),讓 lens 檔案本身的收尾句指示(如「回報最後一行固定為:...」)
34
34
  // 在真正付費呼叫之前先被人看到有沒有漂移。agentBody 已在 resolveSpokes 剝過 frontmatter,
35
35
  // 這裡再跑一次 stripFrontmatter 是冪等的(不再以 --- 開頭,regex 不命中),不需重讀檔。
36
- function lensClosingLineStatus(agentBody) {
36
+ // 工單 X1 v1.1 §三:doctor.ts 的 lens 偵測要判定同一件事(檔案內文最後一句是不是固定收尾句),
37
+ // 匯出供其重用,不得在 doctor.ts 重寫一份判定。
38
+ export function lensClosingLineStatus(agentBody) {
37
39
  const lines = stripFrontmatter(agentBody).split(/\r?\n/).map((l) => l.trim());
38
40
  const lastNonEmpty = [...lines].reverse().find((l) => l.length > 0) ?? "";
39
41
  if (lastNonEmpty.includes(FIXED_CLOSING_LINE))
@@ -148,6 +150,11 @@ export function buildReport(ticketId, spokes, estimates, allowlistEstimates, cli
148
150
  }
149
151
  const allowedCount = spokes.reduce((s, spoke) => s + spoke.allowedReadsResolved.length, 0);
150
152
  lines.push(m(lang, "allowedReadsSummary", allowedCount, outDir));
153
+ // 2026-08-15:待審段落疑似被切斷的警告,排在 gitignore 警告之前——後者關係到產物落在哪,
154
+ // 前者關係到這次派工有沒有意義(付全額、審半份)。
155
+ if (meta.strayHeadings.length > 0) {
156
+ lines.push(m(lang, "strayHeadingsWarning", meta.reviewTextChars, meta.strayHeadings));
157
+ }
151
158
  const gitignoreWarning = formatGitignoreWarning(meta.gitignoreStatus, outDir, lang);
152
159
  if (gitignoreWarning)
153
160
  lines.push(gitignoreWarning);
package/dist/ticket.js CHANGED
@@ -47,6 +47,14 @@ export function parseDispatchTable(markdown, lang) {
47
47
  if (rows.length === 0) {
48
48
  throw new DispatchError(m(lang, "dispatchTableEmpty"), 2);
49
49
  }
50
+ const distinctAgents = new Set(rows.map((r) => r.agent));
51
+ if (distinctAgents.size !== rows.length) {
52
+ const counts = new Map();
53
+ for (const row of rows)
54
+ counts.set(row.agent, (counts.get(row.agent) ?? 0) + 1);
55
+ const [dupAgent, dupCount] = [...counts.entries()].find(([, count]) => count > 1);
56
+ throw new DispatchError(m(lang, "duplicateAgentInDispatchTable", dupAgent, dupCount), 2);
57
+ }
50
58
  return rows;
51
59
  }
52
60
  // plan_i18n_v1.2.md §6.2:「讀檔 → 剝 frontmatter → trim」這個片段跨多處共用(原為
@@ -55,18 +63,42 @@ export function stripFrontmatter(markdown) {
55
63
  const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
56
64
  return (match ? match[1] : markdown).trim();
57
65
  }
66
+ // 工單 X1 v1.1 §一:圍籬內的行一律不視為標題比對對象,避免內嵌規劃書的 code block
67
+ // 挾帶行首 `#` 把待審段落(或回報稽核)切斷。
68
+ function matchFence(line) {
69
+ const match = line.match(/^\s*(`{3,}|~{3,})/);
70
+ if (!match)
71
+ return null;
72
+ return { char: match[1][0], len: match[1].length };
73
+ }
58
74
  // 匯出供 audit.ts 重用(回報模板同樣是 `# 標題` 結構)。
59
75
  export function splitTopLevelSections(markdown) {
60
76
  const lines = markdown.split(/\r?\n/);
61
77
  const sections = new Map();
62
78
  let currentHeading = null;
63
79
  let buffer = [];
80
+ let openFence = null;
64
81
  const flush = () => {
65
82
  if (currentHeading !== null) {
66
83
  sections.set(currentHeading, buffer.join("\n").trim());
67
84
  }
68
85
  };
69
86
  for (const line of lines) {
87
+ const fence = matchFence(line);
88
+ if (openFence) {
89
+ if (fence && fence.char === openFence.char && fence.len >= openFence.len) {
90
+ openFence = null;
91
+ }
92
+ if (currentHeading !== null)
93
+ buffer.push(line);
94
+ continue;
95
+ }
96
+ if (fence) {
97
+ openFence = fence;
98
+ if (currentHeading !== null)
99
+ buffer.push(line);
100
+ continue;
101
+ }
70
102
  const match = line.match(/^#\s+(.+?)\s*$/);
71
103
  if (match) {
72
104
  flush();
@@ -86,6 +118,12 @@ function parseBulletList(body) {
86
118
  .map((l) => l.match(/^-\s+(.+)$/)?.[1]?.trim())
87
119
  .filter((v) => Boolean(v && v.length > 0));
88
120
  }
121
+ // `_shared.md` 裡「前提/待審段落」以外的頂層章節。判準與下方中止路徑共用同一份
122
+ // ——2026-08-15 的清查證實它可靠:tmp/dispatch 底下 21 份真實工單,12 份中招全中、
123
+ // 9 份正常零誤報。
124
+ function findStrayHeadings(sections) {
125
+ return [...sections.keys()].filter((k) => k !== "待審段落" && k !== "Under review" && !k.startsWith("前提") && k !== "Premises");
126
+ }
89
127
  // §4:`_shared.md`。「待審段落」缺失或空即中止;「前提」缺失為警告(空前提合法)。
90
128
  export function parseSharedDoc(markdown, lang) {
91
129
  const sections = splitTopLevelSections(markdown);
@@ -96,7 +134,7 @@ export function parseSharedDoc(markdown, lang) {
96
134
  // 原訊息「缺或內容為空」會讓人先去查自己有沒有寫,方向就錯了。標題存在卻空白時,
97
135
  // 直接指名是誰切斷了它。
98
136
  if (sections.has("待審段落") || sections.has("Under review")) {
99
- const stray = [...sections.keys()].filter((k) => k !== "待審段落" && k !== "Under review" && !k.startsWith("前提") && k !== "Premises");
137
+ const stray = findStrayHeadings(sections);
100
138
  if (stray.length > 0) {
101
139
  throw new DispatchError(m(lang, "strayHeadingsCutReviewSection", stray), 2);
102
140
  }
@@ -105,7 +143,13 @@ export function parseSharedDoc(markdown, lang) {
105
143
  }
106
144
  const premisesBody = sections.get("前提(不受審)") ?? sections.get("前提") ?? sections.get("Premises");
107
145
  const premises = premisesBody ? parseBulletList(premisesBody) : [];
108
- return { premises, reviewText };
146
+ // 2026-08-15:**上面那道防護只擋得住「整段被切光」。** 切在段落中間時 reviewText 非空,
147
+ // 先前一路靜默放行——tmp/dispatch 的 12 份 i18n 翻譯審查工單就是這樣派出去的,
148
+ // 待審段落最短只剩 25 字元(引言句),中文原文全被切走,而允許清單裡只有英文譯文,
149
+ // spoke 手上沒有可比對的原文。$0.1780、12 次派工、結論全部無效,稽核六格沒有一格會說。
150
+ // 這裡只記錄,警告由 report.ts 在付費前的報表印出——非空時無法斷定是不是刻意的,
151
+ // 中止會擋掉合法用法。
152
+ return { premises, reviewText, strayHeadings: findStrayHeadings(sections) };
109
153
  }
110
154
  // §4:`<agent>.md`。「具體問題」缺失或空即中止;「允許讀取」缺失為警告(空清單合法)。
111
155
  export function parseAgentTicket(markdown, lang) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
package/providers.json CHANGED
@@ -31,13 +31,18 @@
31
31
  "allowed": ["low", "high", "max"],
32
32
  "default": "high"
33
33
  },
34
- "models": ["deepseek-v4-flash"],
34
+ "models": ["deepseek-v4-flash", "deepseek-v4-pro"],
35
35
  "charsPerToken": 1.0,
36
36
  "tpmLimit": null,
37
37
  "maxSpokeTokens": null,
38
- "pricingSource": { "url": "https://api-docs.deepseek.com/quick_start/pricing/", "asOf": "2026-08-09", "note": "官方預告近期將調漲,且幅度顯著" },
38
+ "pricingSource": {
39
+ "url": "https://api-docs.deepseek.com/quick_start/pricing/",
40
+ "asOf": "2026-08-14",
41
+ "note": "本表填的是 2026-08-16 16:00 UTC 起生效的尖峰價。在此之前官方仍收舊價(flash 0.0028/0.14/0.28、pro 0.003625/0.435/0.87),所以生效前本表偏高。離峰(尖峰時段 01:00-04:00 與 06:00-10:00 UTC 以外)為尖峰的一半;本檔一個型號只放得下一組價,取尖峰=取上限。"
42
+ },
39
43
  "pricing": {
40
- "deepseek-v4-flash": { "inputPerM": 0.14, "cachedInputPerM": 0.0028, "outputPerM": 0.28 }
44
+ "deepseek-v4-flash": { "inputPerM": 0.44, "cachedInputPerM": 0.014, "outputPerM": 1.32 },
45
+ "deepseek-v4-pro": { "inputPerM": 1.32, "cachedInputPerM": 0.044, "outputPerM": 3.96 }
41
46
  }
42
47
  },
43
48
  "gemini": {
@@ -3,7 +3,7 @@ name: find-holes-external
3
3
  description: Dispatch sections of a plan document to external models (OpenAI / DeepSeek / Gemini / Anthropic) for hole-finding review, executed through the local dowafu CLI, where spokes are read-only and governed by an allowlist. Use when you need a heterogeneous perspective, or when the plan has to be reviewed against the real source code. Usage: /find-holes-external <path to plan> [section or question to focus on]
4
4
  metadata:
5
5
  derived-from: ".claude/skills/find-holes-external/SKILL.md"
6
- derived-from-sha256: "c738dad01f448eb1ea86690ce10946f5091d768a6ba311289387ab84431fbadc"
6
+ derived-from-sha256: "cef716ce7aeee504945074795dd37d0a4f85fdbb6f2a15cffb2471dab1a03982"
7
7
  ---
8
8
 
9
9
  # find-holes-external — external hole-finding
@@ -38,6 +38,8 @@ The tool is `dowafu` (a local CLI, already installed globally); invoke it with y
38
38
  cd <absolute path to the repo root> && dowafu <ticket dir> --repo-root . --dry-run
39
39
  ```
40
40
 
41
+ **`--lang` sets the language of the CLI output and of the spoke prompts; it defaults to `en`.** This is the English pack and its lens files are in English, so the default already matches — pass `--lang en` explicitly only if `DISPATCH_LANG` is set to something else in this environment. Precedence is `--lang` > `DISPATCH_LANG` > the built-in `en`. A pack and a run language that disagree raise no error at all; you simply get a report in one language and spoke prompts in the other.
42
+
41
43
  There is no guarantee which workspace folder your terminal lands in, and `--repo-root` defaults to the cwd. A wrong cwd fails **silently** — the ticket still parses, the spokes still go out, only the allowlist boundary and the lens definitions point somewhere else.
42
44
 
43
45
  **Two: `dowafu` is an external global CLI and is not inside the workspace. Do not go looking for it; just run:**
@@ -76,6 +78,7 @@ Poll `tmp/spoke/<ticket-id>/run.jsonl` with your file-reading tool: the CLI writ
76
78
  | Each spoke's provider / model | See below |
77
79
  | **The per-question "question → which file holds the answer → is it on the list?" mapping** | **Mandatory; format below** |
78
80
  | Estimated cost magnitude | For reference: three spokes on a medium ticket run about 40k tokens |
81
+ | **Where each spoke's artifacts land** | **Mandatory**, see "One spoke, one landing spot" below |
79
82
 
80
83
  ### Questions and the allowlist **must be listed against each other, question by question**
81
84
 
@@ -94,11 +97,38 @@ Listing it per question lets the user see at a glance what is missing. **That co
94
97
 
95
98
  **When deciding the allowlist, ask yourself question by question: "where is the answer to this one? is that file on the list?"** Matching files to the lens's name (giving the safety lens the security-related files) produces the wrong list — the lens is **the angle you look from**, the list is **the material you look at**. When the list contains only the **producing** side of some behavior while the question asks about the **displaying** side, the spoke is physically incapable of answering correctly; swap in a set of files aimed at where the answers live and the same lens finds it. **The point of this self-check is to catch the gap before dispatching, not after.**
96
99
 
97
- ### One table per spoke — the list is cut per lens, not once for the dispatch
100
+ ### One table per spoke — and the table is the deliverable, not a claim about one
101
+
102
+ **Fill in that table once per spoke, and put both tables in front of the user.** Saying the lists were checked, or that they cover what is needed, does not replace showing them: "the allowlist covers every question" is a sentence, and a sentence costs nothing to write whether or not it is true.
103
+
104
+ Each row needs **the path you actually expect the answer in** — not a directory, not "the tags routes", not the lens's name. Write it the way it appears in the allowlist so the two can be read against each other:
105
+
106
+ | Q | Question | Which file holds the answer | On the list? |
107
+ | --- | --- | --- | --- |
108
+ | 1 | Does `requireAuth` return the userId the delete-self check needs | `lib/auth-guard.ts` | ✅ |
109
+ | 2 | Is the last-admin guard atomic | `prisma/schema.prisma` (the passage itself is the rest) | ✅ |
110
+
111
+ **Before writing a path into that column, confirm the answer is in that file.** Grep for the symbol, or open it. The column exists to catch a missing file before dispatch; filled in from memory it catches nothing — a plausible-looking filename passes the format check exactly as well as the right one does, and the spoke pays for the difference.
112
+
113
+ **Two spokes may end up with the same files, but say why.** An identical list is a result you can explain, not a starting point — and a list you trimmed until the two differed is the same mistake wearing the opposite mask.
114
+
115
+ **One list shared by two lenses converges on the intersection, not the union.** What drops out first is whatever only one lens needed, which is precisely what that lens was dispatched to look at; the spoke can then only record the gap in its "cannot verify" section, and finding out that way costs a full dispatch. Trimming for cost is legitimate — trim each spoke's list against its own questions, never against the other spoke's.
116
+
117
+ ### One spoke, one landing spot — dig the holes before you dispatch
118
+
119
+ A spoke's artifacts land in `tmp/spoke/<ticket-id>/` under the group of files named after its agent: `<agent>.md` plus `raw/<agent>.request.json` / `.response.json` / `.errors.json`. That group moves together, so the landing spot is the pair `<ticket-id>` + `<agent>`.
120
+
121
+ **List the landing spot for every spoke in the dispatch plan. The criterion is one line: as many landing spots as spokes, all distinct.**
98
122
 
99
- **Fill in that table once per spoke.** Each spoke gets its own questions, so it gets its own list; handing two lenses the same list because assembling one is less work is how a file that only one of them needed goes missing. If two spokes do end up with the same files, be able to say why — an identical list is a result, not a starting point.
123
+ | Spoke | Lens | Provider / model | Artifacts land in |
124
+ | --- | --- | --- | --- |
125
+ | 1 | safety | openai / gpt-5.6-luna | `tmp/spoke/auth-review-luna/hole-finder-safety.md` |
126
+ | 2 | safety | deepseek / deepseek-v4-flash | `tmp/spoke/auth-review-ds/hole-finder-safety.md` |
127
+ | 3 | feasibility | gemini / gemini-3.6-flash | `tmp/spoke/auth-review-luna/hole-finder-feasibility.md` |
128
+
129
+ **Two spokes resolving to the same path means you are one hole short** — and the fix is not a different filename, it is a separate ticket directory: one model per directory, suffix the ticket-id, dispatch each once. Different lenses can share a directory; their agent names already differ.
100
130
 
101
- **One list shared by two lenses converges on the intersection, not the union.** What drops out first is whatever only one lens needed, which is precisely what that lens was dispatched to look at; the spoke can then only record the gap in its "cannot verify" section, and finding out that way costs a full dispatch. Trimming for cost is legitimate — trim each spoke's list against its own questions.
131
+ Count that column, do not eyeball it. **You do not need to know what a collision does** if the count is off, stop and split the directories.
102
132
 
103
133
  ### Lenses
104
134
 
@@ -115,7 +145,7 @@ Dispatching one is fine; dispatching all three is fine. **Do not rule a lens out
115
145
  | provider | model |
116
146
  | --- | --- |
117
147
  | `openai` | `gpt-5.6-luna` / `gpt-5.6-terra` / `gpt-5.6-sol` |
118
- | `deepseek` | `deepseek-v4-flash` |
148
+ | `deepseek` | `deepseek-v4-flash` / `deepseek-v4-pro` |
119
149
  | `gemini` | `gemini-3.1-flash-lite` / `gemini-3.5-flash-lite` / `gemini-3.6-flash` |
120
150
  | `anthropic` | `claude-opus-5` / `claude-sonnet-5` |
121
151
 
@@ -146,6 +176,8 @@ Write into `tmp/dispatch/<ticket-id>/`, using a topic slug as `<ticket-id>` (for
146
176
 
147
177
  The first line `<!-- format: v1 -->` is **required**; `model` is required; list only the spokes you are dispatching. `effort` may be left blank, meaning **`high`** — all four providers currently default `reasoning.default` to `high`. To change it, check `providers.json`'s `reasoning.allowed`: **each provider's range differs** (for example `deepseek` has no `medium`); a value outside that range is rejected with the list of allowed values shown, never silently downgraded.
148
178
 
179
+ **No agent may appear twice in the same `_dispatch.md`.** One row per agent; to run one lens across several models, split it into separate ticket directories (see "One spoke, one landing spot" in §2). **The CLI rejects a duplicate agent at parse time, so the dry run stops as well** — that is the last line of defence, not a reason to skip counting landing spots.
180
+
149
181
  ### `_shared.md` (shared by every spoke)
150
182
 
151
183
  ```markdown
@@ -177,7 +209,7 @@ The first line `<!-- format: v1 -->` is **required**; `model` is required; list
177
209
 
178
210
  2. **Keep questions open; do not point at what you have already found.** Write the answer into the question and the spoke finding it is just the ticket read back to you.
179
211
 
180
- 3. **The allowlist must cover the files actually needed to answer those questions.** Ask yourself per question: which files do I have to read to answer this? If one is missing, all the spoke can do is note what it lacked in its "cannot verify" section — **that is the dispatcher's failure, not its own**. Paths are **relative to the repo root**. An empty list is legal (a pure text review), but then it cannot read code and you lose the most valuable class of finding: "the document says X, but `src/foo.ts:42` actually does Y". **The list belongs to this `<agent>.md`, not to the dispatch** — do not copy another spoke's list over wholesale: a file that spoke needs and this one does not is dead weight in the read order, and the reverse is a hole.
212
+ 3. **The allowlist must cover the files actually needed to answer those questions.** Ask yourself per question: which files do I have to read to answer this? If one is missing, all the spoke can do is note what it lacked in its "cannot verify" section — **that is the dispatcher's failure, not its own**. Paths are **relative to the repo root**. An empty list is legal (a pure text review), but then it cannot read code and you lose the most valuable class of finding: "the document says X, but `src/foo.ts:42` actually does Y". **The list belongs to this `<agent>.md`, not to the dispatch** — do not copy another spoke's list over wholesale: a file that spoke needs and this one does not is dead weight in the read order, and the reverse is a hole. **A file you did not open is not evidence that the answer is elsewhere** — if you cannot point to the file that answers a question, the question has no file behind it yet, whatever the table says.
181
213
 
182
214
  4. **Put the large files last — this alone can halve the cost.** Spokes read files in **strict list order**, most models **call for one file per round**, and every round resends everything read so far. So the number of times a file is billed again = **total rounds − the round it was read in** — **the earlier it sits, the more times it is resent**. For a file of a dozen-odd k tokens, first versus last can nearly double that spoke's total. The method is simple: **sort ascending by file size**, largest last. If you are unsure of the size, `wc -l` first. **This still applies when you shuffle the list order** — pin the large files at the end and shuffle only the rest.
183
215
 
@@ -209,6 +241,8 @@ What it catches: `repoRoot` pointing at the wrong project, a wrong model name, m
209
241
  dowafu tmp/dispatch/<ticket-id> --repo-root . --dry-run
210
242
  ```
211
243
 
244
+ **One dry run covers one ticket directory.** If this batch was split across several directories — which it is whenever one lens runs across several models — **dry-run each of them**, and add the estimates together before putting any number in front of the user. Dry-running the first one and going straight to the real run leaves every other directory unchecked.
245
+
212
246
  **Explain what this step is for before you run it.** The user has probably never used this tool, and seeing you issue a command will make them think dispatching — and billing — has already started:
213
247
 
214
248
  > This step only parses the ticket, validates the configuration, and estimates usage. **It calls no API and incurs no cost.** Its purpose is to confirm that what is about to go out is correct, before any money is spent.
@@ -227,6 +261,22 @@ dowafu tmp/dispatch/<ticket-id> --repo-root . --dry-run
227
261
 
228
262
  **Whatever you relay, the qualifiers come with it.** The report's hedges are what stop the numbers being misread: that a total is a ceiling rather than an expectation, which day the price list was drawn from, what assumptions an estimate rests on, the lines confirming each lens's closing line. They are the first things to look droppable and the only things that make the numbers safe to act on. **Drop a qualifier and you have handed the user a firmer number than the tool gave you.**
229
263
 
264
+ **Lines the tool marks with `⚠` or `ℹ` are relayed word for word, never paraphrased.** A `⚠` line is the tool telling you something is wrong right now — that the output directory already holds artifacts, that the list order is costing you money, that a spoke read nothing. Rewriting one into a calmer sentence is the single most expensive thing you can do to this report, because the reader loses the only signal that asked for a decision. In particular: `⚠ Sorting large files last could bring this down to N` means **your order is not sorted**; it does not mean "already sorted, reordering would save a little".
265
+
266
+ The qualifiers that must survive, by name:
267
+
268
+ | Where | What must come with the number |
269
+ | --- | --- |
270
+ | Each spoke's line | `effort=`, `lang=`, `store=`, and its `cap` |
271
+ | Price sub-line | the per-M figures **and** `priced as of <date>` |
272
+ | `ℹ` closing-line checks | one line per spoke, as printed |
273
+ | Initial prompt estimate | that it excludes the ticket and the allowlist, and the gate's cap |
274
+ | Allowlist estimate | that it is an upper bound, **not deduplicated**, and the chars-per-token basis |
275
+ | Read-order amplification | that it is an upper bound assuming sequential reads and does not apply to batching providers; the ordering verdict; and that the figure excludes the initial prompt and ticket |
276
+ | Worst-case total | that it is a **ceiling, not an expectation**, and that it is the sum of the per-spoke caps |
277
+
278
+ Numbers without these read as firmer than the tool meant them. If you convert tokens to money yourself, say that the conversion is yours and which price line you used.
279
+
230
280
  The report gives tokens, not money, and that **only holds for the dry run**. To convert to money, **the price list is `providers.json`'s `pricing`** (`inputPerM` / `cachedInputPerM` / `outputPerM`) — **do not look it up on the vendor's website**: those numbers are exactly what the CLI bills against, and pulling from the website would make "what you reported" and "what the CLI actually charges" disagree. If `pricingSource.asOf` looks stale, report it to the user rather than editing the number yourself (fix `providers.json` instead). **For the real run**, `summary.md` has an "estimated cost" column, and each spoke also prints a `cost=` line when it finishes — that figure is already computed by the CLI, so **just relay it; do not compute it yourself**.
231
281
 
232
282
  Then check each item: `repoRoot` is this project, `model` matches what you wrote, **the `effort` printed in the report is the tier you expected**, the token estimate is a sensible magnitude, and **there is no `⚠ Output directory ... is not ignored by the git repo it lives in` warning**.
@@ -291,6 +341,8 @@ dowafu tmp/dispatch/<ticket-id> --repo-root . --yes
291
341
 
292
342
  The artifacts are in `tmp/spoke/<ticket-id>/`: `<agent>.md` (the report), `summary.md` (the audit table), `run.jsonl` (the execution log), and `raw/` (complete requests and responses).
293
343
 
344
+ **`run.jsonl` is appended event by event and is never overwritten.** If the artifacts and `raw/` were clobbered — by a rerun, or by another spoke writing to the same name — that file still holds each spoke's `spoke_start` (provider and model), per-round usage, every read attempt including the refused ones, any errors, and the `spoke_end` token and cost figures. The report text is gone; what was spent and what was read can still be reconstructed.
345
+
294
346
  > **Confirm §5's startup check passed before collecting.** No file in this directory will tell you whether it belongs to this run.
295
347
 
296
348
  **The order of presentation must not be changed**:
@@ -310,7 +362,7 @@ The artifacts are in `tmp/spoke/<ticket-id>/`: `<agent>.md` (the report), `summa
310
362
  **A segment beginning with `⚠` is never dropped, and neither is `(audit unavailable)`.** Those are not decoration sitting outside the named columns; they are the audit telling you something went wrong, and dropping them leaves the user holding only the parts that said nothing did.
311
363
 
312
364
  **Any segment carrying content is reproduced word for word.** Only `pass` and the CLI's own empty-value word may be compressed. The segments saying "nothing here" are the cheap ones to keep, and the one saying something is the one worth dropping — so this rule is deliberately asymmetric: **the more a segment has to say, the less freedom you have with it.**
313
- 3. **Then open a separate "hub assessment" section** — deduplicate, and annotate each item with your preliminary judgment (holds / does not hold + why / needs the user's ruling).
365
+ 3. **Then open a separate "hub assessment" section** — deduplicate, and annotate each item with your preliminary judgment (holds / does not hold + why / needs the user's ruling). **Every item carries the observations it came from**, by spoke and number (`safety 2, 3; feasibility 9`), and **every observation appears against at least one item**. Two spokes' worth of numbered observations either all show up in that column or the ones that did not are visible at a glance — which is the point: without the numbers, an observation that quietly failed to make it into the assessment cannot be told apart from one you judged and dismissed.
314
366
 
315
367
  ### Merging multiple runs
316
368
 
@@ -324,8 +376,12 @@ The artifacts are in `tmp/spoke/<ticket-id>/`: `<agent>.md` (the report), `summa
324
376
 
325
377
  **Line numbers must be re-verified.** A spoke's citations can be off by anywhere from a few to dozens of lines while **the description of the content is usually right** — usable at the fact level, unusable at the location level. **A wrong location is not a hallucination** (a hallucination is "that passage does not exist in that file at all"), and the two are handled differently: a hallucination calls for a rerun or a different model, a wrong location only needs you to locate it again. **Mistaking a wrong location for a hallucination throws away an entire usable output.**
326
378
 
379
+ **This applies to the line numbers you write, too.** Re-verifying a spoke's citation and then citing it from memory a few paragraphs later puts the drift back in under your own name — and yours carries more weight with the user, because you said you opened the file.
380
+
327
381
  **Three: when verifying a spoke's citation, a comment is not evidence.** "The spoke says a comment backs this conclusion" is not enough — you also have to verify **whether what the comment says still holds**. Comments drift away from the code, and a drifted comment reads exactly like a correct one, so verifying only that "the comment exists and matches" turns a wrong claim into an accepted one. The first item of the MUST checklist in AGENTS.md, "a filename, a comment, or a line number is not enough", was written for authoring plans; here it extends to accepting a spoke's citations.
328
382
 
383
+ **A spoke reporting "I could not read X" is a correct report, not a false alarm.** It names a file you did not put on its list, which makes it your gap and not its mistake — filing it under "false alarm", or quietly resolving it yourself and moving on, hides the one signal that tells you the allowlist was wrong. Resolve it if you can, and still say plainly that the list was short.
384
+
329
385
  ### Reading the audit table
330
386
 
331
387
  | Column | Meaning |
@@ -3,7 +3,7 @@ name: preflight
3
3
  description: Before starting work in a project, check whether its environment has silently disabled the workflow: whether the workflow-specification chapter is actually readable, whether the skills and lenses are present, whether tmp/ is gitignored, and whether dowafu runs. Read-only, report-only — changes no settings.
4
4
  metadata:
5
5
  derived-from: ".claude/skills/preflight/SKILL.md"
6
- derived-from-sha256: "3dfe6eda496a2619d240abefa31791c650d78abace10c70f4ce6df2b80f52e17"
6
+ derived-from-sha256: "d1f8de52f2cca4332790da137274c7162af08327594d805c71c6f275a4f6fcd8"
7
7
  ---
8
8
 
9
9
  # preflight — environment pre-check
@@ -28,7 +28,8 @@ cd <absolute path to the repo root>
28
28
  echo "=== workflow specification ==="
29
29
  ls CLAUDE.md AGENTS.md workflow_spec.md 2>&1
30
30
  # This only locates which file holds the content. A hit here ≠ you can read it — see the criterion below.
31
- grep -n "Plan Implement Accept" CLAUDE.md AGENTS.md workflow_spec.md 2>/dev/null
31
+ # Both spellings: a project may carry this chapter in another language than the pack you installed.
32
+ grep -nE "Plan → Implement → Accept|規劃→實作→驗收" CLAUDE.md AGENTS.md workflow_spec.md 2>/dev/null
32
33
 
33
34
  echo "=== skills and lenses ==="
34
35
  ls .claude/skills/ 2>/dev/null
@@ -42,6 +43,8 @@ git check-ignore -q tmp && echo "ignored" || echo "not ignored"
42
43
 
43
44
  **There is only one criterion: can you, right now, read the contents of the chapter "Plan → Implement → Accept (hub-and-spoke form)"?**
44
45
 
46
+ **The chapter may be there under the other language's heading.** Language packs install their own copy, so a project that already had this chapter can end up with two — in two languages, only one of which auto-loads. The grep above looks for both spellings for exactly this reason; if it returns hits in more than one file, read the next paragraph before deciding anything.
47
+
45
48
  If you can read it, it passes. Whether the content is pasted directly into the entry file or pulled in via something like `@` is **the user's choice and outside the scope of this check**.
46
49
 
47
50
  If you cannot read it, mark it as failing, then **go find it yourself** (usually `workflow_spec.md` at the repo root), and note in your report that "the specification is not in the auto-loaded set; it was read manually this time" — so the user knows a different session will miss it again.
@@ -73,7 +76,7 @@ If it is not gitignored, mark it as failing: spoke reports contain the verbatim
73
76
 
74
77
  ---
75
78
 
76
- ## 2. Three more things this toolchain needs checked
79
+ ## 2. Four more things this toolchain needs checked
77
80
 
78
81
  **In this order** — if an earlier one does not hold, checking the later ones is pointless.
79
82
 
@@ -94,6 +97,8 @@ Printing a version number passes. Failing to print one means exactly one of two
94
97
 
95
98
  **`command -v dowafu` finding nothing does not mean it is not installed** — do not use that as the criterion.
96
99
 
100
+ **A command that neither returns nor errors is a third case: it is waiting.** Without `--yes` the CLI prints a confirmation prompt and blocks on stdin; depending on the host that surfaces as a timeout, as silence, or as an offer to send input on your behalf. Note which one your environment does — you will meet it again at dispatch time, and that is a much worse moment to find out.
101
+
97
102
  ### Two: whether the lens definitions and skills are present
98
103
 
99
104
  See "skills and lenses" in section 1. One point matters especially here:
@@ -106,6 +111,18 @@ See "is the workflow specification readable" in section 1; the criterion is the
106
111
 
107
112
  **This is an easy one to trip on**, so it is worth a second look. `@xxx.md` is one particular host's import syntax and you will not expand it — if the entry file contains only that line, what you see is one line of text, and **you may well believe you have already read the specification**. Actually check whether that chapter's content is in your context; do not go by impression.
108
113
 
114
+ ### Four: is the CLI configured — `dowafu --doctor`
115
+
116
+ ```bash
117
+ dowafu --doctor
118
+ ```
119
+
120
+ It prints where the config directory resolved to, whether `.env` is there, **which providers have a key** (presence only — it never prints a value), the bundled model whitelist, and which lens definitions it found. It calls no API and costs nothing, and it needs no ticket, which is what makes it usable before anything else exists.
121
+
122
+ Report the missing rows as they are printed. **Do not offer to write the key for the user, and do not ask them to paste one into this conversation** — whatever is pasted here stays in this conversation's history. Creating the directory and an empty template is fine; the value itself is theirs to type into the file.
123
+
124
+ A `dowafu --doctor` that prints nothing but an error is the same finding as item one: the CLI is not runnable from here, and nothing below it matters yet.
125
+
109
126
  ---
110
127
 
111
128
  ## 3. Output
@@ -59,6 +59,8 @@ Do not split the questions and the file list into two separate blocks — that m
59
59
 
60
60
  **This is the most common mistake** — "asking a question without providing the file needed to answer it". Listing it per question lets the user see at a glance what is missing. **That column is not a formality; it is currently the only thing standing between you and a missing file.**
61
61
 
62
+ **Before writing a path into that column, confirm the answer is in that file** — grep for the symbol, or open it. Filled in from memory the column catches nothing: a plausible-looking filename passes the format check exactly as well as the right one does, and the difference only surfaces a full dispatch later. **Two spokes may end up with the same files, but say why** — an identical list is a result you can explain, not a starting point.
63
+
62
64
  **Any change the user makes to the count, the models, or the lenses is followed without argument.**
63
65
 
64
66
  ### 3.1 Once confirmed, every prompt must contain
@@ -67,6 +69,7 @@ Do not split the questions and the file list into two separate blocks — that m
67
69
  - **The list of source-code files the spoke may read**, with an explicit prohibition on browsing any other document under `_docs/`
68
70
 
69
71
  When trimming that list, **ask yourself question by question: "where is the answer to this one? is that file on the list?"** Matching files to the lens's name (giving the safety lens the security-related files) produces the wrong list — **the lens is the angle you look from, the list is the material you look at**. If the list does not line up with where the answers live, the spoke is physically incapable of answering correctly. **A missing file is the dispatcher's failure, not the spoke's.** **Each spoke's list is trimmed against its own questions** — do not give two lenses the same list because one list is less work to assemble; whatever only one of them needed is what goes missing.
72
+ **A file you did not open is not evidence that the answer is elsewhere** — if you cannot point to the file that answers a question, that question has no file behind it yet, whatever the table says.
70
73
 
71
74
  - **Put the large files last on the list** (ascending by file size). Spokes read files in list order, and every round resends everything read so far, so the earlier a file sits, the more times it is billed again — the gap can approach a factor of two. In-process dispatch has a different context mechanism and the effect may not be the same, but ordering costs nothing and has no side effects; doing it anyway cannot hurt.
72
75
 
@@ -103,6 +106,8 @@ You revise the plan for the items that were adopted (a new version writes only t
103
106
 
104
107
  > **A wrong location is not a hallucination.** A hallucination is "that passage does not exist in that file at all", and the remedy is a rerun or a different model; a wrong location only needs you to locate it again. Mistaking the former for the latter throws away an entire usable output.
105
108
 
109
+ **A spoke reporting "I could not read X" is a correct report, not a false alarm.** It names a file you did not put on its list, which makes it your gap and not its mistake — filing it under "false alarm", or quietly resolving it yourself and moving on, hides the one signal that tells you the list was wrong. Resolve it if you can, and still say plainly that the list was short.
110
+
106
111
  ## Red lines
107
112
 
108
113
  - A spoke's observations **must never touch "whether to do it"**. If one produces a terminate/block style conclusion, discard that conclusion, keep only the factual part of it, and note this in your report.