dowafu 0.3.1 → 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/audit.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // finalText 與允許讀取清單,吐結構化稽核結果。「疑似」而非「fail」的界線見 §15 說明——
3
3
  // 禁止內容關鍵詞必有誤判,故只標記交 hub 判讀,不得自動刪改 spoke 原文。
4
4
  import { splitTopLevelSections } from "./ticket.js";
5
+ import { REPORT_PLACEHOLDERS } from "./prompt.js";
5
6
  export const FIXED_CLOSING_LINE = "以上為觀察與問題,採用與否由 hub 與使用者裁決。";
6
7
  // 英文工單走英文模板,收尾句與章節名也跟著換。稽核**兩套都認**且不看工單語言——spoke
7
8
  // 偶爾會用另一種語言作答,那是產出品質的事,不該讓稽核整份判 fail 而蓋掉真正的訊號。
@@ -16,18 +17,38 @@ function getSection(sections, names) {
16
17
  }
17
18
  return undefined;
18
19
  }
19
- const SUSPECT_PHRASES = ["應該改成", "建議採用", "嚴重度", "高風險", "應廢止"];
20
- // plan_i18n_v1.2.md §4.1/i18n_classification_t2.md §五 #8-12:英文回報若只比對中文詞,
21
- // 稽核會靜默失效——不報錯、不變紅,只是什麼都抓不到。兩套並存同時比對,不隨 lang 切換
22
- // (spoke 可能用另一種語言作答,稽核本來就不看工單語言)。summary.md 需要分開標示是
23
- // 哪一套命中,供日後調整這份清單時有資料可依據,故 auditSpoke 分開回傳兩個陣列。
24
- const SUSPECT_PHRASES_EN = ["should be changed to", "recommend adopting", "severity", "high risk", "should be deprecated"];
25
20
  // 抓看起來像相對路徑的引用:至少一層目錄+副檔名,容許前後有反引號與 :行號。
26
21
  // plan_dispatch_v1.12.md §15:字元類須容許中括號,否則 Next.js 動態路由段([id]、
27
22
  // [...slug]、[[...slug]])會把路徑從中括號後截斷——截斷後的字串當然不在允許清單內,
28
23
  // 於是每一條合法引用都被誤判為清單外(首次外部派工實測:三個 spoke 全部誤報)。
29
24
  // 比對方式維持精確集合比對不變,只改路徑抽取的容許字元。
30
25
  const PATH_REGEX = /`?([\w.\-[\]]+(?:\/[\w.\-[\]]+)+\.[A-Za-z0-9]{1,10})(?::\d+)?`?/g;
26
+ function countOccurrences(text, needle) {
27
+ let count = 0;
28
+ let idx = 0;
29
+ for (;;) {
30
+ const found = text.indexOf(needle, idx);
31
+ if (found === -1)
32
+ return count;
33
+ count++;
34
+ idx = found + needle.length;
35
+ }
36
+ }
37
+ // external-luna-high-r3 的實際形態:spoke 把 `<觀察>` 當成 XML 標籤,自創 `</觀察>` 收尾。
38
+ // 對每個佔位符一併查它的自創收尾標籤形態,不只查模板原樣的開標籤。
39
+ function countTemplatePlaceholders(finalText) {
40
+ const hits = [];
41
+ for (const placeholder of REPORT_PLACEHOLDERS) {
42
+ const openCount = countOccurrences(finalText, placeholder);
43
+ if (openCount > 0)
44
+ hits.push({ placeholder, count: openCount });
45
+ const closingTag = `</${placeholder.slice(1)}`;
46
+ const closingCount = countOccurrences(finalText, closingTag);
47
+ if (closingCount > 0)
48
+ hits.push({ placeholder: closingTag, count: closingCount });
49
+ }
50
+ return hits;
51
+ }
31
52
  // plan_dispatch_v1.9.md §15:稽核的職責是記錄,不是規範 spoke 的表達結構。原本只認
32
53
  // 範本的平鋪編號("1. "),deepseek 用「## 問題 N」+「**觀察 N.N**」的巢狀結構時被判成
33
54
  // 0 條——而那次是三份回報中最豐富的一份。放寬為多種常見形式皆計入,依序嘗試、採第一個
@@ -56,35 +77,16 @@ function countObservations(observationsBody) {
56
77
  }
57
78
  return null; // 章節有內容,但沒有一種已知樣式命中——無法辨識,不是沒有
58
79
  }
59
- // 找出 targetPath 第一次出現的頂層章節(依文件順序)。用同一個 PATH_REGEX 逐章節重新
60
- // 抽取比對,而非對章節內文字做 includes 子字串比對,避免相近路徑互相誤判。
61
- function findSection(sections, targetPath) {
62
- for (const [heading, body] of sections) {
63
- const pathsInSection = new Set([...body.matchAll(PATH_REGEX)].map((m) => m[1]));
64
- if (pathsInSection.has(targetPath))
65
- return heading;
66
- }
67
- return null;
68
- }
69
- // 若 citedPath 是某個允許清單項目的路徑後綴(以 "/" 為界,非任意子字串),回傳該項目
70
- // 原始字串——供讀者判斷是否為縮寫,而非臆測。
71
- function findSuffixSource(citedPath, allowedRelativePaths) {
72
- return allowedRelativePaths.find((allowed) => allowed.length > citedPath.length &&
73
- allowed.endsWith(citedPath) &&
74
- allowed[allowed.length - citedPath.length - 1] === "/");
75
- }
76
- export function auditSpoke(finalText, allowedRelativePaths) {
80
+ // 工單 X1 v1.1 §六(使用者裁示 2026-08-15):「清單外引用」欄拿掉的是判定,citedPaths 這個
81
+ // 記錄本身保留——它是純資料,不含判斷,供日後「引用 vs 實際讀取」的交叉分析使用。
82
+ export function auditSpoke(finalText) {
77
83
  if (!finalText) {
78
84
  return {
79
85
  finalLinePass: false,
80
86
  observationCount: null, // 完全沒有內容可數,不是「數出來是零」
81
87
  citedPaths: [],
82
- citedPathsOutsideAllowlist: [],
83
- citedPathsOutsideAllowlistDetail: [],
84
88
  cannotVerifySectionPresent: false,
85
- suspectPhrases: [],
86
- suspectPhrasesZh: [],
87
- suspectPhrasesEn: [],
89
+ templatePlaceholdersFound: [],
88
90
  };
89
91
  }
90
92
  const lines = finalText.split(/\r?\n/).map((l) => l.trim());
@@ -95,45 +97,12 @@ export function auditSpoke(finalText, allowedRelativePaths) {
95
97
  const observationCount = observationsSection !== undefined ? countObservations(observationsSection) : null;
96
98
  const cannotVerifySectionPresent = CANNOT_VERIFY_SECTIONS.some((n) => sections.has(n));
97
99
  const citedPaths = [...new Set([...finalText.matchAll(PATH_REGEX)].map((m) => m[1]))];
98
- const allowedSet = new Set(allowedRelativePaths);
99
- // 熱修補(issue_log_v1.1.md):「無法驗證」章節內的路徑必然在允許清單之外——§16 回報
100
- // 模板定義該欄為「需要但讀不到的檔案」,出現清單外路徑是模板要求的正確行為,不是 §15
101
- // 要防的「臆測或引用工單原文」。故清單外判定排除只出現在該章節的路徑;citedPaths 本身
102
- // 維持完整記錄不變,記錄(§12)與判定(§15)是不同職責。
103
- //
104
- // 「只出現在」是關鍵字——同一路徑若跨「觀察」與「無法驗證」兩節出現,觀察節那筆仍須被
105
- // 抓到(那正是 §15 要防的訊號:先在觀察節臆測引用,再於無法驗證節「自首」寫不在清單內,
106
- // 藉此讓臆測那筆連帶被放行)。故須先算出「無法驗證節以外」引用了哪些路徑,只有兩者皆
107
- // 不成立(在無法驗證節出現、且未在別處出現)才排除。
108
- //
109
- // 取捨:spoke 若在「無法驗證」欄裡編造一個不存在、且未在別處引用的路徑,此處抓不到。
110
- // 可接受——該欄本來就是列「讀不到的檔案」,單獨出現在那裡不構成 §15 要防的訊號。
111
- const cannotVerifySectionText = getSection(sections, CANNOT_VERIFY_SECTIONS) ?? "";
112
- const pathsCitedInCannotVerifySection = new Set([...cannotVerifySectionText.matchAll(PATH_REGEX)].map((m) => m[1]));
113
- const elsewhereText = [...sections.entries()]
114
- .filter(([heading]) => !CANNOT_VERIFY_SECTIONS.includes(heading))
115
- .map(([, body]) => body)
116
- .join("\n");
117
- const pathsCitedElsewhere = new Set([...elsewhereText.matchAll(PATH_REGEX)].map((m) => m[1]));
118
- const pathsOnlyInCannotVerify = new Set([...pathsCitedInCannotVerifySection].filter((p) => !pathsCitedElsewhere.has(p)));
119
- const citedPathsOutsideAllowlist = citedPaths.filter((p) => !allowedSet.has(p) && !pathsOnlyInCannotVerify.has(p));
120
- const suspectPhrasesZh = SUSPECT_PHRASES.filter((phrase) => finalText.includes(phrase));
121
- const suspectPhrasesEn = SUSPECT_PHRASES_EN.filter((phrase) => finalText.includes(phrase));
122
- const suspectPhrases = [...suspectPhrasesZh, ...suspectPhrasesEn];
123
- const citedPathsOutsideAllowlistDetail = citedPathsOutsideAllowlist.map((p) => ({
124
- path: p,
125
- section: findSection(sections, p),
126
- suffixOf: findSuffixSource(p, allowedRelativePaths),
127
- }));
100
+ const templatePlaceholdersFound = countTemplatePlaceholders(finalText);
128
101
  return {
129
102
  finalLinePass,
130
103
  observationCount,
131
104
  citedPaths,
132
- citedPathsOutsideAllowlist,
133
- citedPathsOutsideAllowlistDetail,
134
105
  cannotVerifySectionPresent,
135
- suspectPhrases,
136
- suspectPhrasesZh,
137
- suspectPhrasesEn,
106
+ templatePlaceholdersFound,
138
107
  };
139
108
  }
package/dist/cli.js CHANGED
@@ -178,7 +178,13 @@ async function main() {
178
178
  exitCode,
179
179
  });
180
180
  // §10 步驟 7/§11:派工報表
181
- const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, { repoRoot, providersSource, gitignoreStatus }, lang);
181
+ const report = buildReport(ticketId, spokes, estimates, allowlistEstimates, options, outDir, {
182
+ repoRoot,
183
+ providersSource,
184
+ gitignoreStatus,
185
+ reviewTextChars: ticket.shared.reviewText.length,
186
+ strayHeadings: ticket.shared.strayHeadings,
187
+ }, lang);
182
188
  log.info(report);
183
189
  // 護欄的預告:乾跑不受影響(它不寫任何東西),但先講,免得實跑才發現被擋。
184
190
  const outDirDirty = await outDirHasArtifacts(outDir);
@@ -290,10 +296,7 @@ async function main() {
290
296
  };
291
297
  });
292
298
  // §10 步驟 9:確定性稽核
293
- const audits = new Map(results.map((r) => {
294
- const spoke = spokes.find((sp) => sp.agent === r.agent);
295
- return [r.agent, auditSpoke(r.finalText, spoke.allowedReadsRelative)];
296
- }));
299
+ const audits = new Map(results.map((r) => [r.agent, auditSpoke(r.finalText)]));
297
300
  // plan_dispatch_v2.0.md §15(一):tool 呼叫是執行資料不是文字,獨立於 auditSpoke 之外判定。
298
301
  const toolCallAudits = new Map(results.map((r) => {
299
302
  const spoke = spokes.find((sp) => sp.agent === r.agent);
package/dist/doctor.js CHANGED
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { resolveDispatchHome } from "./dispatch-home.js";
14
14
  import { SECRET_ENV_VARS } from "./secret-env.js";
15
15
  import { PROVIDERS_FORMAT_VERSION } from "./providers.js";
16
+ import { lensClosingLineStatus } from "./report.js";
16
17
  import { m } from "./messages.js";
17
18
  const DEFAULT_PROBE = {
18
19
  fileExists: (p) => {
@@ -31,13 +32,15 @@ const DEFAULT_PROBE = {
31
32
  return null;
32
33
  }
33
34
  },
35
+ readFile: (p) => {
36
+ try {
37
+ return fs.readFileSync(p, "utf8");
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ },
34
43
  };
35
- // 項四〈lens 定義〉:`.claude/agents/` 底下所有 hole-finder 開頭的檔,**含無後綴的
36
- // `hole-finder.md`**——語言包確實出貨那一支,而 CLI 不限定 agent 名(validate.ts 只看
37
- // `.claude/agents/<agent>.md` 在不在),所以它是可派的。漏算它會讓 doctor 報的支數
38
- // 與使用者 `ls` 看到的對不上,而這支指令的用途正是「回報你手上實際有什麼」。
39
- // `explore-haiku.md` 不算:它不是 hole-finder lens,沒有固定收尾句。
40
- const HOLE_FINDER_LENS_PATTERN = /^hole-finder(-.+)?\.md$/;
41
44
  function buildConfigDirValue(lang, env, dispatchHome) {
42
45
  if (dispatchHome === null)
43
46
  return m(lang, "doctorConfigDirUnresolved");
@@ -62,17 +65,37 @@ function buildModelListValue(lang, providers) {
62
65
  .join(joiner);
63
66
  return m(lang, "doctorModelListValue", PROVIDERS_FORMAT_VERSION, items);
64
67
  }
68
+ // 工單 X1 v1.1 §三:doctor 不再只認 `hole-finder` 這個名字——只裝 translation-* 一類 lens
69
+ // 的專案先前會被回報 0 個,使用者會誤以為裝錯了。改列出 `.claude/agents/` 底下所有 `.md`,
70
+ // 並依「檔案內文最後一句是不是固定收尾句」分成兩組(判定沿用 report.ts 的
71
+ // lensClosingLineStatus,不在此重寫)。沒有收尾句不是錯誤——`explore-haiku.md` 就是這種。
65
72
  function buildLensValue(lang, probe, cwd) {
66
73
  const lensDir = path.join(cwd, ".claude", "agents");
67
74
  const entries = probe.readDir(lensDir);
68
75
  if (entries === null)
69
76
  return m(lang, "doctorLensDirMissingValue", lensDir);
70
77
  const joiner = lang === "zh" ? "、" : ", ";
71
- const lenses = entries
72
- .filter((f) => HOLE_FINDER_LENS_PATTERN.test(f))
73
- .map((f) => f.slice(0, -".md".length))
74
- .sort();
75
- return m(lang, "doctorLensFoundValue", lensDir, lenses.length, lenses.join(joiner));
78
+ const mdFiles = entries.filter((f) => f.endsWith(".md"));
79
+ const withClosing = [];
80
+ const withoutClosing = [];
81
+ for (const file of mdFiles) {
82
+ const name = file.slice(0, -".md".length);
83
+ const content = probe.readFile(path.join(lensDir, file));
84
+ // 讀不到內容(不存在於這一刻、權限不足…)不得讓整支 doctor 失敗——歸入無收尾句一組。
85
+ // 取捨:輸出上這與「確實沒有收尾句」分不出來,讀者看到的都是「無收尾句」;工單允許
86
+ // 「無法判定」或「無收尾句」二選一,這裡選後者,不是宣稱「照實說」出兩者的差異。
87
+ const status = content !== null ? lensClosingLineStatus(content) : "none";
88
+ (status === "none" ? withoutClosing : withClosing).push(name);
89
+ }
90
+ // 熱修補(2026-08-15):對「去掉 .md 的名字」排序,不是對檔名排序——'-'(45) < '.'(46),
91
+ // 對檔名排序會讓無後綴的 hole-finder.md 排到 hole-finder-cost.md 之後,看起來像被降級。
92
+ withClosing.sort();
93
+ withoutClosing.sort();
94
+ const noClosingSuffix = withoutClosing.length > 0
95
+ ? m(lang, "doctorLensNoClosingSuffix", withoutClosing.length, withoutClosing.join(joiner))
96
+ : "";
97
+ const closingNames = withClosing.length > 0 ? withClosing.join(joiner) : m(lang, "noneLabel");
98
+ return m(lang, "doctorLensFoundValue", lensDir, mdFiles.length, withClosing.length, closingNames, noClosingSuffix);
76
99
  }
77
100
  export function buildDoctorReport(lang, cmd, providers, env = process.env, homedir = os.homedir, probe = DEFAULT_PROBE, cwd = process.cwd()) {
78
101
  const dispatchHome = resolveDispatchHome(env, homedir);
@@ -50,17 +50,15 @@ export function buildJsonSpoke(result, audit, toolCallAudit) {
50
50
  ? {
51
51
  closingLine: audit.finalLinePass,
52
52
  observationCount: audit.observationCount, // number | null 原樣保留,不得降級為 0
53
- pathsOutsideAllowlist: audit.citedPathsOutsideAllowlist,
54
53
  hasUnverifiableSection: audit.cannotVerifySectionPresent,
55
- suspectMatches: audit.suspectPhrases,
54
+ templatePlaceholdersFound: audit.templatePlaceholdersFound,
56
55
  zeroSourceRead: toolCallAudit?.zeroSourceRead ?? false,
57
56
  }
58
57
  : {
59
58
  closingLine: false,
60
59
  observationCount: null,
61
- pathsOutsideAllowlist: [],
62
60
  hasUnverifiableSection: false,
63
- suspectMatches: [],
61
+ templatePlaceholdersFound: [],
64
62
  zeroSourceRead: toolCallAudit?.zeroSourceRead ?? false,
65
63
  },
66
64
  };
package/dist/messages.js CHANGED
@@ -132,19 +132,15 @@ const MESSAGES = {
132
132
  budgetTriggerLabel: (trigger) => ({ total: "總量", reasoning: "推理累積", reasoning_round: "推理單輪尖峰" })[trigger],
133
133
  anomalySpikeFlag: () => "⚠ 異常尖峰・",
134
134
  noneLabel: () => "無",
135
- outsideAllowlistSection: (section) => `「${section}」節`,
136
- outsideAllowlistNoSection: () => "章節外",
137
- outsideAllowlistSuffixNote: (suffixOf) => `;疑似 ${suffixOf} 的縮寫`,
138
- outsideAllowlistEntry: (path, detail) => `${path}(${detail})`,
139
135
  unknownUsageKeysWarning: (provider, keys) => `⚠ 未知 usage 欄位:${provider} ${keys}`,
140
136
  zeroSourceReadWarning: (n) => `⚠ 零原始碼讀取(允許 ${n} 檔)`,
141
137
  toolCallStats: (total, allowed, rejected) => `工具呼叫:${total}(允許 ${allowed}/拒絕 ${rejected})`,
142
138
  closingLineCell: (passFail) => `收尾句:${passFail}`,
143
139
  observationCountCell: (display) => `觀察:${display}`,
144
140
  cannotCountObservations: () => "無法計數",
145
- outsideAllowlistCell: (detail) => `清單外引用:${detail}`,
146
141
  cannotVerifySectionCell: (passFail) => `無法驗證欄:${passFail}`,
147
- suspectPhrasesCell: (detail) => `疑似禁止內容:${detail}`,
142
+ templatePlaceholderEntry: (placeholder, count) => `${placeholder}×${count}`,
143
+ templatePlaceholdersCell: (detail) => `佔位符:${detail}`,
148
144
  auditUnavailable: () => "(無法稽核)",
149
145
  summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
150
146
 
@@ -152,6 +148,10 @@ const MESSAGES = {
152
148
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`,
153
149
  providersBundled: (formatVersion) => `出貨(formatVersion ${formatVersion})`,
154
150
  providersExplicit: (path, formatVersion) => `外部檔 ${path}(formatVersion ${formatVersion})`,
151
+ strayHeadingsWarning: (reviewChars, strayNames) => ` ⚠ _shared.md 的「# 待審段落」只有 ${reviewChars} 字,另有 ${strayNames.length} 個頂層章節:` +
152
+ `${strayNames.map((s) => `「# ${s}」`).join("、")}` +
153
+ `——待審段落可能被它們切斷了。工單以 \`#\` 切分區塊,內嵌文件若自帶 \`#\` 標題請降成 \`##\`,` +
154
+ `或用 \`\`\` 圍籬包起來。確認過是刻意的就忽略本行`,
155
155
  gitignoreNotIgnored: (outDir) => ` ⚠ 輸出目錄 ${outDir} 未被輸出目錄所在的 git repo 忽略`,
156
156
  gitignoreUnknown: (outDir) => ` ℹ 無法判定輸出目錄 ${outDir} 是否被 .gitignore 涵蓋(非 git repo 或 git 不可用)`,
157
157
  aboutToDispatch: (ticketId) => `即將派工 ${ticketId}:`,
@@ -199,7 +199,8 @@ const MESSAGES = {
199
199
  doctorModelListValue: (formatVersion, items) => `providers.json formatVersion ${formatVersion}:${items}`,
200
200
  doctorModelListLoadFailedValue: (reason) => `無法載入:${reason}`,
201
201
  doctorLensLine: (value) => ` lens 定義 ${value}`,
202
- doctorLensFoundValue: (dirPath, count, names) => `${dirPath} 找到 ${count} 支:${names}`,
202
+ doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} 找到 ${total} 個定義檔,其中 ${closingCount} 個具備固定收尾句:\n${" ".repeat(14)}${closingNames}${noClosingSuffix}`,
203
+ doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(14)}(另 ${count} 個無收尾句:${names})`,
203
204
  doctorLensDirMissingValue: (dirPath) => `目錄不存在:${dirPath}`,
204
205
  doctorFooter: () => "本指令不呼叫任何 API,不會花錢。缺的項目怎麼補,見 README 的〈API keys〉一節。",
205
206
  },
@@ -319,19 +320,15 @@ const MESSAGES = {
319
320
  budgetTriggerLabel: (trigger) => ({ total: "total", reasoning: "cumulative reasoning", reasoning_round: "single-round reasoning spike" })[trigger],
320
321
  anomalySpikeFlag: () => "⚠ anomalous spike - ",
321
322
  noneLabel: () => "none",
322
- outsideAllowlistSection: (section) => `the "${section}" section`,
323
- outsideAllowlistNoSection: () => "outside any section",
324
- outsideAllowlistSuffixNote: (suffixOf) => `; possibly an abbreviation of ${suffixOf}`,
325
- outsideAllowlistEntry: (path, detail) => `${path} (${detail})`,
326
323
  unknownUsageKeysWarning: (provider, keys) => `⚠ Unknown usage field(s): ${provider} ${keys}`,
327
324
  zeroSourceReadWarning: (n) => `⚠ Zero source reads (allowed ${n} file(s))`,
328
325
  toolCallStats: (total, allowed, rejected) => `Tool calls:${total} (allowed ${allowed} / rejected ${rejected})`,
329
326
  closingLineCell: (passFail) => `Closing line:${passFail}`,
330
327
  observationCountCell: (display) => `Observations:${display}`,
331
328
  cannotCountObservations: () => "uncountable",
332
- outsideAllowlistCell: (detail) => `Citations outside allowlist:${detail}`,
333
329
  cannotVerifySectionCell: (passFail) => `Cannot-verify section:${passFail}`,
334
- suspectPhrasesCell: (detail) => `Suspect phrases:${detail}`,
330
+ templatePlaceholderEntry: (placeholder, count) => `${placeholder}×${count}`,
331
+ templatePlaceholdersCell: (detail) => `Template placeholders:${detail}`,
335
332
  auditUnavailable: () => "(audit unavailable)",
336
333
  summaryHeader: (ticketId) => `# dispatch summary — ${ticketId}
337
334
 
@@ -339,6 +336,10 @@ const MESSAGES = {
339
336
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |`,
340
337
  providersBundled: (formatVersion) => `bundled (formatVersion ${formatVersion})`,
341
338
  providersExplicit: (path, formatVersion) => `external file ${path} (formatVersion ${formatVersion})`,
339
+ strayHeadingsWarning: (reviewChars, strayNames) => ` ⚠ _shared.md's "# Under review" holds only ${reviewChars} characters, and ${strayNames.length} other ` +
340
+ `top-level heading(s) follow: ${strayNames.map((s) => `"# ${s}"`).join(", ")} — the section under review ` +
341
+ `may have been cut off by them. The ticket splits into sections by \`#\`; if an embedded document has its ` +
342
+ `own \`#\` headings, demote them to \`##\` or wrap the block in \`\`\` fences. Ignore this line if it is intended`,
342
343
  gitignoreNotIgnored: (outDir) => ` ⚠ Output directory ${outDir} is not ignored by the git repo it lives in`,
343
344
  gitignoreUnknown: (outDir) => ` ℹ Cannot determine whether output directory ${outDir} is covered by .gitignore (not a git repo, or git unavailable)`,
344
345
  aboutToDispatch: (ticketId) => `About to dispatch ${ticketId}:`,
@@ -386,7 +387,8 @@ const MESSAGES = {
386
387
  doctorModelListValue: (formatVersion, items) => `providers.json formatVersion ${formatVersion}: ${items}`,
387
388
  doctorModelListLoadFailedValue: (reason) => `failed to load: ${reason}`,
388
389
  doctorLensLine: (value) => ` Lens defs ${value}`,
389
- doctorLensFoundValue: (dirPath, count, names) => `${dirPath} holds ${count}: ${names}`,
390
+ doctorLensFoundValue: (dirPath, total, closingCount, closingNames, noClosingSuffix) => `${dirPath} holds ${total} definition file(s), ${closingCount} with a fixed closing line:\n${" ".repeat(15)}${closingNames}${noClosingSuffix}`,
391
+ doctorLensNoClosingSuffix: (count, names) => `\n${" ".repeat(15)}(${count} more without a closing line: ${names})`,
390
392
  doctorLensDirMissingValue: (dirPath) => `directory not found: ${dirPath}`,
391
393
  doctorFooter: () => 'This command calls no API and costs nothing. To fill in what is missing, see "API keys" in the README.',
392
394
  },
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
@@ -63,18 +63,42 @@ export function stripFrontmatter(markdown) {
63
63
  const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
64
64
  return (match ? match[1] : markdown).trim();
65
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
+ }
66
74
  // 匯出供 audit.ts 重用(回報模板同樣是 `# 標題` 結構)。
67
75
  export function splitTopLevelSections(markdown) {
68
76
  const lines = markdown.split(/\r?\n/);
69
77
  const sections = new Map();
70
78
  let currentHeading = null;
71
79
  let buffer = [];
80
+ let openFence = null;
72
81
  const flush = () => {
73
82
  if (currentHeading !== null) {
74
83
  sections.set(currentHeading, buffer.join("\n").trim());
75
84
  }
76
85
  };
77
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
+ }
78
102
  const match = line.match(/^#\s+(.+?)\s*$/);
79
103
  if (match) {
80
104
  flush();
@@ -94,6 +118,12 @@ function parseBulletList(body) {
94
118
  .map((l) => l.match(/^-\s+(.+)$/)?.[1]?.trim())
95
119
  .filter((v) => Boolean(v && v.length > 0));
96
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
+ }
97
127
  // §4:`_shared.md`。「待審段落」缺失或空即中止;「前提」缺失為警告(空前提合法)。
98
128
  export function parseSharedDoc(markdown, lang) {
99
129
  const sections = splitTopLevelSections(markdown);
@@ -104,7 +134,7 @@ export function parseSharedDoc(markdown, lang) {
104
134
  // 原訊息「缺或內容為空」會讓人先去查自己有沒有寫,方向就錯了。標題存在卻空白時,
105
135
  // 直接指名是誰切斷了它。
106
136
  if (sections.has("待審段落") || sections.has("Under review")) {
107
- const stray = [...sections.keys()].filter((k) => k !== "待審段落" && k !== "Under review" && !k.startsWith("前提") && k !== "Premises");
137
+ const stray = findStrayHeadings(sections);
108
138
  if (stray.length > 0) {
109
139
  throw new DispatchError(m(lang, "strayHeadingsCutReviewSection", stray), 2);
110
140
  }
@@ -113,7 +143,13 @@ export function parseSharedDoc(markdown, lang) {
113
143
  }
114
144
  const premisesBody = sections.get("前提(不受審)") ?? sections.get("前提") ?? sections.get("Premises");
115
145
  const premises = premisesBody ? parseBulletList(premisesBody) : [];
116
- 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) };
117
153
  }
118
154
  // §4:`<agent>.md`。「具體問題」缺失或空即中止;「允許讀取」缺失為警告(空清單合法)。
119
155
  export function parseAgentTicket(markdown, lang) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowafu",
3
- "version": "0.3.1",
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",