dowafu 0.1.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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/adapters/anthropic-messages.js +145 -0
- package/dist/adapters/gemini-native.js +114 -0
- package/dist/adapters/responses.js +112 -0
- package/dist/audit.js +123 -0
- package/dist/cli-args.js +138 -0
- package/dist/cli.js +304 -0
- package/dist/cost.js +54 -0
- package/dist/dispatch-home.js +23 -0
- package/dist/dotenv-invariant.js +66 -0
- package/dist/error-classify.js +13 -0
- package/dist/gate.js +37 -0
- package/dist/gitignore-check.js +24 -0
- package/dist/json-output.js +80 -0
- package/dist/mask.js +83 -0
- package/dist/output.js +152 -0
- package/dist/pkg-info.js +35 -0
- package/dist/prompt.js +105 -0
- package/dist/providers.js +151 -0
- package/dist/rate-limit.js +27 -0
- package/dist/raw-integrity.js +49 -0
- package/dist/report.js +101 -0
- package/dist/runner.js +328 -0
- package/dist/secret-env.js +6 -0
- package/dist/semaphore.js +25 -0
- package/dist/ticket.js +156 -0
- package/dist/tool-call-audit.js +19 -0
- package/dist/types.js +11 -0
- package/dist/usage.js +224 -0
- package/dist/validate.js +100 -0
- package/dist/whitelist.js +38 -0
- package/package.json +60 -0
- package/providers.json +84 -0
- package/publish/.agents/skills/find-holes-external/SKILL.md +418 -0
- package/publish/.agents/skills/preflight/SKILL.md +126 -0
- package/publish/.agents/skills/wrap/SKILL.md +65 -0
- package/publish/.claude/agents/explore-haiku.md +8 -0
- package/publish/.claude/agents/hole-finder-cost.md +15 -0
- package/publish/.claude/agents/hole-finder-feasibility.md +15 -0
- package/publish/.claude/agents/hole-finder-safety.md +15 -0
- package/publish/.claude/agents/hole-finder.md +14 -0
- package/publish/.claude/skills/find-holes/SKILL.md +112 -0
- package/publish/.claude/skills/find-holes-external/SKILL.md +434 -0
- package/publish/.claude/skills/preflight/SKILL.md +196 -0
- package/publish/.claude/skills/wrap/SKILL.md +62 -0
- package/publish/README.md +75 -0
- package/publish/workflow_spec.md +65 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §9/§13:--concurrency 用信號量控制發起;429 等待期間 release(),
|
|
2
|
+
// 結束後重新 acquire()。release() 優先直接把名額交給排隊者,不經過 available 計數,
|
|
3
|
+
// 避免「release 後又被別人搶先 acquire」的競態窗口。
|
|
4
|
+
export class Semaphore {
|
|
5
|
+
available;
|
|
6
|
+
queue = [];
|
|
7
|
+
constructor(max) {
|
|
8
|
+
this.available = max;
|
|
9
|
+
}
|
|
10
|
+
acquire() {
|
|
11
|
+
if (this.available > 0) {
|
|
12
|
+
this.available--;
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
}
|
|
15
|
+
return new Promise((resolve) => this.queue.push(resolve));
|
|
16
|
+
}
|
|
17
|
+
release() {
|
|
18
|
+
const next = this.queue.shift();
|
|
19
|
+
if (next) {
|
|
20
|
+
next();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.available++;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/ticket.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §4:工單目錄解析。純函式(吃 markdown 字串、吐結構化資料),
|
|
2
|
+
// 檔案系統存取另外在 loadTicket() 做,方便單元測試不必 mock 檔案系統。
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DispatchError } from "./types.js";
|
|
6
|
+
const FORMAT_MARKER = "<!-- format: v1 -->";
|
|
7
|
+
function splitTableRow(line) {
|
|
8
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
9
|
+
return trimmed.split("|").map((cell) => cell.trim());
|
|
10
|
+
}
|
|
11
|
+
function isSeparatorRow(cells) {
|
|
12
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c));
|
|
13
|
+
}
|
|
14
|
+
// §4:`_dispatch.md`。model/provider/agent 必填,無預設值;留白或寫 "default" 視為缺失。
|
|
15
|
+
export function parseDispatchTable(markdown) {
|
|
16
|
+
const lines = markdown.split(/\r?\n/);
|
|
17
|
+
const firstNonBlank = lines.find((l) => l.trim().length > 0);
|
|
18
|
+
if (firstNonBlank?.trim() !== FORMAT_MARKER) {
|
|
19
|
+
throw new DispatchError(`_dispatch.md 首行須為 ${FORMAT_MARKER},實際為:${firstNonBlank?.trim() ?? "(空白)"}`, 2);
|
|
20
|
+
}
|
|
21
|
+
const headerIndex = lines.findIndex((l) => /^\s*\|\s*agent\s*\|/i.test(l));
|
|
22
|
+
if (headerIndex === -1 || !isSeparatorRow(splitTableRow(lines[headerIndex + 1] ?? ""))) {
|
|
23
|
+
throw new DispatchError("_dispatch.md 找不到派工表(缺 | agent | ... | 表頭或分隔列)", 2);
|
|
24
|
+
}
|
|
25
|
+
const headerCells = splitTableRow(lines[headerIndex]).map((c) => c.toLowerCase());
|
|
26
|
+
const rows = [];
|
|
27
|
+
for (let i = headerIndex + 2; i < lines.length; i++) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
if (!line.trim().startsWith("|"))
|
|
30
|
+
break;
|
|
31
|
+
const cells = splitTableRow(line);
|
|
32
|
+
const get = (col) => {
|
|
33
|
+
const idx = headerCells.indexOf(col);
|
|
34
|
+
return idx === -1 ? "" : (cells[idx] ?? "").trim();
|
|
35
|
+
};
|
|
36
|
+
const agent = get("agent");
|
|
37
|
+
const provider = get("provider");
|
|
38
|
+
const model = get("model");
|
|
39
|
+
const effort = get("effort");
|
|
40
|
+
const isMissing = (v) => v.length === 0 || v.toLowerCase() === "default";
|
|
41
|
+
if (isMissing(agent) || isMissing(provider) || isMissing(model)) {
|
|
42
|
+
throw new DispatchError(`_dispatch.md 第 ${i + 1} 行缺 agent/provider/model 必填欄位(留白或寫 "default" 視為缺失):${line}`, 2);
|
|
43
|
+
}
|
|
44
|
+
rows.push({ agent, provider, model, effort: effort.length > 0 ? effort : undefined });
|
|
45
|
+
}
|
|
46
|
+
if (rows.length === 0) {
|
|
47
|
+
throw new DispatchError("_dispatch.md 派工表沒有任何資料列", 2);
|
|
48
|
+
}
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
// 匯出供 audit.ts 重用(回報模板同樣是 `# 標題` 結構)。
|
|
52
|
+
export function splitTopLevelSections(markdown) {
|
|
53
|
+
const lines = markdown.split(/\r?\n/);
|
|
54
|
+
const sections = new Map();
|
|
55
|
+
let currentHeading = null;
|
|
56
|
+
let buffer = [];
|
|
57
|
+
const flush = () => {
|
|
58
|
+
if (currentHeading !== null) {
|
|
59
|
+
sections.set(currentHeading, buffer.join("\n").trim());
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const match = line.match(/^#\s+(.+?)\s*$/);
|
|
64
|
+
if (match) {
|
|
65
|
+
flush();
|
|
66
|
+
currentHeading = match[1];
|
|
67
|
+
buffer = [];
|
|
68
|
+
}
|
|
69
|
+
else if (currentHeading !== null) {
|
|
70
|
+
buffer.push(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return sections;
|
|
75
|
+
}
|
|
76
|
+
function parseBulletList(body) {
|
|
77
|
+
return body
|
|
78
|
+
.split(/\r?\n/)
|
|
79
|
+
.map((l) => l.match(/^-\s+(.+)$/)?.[1]?.trim())
|
|
80
|
+
.filter((v) => Boolean(v && v.length > 0));
|
|
81
|
+
}
|
|
82
|
+
// §4:`_shared.md`。「待審段落」缺失或空即中止;「前提」缺失為警告(空前提合法)。
|
|
83
|
+
export function parseSharedDoc(markdown) {
|
|
84
|
+
const sections = splitTopLevelSections(markdown);
|
|
85
|
+
const reviewText = sections.get("待審段落") ?? sections.get("Under review");
|
|
86
|
+
if (!reviewText || reviewText.length === 0) {
|
|
87
|
+
// issue_log_v2.1.md:這個錯誤實測撞過兩次,而兩次的成因都不是「忘了寫」——是內嵌的
|
|
88
|
+
// 規劃書自帶 `#` 標題,被 splitTopLevelSections 當成新區塊,把待審段落切斷了。
|
|
89
|
+
// 原訊息「缺或內容為空」會讓人先去查自己有沒有寫,方向就錯了。標題存在卻空白時,
|
|
90
|
+
// 直接指名是誰切斷了它。
|
|
91
|
+
if (sections.has("待審段落") || sections.has("Under review")) {
|
|
92
|
+
const stray = [...sections.keys()].filter((k) => k !== "待審段落" && k !== "Under review" && !k.startsWith("前提") && k !== "Premises");
|
|
93
|
+
if (stray.length > 0) {
|
|
94
|
+
throw new DispatchError(`_shared.md 的「# 待審段落」有標題但內容為空——被後面這些 \`#\` 標題切斷了:` +
|
|
95
|
+
`${stray.map((s) => `「# ${s}」`).join("、")}。` +
|
|
96
|
+
`工單以 \`#\` 切分區塊,內嵌的規劃書若自帶 \`#\` 標題請降成 \`##\`。` +
|
|
97
|
+
`(注意:在「# 待審段落」下面補一行文字雖然能通過檢查,但規劃書本體仍會留在` +
|
|
98
|
+
`後面那個區塊裡,spoke 收到的待審段落等於是空的。)`, 2);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
throw new DispatchError('_shared.md 缺「# 待審段落」(或英文工單的「# Under review」)或內容為空', 2);
|
|
102
|
+
}
|
|
103
|
+
const premisesBody = sections.get("前提(不受審)") ?? sections.get("前提") ?? sections.get("Premises");
|
|
104
|
+
const premises = premisesBody ? parseBulletList(premisesBody) : [];
|
|
105
|
+
return { premises, reviewText };
|
|
106
|
+
}
|
|
107
|
+
// §4:`<agent>.md`。「具體問題」缺失或空即中止;「允許讀取」缺失為警告(空清單合法)。
|
|
108
|
+
export function parseAgentTicket(markdown) {
|
|
109
|
+
const sections = splitTopLevelSections(markdown);
|
|
110
|
+
// 先中文後英文。命中哪一套就是這份工單的語言——不看內文,只看標記,因為內文可能
|
|
111
|
+
// 中英混雜(英文規劃書配中文問題是常見寫法),標記則是作者明確選的。
|
|
112
|
+
const zhQuestions = sections.get("具體問題");
|
|
113
|
+
const enQuestions = sections.get("Questions");
|
|
114
|
+
const lang = zhQuestions !== undefined ? "zh" : enQuestions !== undefined ? "en" : "zh";
|
|
115
|
+
const questions = zhQuestions ?? enQuestions;
|
|
116
|
+
if (!questions || questions.length === 0) {
|
|
117
|
+
throw new DispatchError('<agent>.md 缺「# 具體問題」(或英文工單的「# Questions」)或內容為空', 2);
|
|
118
|
+
}
|
|
119
|
+
const allowedBody = sections.get("允許讀取") ?? sections.get("Allowed reads");
|
|
120
|
+
const allowedReads = allowedBody ? parseBulletList(allowedBody) : [];
|
|
121
|
+
return { questions, allowedReads, lang };
|
|
122
|
+
}
|
|
123
|
+
// 檔案系統存取層:讀工單目錄、組出完整 Ticket。
|
|
124
|
+
export async function loadTicket(ticketDir) {
|
|
125
|
+
const dispatchPath = path.join(ticketDir, "_dispatch.md");
|
|
126
|
+
const sharedPath = path.join(ticketDir, "_shared.md");
|
|
127
|
+
let dispatchText;
|
|
128
|
+
try {
|
|
129
|
+
dispatchText = await readFile(dispatchPath, "utf8");
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new DispatchError(`找不到 ${dispatchPath}`, 2);
|
|
133
|
+
}
|
|
134
|
+
let sharedText;
|
|
135
|
+
try {
|
|
136
|
+
sharedText = await readFile(sharedPath, "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new DispatchError(`找不到 ${sharedPath}`, 2);
|
|
140
|
+
}
|
|
141
|
+
const rows = parseDispatchTable(dispatchText);
|
|
142
|
+
const shared = parseSharedDoc(sharedText);
|
|
143
|
+
const perAgent = new Map();
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
const agentPath = path.join(ticketDir, `${row.agent}.md`);
|
|
146
|
+
let agentText;
|
|
147
|
+
try {
|
|
148
|
+
agentText = await readFile(agentPath, "utf8");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new DispatchError(`找不到 ${agentPath}(_dispatch.md 列了 agent "${row.agent}")`, 2);
|
|
152
|
+
}
|
|
153
|
+
perAgent.set(row.agent, parseAgentTicket(agentText));
|
|
154
|
+
}
|
|
155
|
+
return { ticketDir, rows, shared, perAgent };
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// plan_dispatch_v2.0.md §15(一):稽核表補「tool 呼叫」欄。這是對執行資料(run.jsonl 的
|
|
2
|
+
// toolCalls[])的判定,刻意獨立於 audit.ts 之外——audit.ts 的 auditSpoke 是對 spoke 產出
|
|
3
|
+
// 文字的確定性判定,純函式吃 finalText,不吃執行資料,這個純度是它現有測試的基礎。
|
|
4
|
+
// tool 呼叫記錄不是文字,不塞進 auditSpoke。
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
function isTicketFile(requestedPath, agent) {
|
|
7
|
+
const base = path.basename(requestedPath);
|
|
8
|
+
return base === "_shared.md" || base === `${agent}.md`;
|
|
9
|
+
}
|
|
10
|
+
export function auditToolCalls(agent, toolCalls, allowedReadsCount) {
|
|
11
|
+
const total = toolCalls.length;
|
|
12
|
+
const allowed = toolCalls.filter((t) => t.allowed).length;
|
|
13
|
+
const rejected = total - allowed;
|
|
14
|
+
// total > 0 護欄:空陣列的 .every() 恆真,避免「一次 tool 都沒呼叫」被誤判為「只讀了
|
|
15
|
+
// 工單檔」——兩者是不同訊號,前者連工單都沒讀,非本項要抓的「讀了工單卻不讀程式碼」。
|
|
16
|
+
const allTicketFiles = total > 0 && toolCalls.every((t) => isTicketFile(t.path, agent));
|
|
17
|
+
const zeroSourceRead = allTicketFiles && allowedReadsCount > 0;
|
|
18
|
+
return { total, allowed, rejected, allowedReadsCount, zeroSourceRead };
|
|
19
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §8:中性表示。raw 原樣保留,不解構重組;toolCalls 是唯讀投影,
|
|
2
|
+
// 核心不得用它重建請求(adapter 續接一律用 raw)。
|
|
3
|
+
// fail-closed 錯誤:帶 exit code,CLI 層 catch 後對應 process.exit(§10)。
|
|
4
|
+
export class DispatchError extends Error {
|
|
5
|
+
exitCode;
|
|
6
|
+
constructor(message, exitCode) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.name = "DispatchError";
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §12:usage 正規化層。key 是 provider+端點,不是只有 provider——
|
|
2
|
+
// 實測四套命名互不相同(facts_dispatch.md)。純函式,供單元測試。
|
|
3
|
+
function isNumber(v) {
|
|
4
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
5
|
+
}
|
|
6
|
+
// openai/deepseek 的 /v1/responses:input_tokens / output_tokens / total_tokens
|
|
7
|
+
export function normalizeResponsesUsage(raw) {
|
|
8
|
+
const r = (raw ?? {});
|
|
9
|
+
const inputTokens = isNumber(r.input_tokens) ? r.input_tokens : undefined;
|
|
10
|
+
const outputTokens = isNumber(r.output_tokens) ? r.output_tokens : undefined;
|
|
11
|
+
let totalTokens = isNumber(r.total_tokens) ? r.total_tokens : undefined;
|
|
12
|
+
// 缺 totalTokens 一律以 input + output 補(§12)
|
|
13
|
+
if (totalTokens === undefined && inputTokens !== undefined && outputTokens !== undefined) {
|
|
14
|
+
totalTokens = inputTokens + outputTokens;
|
|
15
|
+
}
|
|
16
|
+
const inputDetails = r.input_tokens_details;
|
|
17
|
+
const outputDetails = r.output_tokens_details;
|
|
18
|
+
const cachedTokens = isNumber(inputDetails?.cached_tokens) ? inputDetails.cached_tokens : undefined;
|
|
19
|
+
const cacheWriteTokens = isNumber(inputDetails?.cache_write_tokens)
|
|
20
|
+
? inputDetails.cache_write_tokens
|
|
21
|
+
: undefined;
|
|
22
|
+
const reasoningTokens = isNumber(outputDetails?.reasoning_tokens)
|
|
23
|
+
? outputDetails.reasoning_tokens
|
|
24
|
+
: undefined;
|
|
25
|
+
return {
|
|
26
|
+
inputTokens: inputTokens ?? 0,
|
|
27
|
+
outputTokens: outputTokens ?? 0,
|
|
28
|
+
totalTokens: totalTokens ?? 0,
|
|
29
|
+
cachedTokens,
|
|
30
|
+
cacheWriteTokens,
|
|
31
|
+
reasoningTokens,
|
|
32
|
+
// v2.5 規格二:快取欄位不得影響 available。openai 的 input_tokens_details 有一種形狀
|
|
33
|
+
// (('image_tokens','text_tokens'),實測 79 次)完全沒有 cache_write_tokens,屬正常情形。
|
|
34
|
+
available: inputTokens !== undefined && outputTokens !== undefined && totalTokens !== undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// gemini generateContent:promptTokenCount / candidatesTokenCount / totalTokenCount。
|
|
38
|
+
// thoughtsTokenCount 是 v1.8 才新增的欄位——開啟 thinkingConfig 之前的實測從未觀察到它
|
|
39
|
+
// (facts_dispatch.md 寫的是「無對應欄位」),§20 驗證腳本確認開啟後會出現且隨值縮放
|
|
40
|
+
// (low 133 → high 1,805)。與 openai 的 reasoning_tokens 同樣只記錄不參與 totalTokens
|
|
41
|
+
// 計算——totalTokenCount 由 API 直接給值時一律優先採用,不重算。
|
|
42
|
+
export function normalizeGeminiUsage(raw) {
|
|
43
|
+
const r = (raw ?? {});
|
|
44
|
+
const inputTokens = isNumber(r.promptTokenCount) ? r.promptTokenCount : undefined;
|
|
45
|
+
const outputTokens = isNumber(r.candidatesTokenCount) ? r.candidatesTokenCount : undefined;
|
|
46
|
+
const reasoningTokens = isNumber(r.thoughtsTokenCount) ? r.thoughtsTokenCount : undefined;
|
|
47
|
+
const cachedTokens = isNumber(r.cachedContentTokenCount) ? r.cachedContentTokenCount : undefined;
|
|
48
|
+
let totalTokens = isNumber(r.totalTokenCount) ? r.totalTokenCount : undefined;
|
|
49
|
+
// plan_dispatch_v1.12.md §12/§14:gemini 的帳目關係是 promptTokenCount +
|
|
50
|
+
// candidatesTokenCount + thoughtsTokenCount = totalTokenCount(實測 592+79+321=992)——
|
|
51
|
+
// thoughtsTokenCount 不是 candidatesTokenCount 的子集,與 openai/deepseek 的
|
|
52
|
+
// reasoning_tokens ⊆ output_tokens 相反。缺 totalTokenCount 時的回補公式若沿用
|
|
53
|
+
// input+output(子集假設下才成立),會在 thoughtsTokenCount 存在時漏算,故此處須把
|
|
54
|
+
// reasoningTokens 一併算入。
|
|
55
|
+
if (totalTokens === undefined && inputTokens !== undefined && outputTokens !== undefined) {
|
|
56
|
+
totalTokens = inputTokens + outputTokens + (reasoningTokens ?? 0);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
inputTokens: inputTokens ?? 0,
|
|
60
|
+
outputTokens: outputTokens ?? 0,
|
|
61
|
+
totalTokens: totalTokens ?? 0,
|
|
62
|
+
reasoningTokens,
|
|
63
|
+
cachedTokens,
|
|
64
|
+
// v2.5 規格二:implicit caching 下限 4,096 token,小 prompt 本來就不會有這個欄位,
|
|
65
|
+
// 缺席不得使 available 轉 false(facts_dispatch.md 2026-08-08 更正條目二)
|
|
66
|
+
available: inputTokens !== undefined && outputTokens !== undefined && totalTokens !== undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// plan_dispatch_v2.7.md §29 規格十/十一:anthropic Messages API 的 usage 欄位名,實測
|
|
70
|
+
// 確認(scripts/verify-providers.ts,見 facts_dispatch.md)——input_tokens/
|
|
71
|
+
// cache_creation_input_tokens/cache_read_input_tokens/cache_creation/output_tokens/
|
|
72
|
+
// output_tokens_details(含 thinking_tokens)/service_tier/inference_geo。
|
|
73
|
+
export function normalizeAnthropicUsage(raw) {
|
|
74
|
+
const r = (raw ?? {});
|
|
75
|
+
const inputTokens = isNumber(r.input_tokens) ? r.input_tokens : undefined;
|
|
76
|
+
const outputTokens = isNumber(r.output_tokens) ? r.output_tokens : undefined;
|
|
77
|
+
const cacheReadTokens = isNumber(r.cache_read_input_tokens) ? r.cache_read_input_tokens : undefined;
|
|
78
|
+
const cacheCreationTokens = isNumber(r.cache_creation_input_tokens) ? r.cache_creation_input_tokens : undefined;
|
|
79
|
+
const outputDetails = r.output_tokens_details;
|
|
80
|
+
const reasoningTokens = isNumber(outputDetails?.thinking_tokens) ? outputDetails.thinking_tokens : undefined;
|
|
81
|
+
// 規格十:Anthropic 無 total_tokens 欄位,須自行加總。且 input_tokens 在有快取時不含快取
|
|
82
|
+
// 部分(官方 usage 範例三者並列),故正確總量是 input+output+快取讀取+快取寫入的四者之和,
|
|
83
|
+
// 不是 input+output 兩者(那個公式是 openai/deepseek 的形狀,此處會低估)。
|
|
84
|
+
const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheReadTokens ?? 0) + (cacheCreationTokens ?? 0);
|
|
85
|
+
return {
|
|
86
|
+
inputTokens: inputTokens ?? 0,
|
|
87
|
+
outputTokens: outputTokens ?? 0,
|
|
88
|
+
totalTokens,
|
|
89
|
+
cachedTokens: cacheReadTokens,
|
|
90
|
+
cacheWriteTokens: cacheCreationTokens,
|
|
91
|
+
reasoningTokens,
|
|
92
|
+
available: inputTokens !== undefined && outputTokens !== undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const STOP_VALUES = new Set(["completed", "STOP", "end_turn"]);
|
|
96
|
+
export function normalizeFinishReason(raw) {
|
|
97
|
+
const finishReasonRaw = raw ?? null;
|
|
98
|
+
if (raw && STOP_VALUES.has(raw)) {
|
|
99
|
+
return { finishReason: "stop", finishReasonRaw };
|
|
100
|
+
}
|
|
101
|
+
return { finishReason: raw ?? "unknown", finishReasonRaw };
|
|
102
|
+
}
|
|
103
|
+
// v2.5 規格三:兩邊皆缺席時輸出 undefined,不是 0——「沒有資料」與「量測值為 0」必須
|
|
104
|
+
// 可區分,否則 run.jsonl 裡的 0 會被誤讀為一次量測結果。
|
|
105
|
+
function addOptional(a, b) {
|
|
106
|
+
if (a === undefined && b === undefined)
|
|
107
|
+
return undefined;
|
|
108
|
+
return (a ?? 0) + (b ?? 0);
|
|
109
|
+
}
|
|
110
|
+
export function sumUsage(a, b) {
|
|
111
|
+
return {
|
|
112
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
113
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
114
|
+
totalTokens: a.totalTokens + b.totalTokens,
|
|
115
|
+
cachedTokens: addOptional(a.cachedTokens, b.cachedTokens),
|
|
116
|
+
cacheWriteTokens: addOptional(a.cacheWriteTokens, b.cacheWriteTokens),
|
|
117
|
+
reasoningTokens: addOptional(a.reasoningTokens, b.reasoningTokens),
|
|
118
|
+
available: a.available && b.available,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export const EMPTY_USAGE = {
|
|
122
|
+
inputTokens: 0,
|
|
123
|
+
outputTokens: 0,
|
|
124
|
+
totalTokens: 0,
|
|
125
|
+
available: true,
|
|
126
|
+
};
|
|
127
|
+
// plan_dispatch_v2.6.md §26 規格二:允許清單緊鄰對應的正規化函式,不放 providers.json——
|
|
128
|
+
// 「正規化層認得哪些 key」是程式內部知識,距離讀取邏輯越遠越容易漏更新(這正是本版要防
|
|
129
|
+
// 的事)。清單須含所有已知 key,不只「有讀的 key」;已知但刻意不讀的也要列入並註明理由,
|
|
130
|
+
// 否則每次派工都會誤報,把真正的新欄位訊號淹掉。清單依據:tmp/external-runs/ 十次真實
|
|
131
|
+
// 派工的 raw 全量掃描(§27),逐一開檔核對而非憑欄位名猜測。
|
|
132
|
+
const RESPONSES_USAGE_KEYS = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
|
|
133
|
+
const RESPONSES_INPUT_DETAILS_KEYS = new Set(["cached_tokens", "cache_write_tokens"]);
|
|
134
|
+
// image_tokens/text_tokens:已知,不參與計算。註:本機十次真實派工的 raw 裡,這兩個
|
|
135
|
+
// key 實際只出現在同一份 response 的另一個頂層欄位 tool_usage.image_gen.*_tokens_details
|
|
136
|
+
// (與 usage 完全無關的欄位,不在本偵測器掃描範圍內——見規格一),從未在
|
|
137
|
+
// usage.output_tokens_details 本身出現過。列在此處純屬防禦:即便官方日後把它併入
|
|
138
|
+
// usage.output_tokens_details,也不會被誤報成新欄位。
|
|
139
|
+
const RESPONSES_OUTPUT_DETAILS_KEYS = new Set(["reasoning_tokens", "image_tokens", "text_tokens"]);
|
|
140
|
+
const GEMINI_USAGE_KEYS = new Set([
|
|
141
|
+
"promptTokenCount",
|
|
142
|
+
"candidatesTokenCount",
|
|
143
|
+
"totalTokenCount",
|
|
144
|
+
"thoughtsTokenCount",
|
|
145
|
+
"cachedContentTokenCount",
|
|
146
|
+
"promptTokensDetails",
|
|
147
|
+
"cacheTokensDetails", // 已知,不參與計算:cachedContentTokenCount 的 modality 拆分(v2.5 未讀,14 次)
|
|
148
|
+
"serviceTier", // 已知,不參與計算
|
|
149
|
+
]);
|
|
150
|
+
// 規格一:巢狀節點只走已知的三個,不做無限遞迴——cacheTokensDetails 雖與
|
|
151
|
+
// promptTokensDetails 同形狀,但不在這三個之列,只列為已知頂層 key,不遞迴檢查其內容。
|
|
152
|
+
const GEMINI_PROMPT_DETAILS_ITEM_KEYS = new Set(["modality", "tokenCount"]);
|
|
153
|
+
// plan_dispatch_v2.7.md §29 規格七之二:以 scripts/verify-providers.ts 對真實 API 的回應
|
|
154
|
+
// 為準(見 facts_dispatch.md),不照官方文件猜——文件另提過 server_tool_use 這個欄位,但
|
|
155
|
+
// 本次實測(純 client-side read_file 工具,非 Anthropic 伺服器端工具)從未出現過,故不列入;
|
|
156
|
+
// 依 v2.6 §26 的既有紀律,未出現的欄位交由本偵測器在真正撞到時示警,再核對後補入,不預先
|
|
157
|
+
// 整批加入猜測值。
|
|
158
|
+
const ANTHROPIC_USAGE_KEYS = new Set([
|
|
159
|
+
"input_tokens",
|
|
160
|
+
"output_tokens",
|
|
161
|
+
"cache_creation_input_tokens",
|
|
162
|
+
"cache_read_input_tokens",
|
|
163
|
+
"cache_creation",
|
|
164
|
+
"output_tokens_details",
|
|
165
|
+
"service_tier",
|
|
166
|
+
"inference_geo",
|
|
167
|
+
]);
|
|
168
|
+
const ANTHROPIC_CACHE_CREATION_KEYS = new Set(["ephemeral_5m_input_tokens", "ephemeral_1h_input_tokens"]);
|
|
169
|
+
const ANTHROPIC_OUTPUT_DETAILS_KEYS = new Set(["thinking_tokens"]);
|
|
170
|
+
function collectUnknownKeys(obj, known, pathPrefix) {
|
|
171
|
+
return Object.keys(obj)
|
|
172
|
+
.filter((k) => !known.has(k))
|
|
173
|
+
.map((k) => (pathPrefix ? `${pathPrefix}.${k}` : k));
|
|
174
|
+
}
|
|
175
|
+
function isPlainRecord(v) {
|
|
176
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
177
|
+
}
|
|
178
|
+
// 規格七之二:exhaustive 分派,不留「其餘落到 X」的預設分支——switch 缺少任一 case 時,
|
|
179
|
+
// TypeScript 會因「不是每條路徑都回傳值」而編譯失敗,逼未來新增第四家 provider 時必須
|
|
180
|
+
// 手動同步這裡,不會靜默落到既有某一家的允許清單(v2.6 的 unknownUsageProviderKey 三元
|
|
181
|
+
// 運算子正是這樣被 "anthropic-messages" 加入後靜默吃掉,型別檢查抓不到)。
|
|
182
|
+
export function usageProviderKeyFor(api) {
|
|
183
|
+
switch (api) {
|
|
184
|
+
case "responses":
|
|
185
|
+
return "responses";
|
|
186
|
+
case "gemini-native":
|
|
187
|
+
return "gemini";
|
|
188
|
+
case "anthropic-messages":
|
|
189
|
+
return "anthropic";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function findUnknownUsageKeys(providerKey, usageRaw) {
|
|
193
|
+
if (!isPlainRecord(usageRaw))
|
|
194
|
+
return [];
|
|
195
|
+
if (providerKey === "responses") {
|
|
196
|
+
const unknown = collectUnknownKeys(usageRaw, RESPONSES_USAGE_KEYS, "");
|
|
197
|
+
if (isPlainRecord(usageRaw.input_tokens_details)) {
|
|
198
|
+
unknown.push(...collectUnknownKeys(usageRaw.input_tokens_details, RESPONSES_INPUT_DETAILS_KEYS, "input_tokens_details"));
|
|
199
|
+
}
|
|
200
|
+
if (isPlainRecord(usageRaw.output_tokens_details)) {
|
|
201
|
+
unknown.push(...collectUnknownKeys(usageRaw.output_tokens_details, RESPONSES_OUTPUT_DETAILS_KEYS, "output_tokens_details"));
|
|
202
|
+
}
|
|
203
|
+
return unknown;
|
|
204
|
+
}
|
|
205
|
+
if (providerKey === "anthropic") {
|
|
206
|
+
const unknown = collectUnknownKeys(usageRaw, ANTHROPIC_USAGE_KEYS, "");
|
|
207
|
+
if (isPlainRecord(usageRaw.cache_creation)) {
|
|
208
|
+
unknown.push(...collectUnknownKeys(usageRaw.cache_creation, ANTHROPIC_CACHE_CREATION_KEYS, "cache_creation"));
|
|
209
|
+
}
|
|
210
|
+
if (isPlainRecord(usageRaw.output_tokens_details)) {
|
|
211
|
+
unknown.push(...collectUnknownKeys(usageRaw.output_tokens_details, ANTHROPIC_OUTPUT_DETAILS_KEYS, "output_tokens_details"));
|
|
212
|
+
}
|
|
213
|
+
return unknown;
|
|
214
|
+
}
|
|
215
|
+
const unknown = collectUnknownKeys(usageRaw, GEMINI_USAGE_KEYS, "");
|
|
216
|
+
if (Array.isArray(usageRaw.promptTokensDetails)) {
|
|
217
|
+
for (const el of usageRaw.promptTokensDetails) {
|
|
218
|
+
if (isPlainRecord(el)) {
|
|
219
|
+
unknown.push(...collectUnknownKeys(el, GEMINI_PROMPT_DETAILS_ITEM_KEYS, "promptTokensDetails"));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return unknown;
|
|
224
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §4/§7/§18:工單與 providers.json 的交叉驗證,組出每個 spoke
|
|
2
|
+
// 執行所需的完整、已驗證資料(ResolvedSpoke)。第 1–5 步全部在任何 API 呼叫之前完成,
|
|
3
|
+
// 成本為零(§10)。
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DispatchError } from "./types.js";
|
|
8
|
+
import { buildAllowSet } from "./whitelist.js";
|
|
9
|
+
function apiKeyEnvFor(provider) {
|
|
10
|
+
return `${provider.toUpperCase()}_API_KEY`;
|
|
11
|
+
}
|
|
12
|
+
function stripFrontmatter(markdown) {
|
|
13
|
+
const match = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
|
|
14
|
+
return (match ? match[1] : markdown).trim();
|
|
15
|
+
}
|
|
16
|
+
async function readAgentBody(agentsDir, agent) {
|
|
17
|
+
const agentDefPath = path.join(agentsDir, `${agent}.md`);
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = await readFile(agentDefPath, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new DispatchError(`找不到 agent 定義檔 ${agentDefPath}(_dispatch.md 列了 "${agent}")`, 2);
|
|
24
|
+
}
|
|
25
|
+
return stripFrontmatter(text);
|
|
26
|
+
}
|
|
27
|
+
function isUnderDocsDir(repoRoot, resolved) {
|
|
28
|
+
const docsRoot = path.resolve(repoRoot, "_docs");
|
|
29
|
+
return resolved === docsRoot || resolved.startsWith(docsRoot + path.sep);
|
|
30
|
+
}
|
|
31
|
+
export async function resolveSpokes(ticket, providers, repoRoot, agentsDir = path.join(repoRoot, ".claude", "agents")) {
|
|
32
|
+
const resolved = [];
|
|
33
|
+
for (const row of ticket.rows) {
|
|
34
|
+
const providerConfig = providers[row.provider];
|
|
35
|
+
if (!providerConfig) {
|
|
36
|
+
throw new DispatchError(`_dispatch.md 的 "${row.agent}" 列引用了未定義於 providers.json 的 provider "${row.provider}"`, 2);
|
|
37
|
+
}
|
|
38
|
+
// §18:只檢查本次工單用到的 provider,缺即中止。
|
|
39
|
+
const envName = apiKeyEnvFor(row.provider);
|
|
40
|
+
if (!process.env[envName]) {
|
|
41
|
+
throw new DispatchError(`缺少環境變數 ${envName}("${row.agent}" 列需要 provider "${row.provider}")`, 2);
|
|
42
|
+
}
|
|
43
|
+
// §5:model 須在 providers.json 的 models 白名單內;白名單為空 = 不做型號檢查。
|
|
44
|
+
if (providerConfig.models.length > 0 && !providerConfig.models.includes(row.model)) {
|
|
45
|
+
throw new DispatchError(`"${row.agent}" 列的 model "${row.model}" 不在 provider "${row.provider}" 的 models 白名單內` +
|
|
46
|
+
`(允許:${providerConfig.models.join(", ")})`, 2);
|
|
47
|
+
}
|
|
48
|
+
// §4:effort 填了但不在該 provider 的 allowed 內即中止(含 allowed 為空陣列)。
|
|
49
|
+
if (row.effort !== undefined && !providerConfig.reasoning.allowed.includes(row.effort)) {
|
|
50
|
+
throw new DispatchError(`"${row.agent}" 列的 effort "${row.effort}" 不在 provider "${row.provider}" 的允許值域內` +
|
|
51
|
+
`(允許值:${providerConfig.reasoning.allowed.length > 0 ? providerConfig.reasoning.allowed.join(", ") : "(空——尚未驗證,任何值皆拒絕)"})`, 2);
|
|
52
|
+
}
|
|
53
|
+
// §5:「留白」不再是「不送任何 reasoning 參數」,而是送 reasoning.default。
|
|
54
|
+
// allowed 為空時 default 不存在——該 provider 不可用,即使 effort 留白也中止。
|
|
55
|
+
const effectiveEffort = row.effort ?? providerConfig.reasoning.default;
|
|
56
|
+
if (effectiveEffort === undefined) {
|
|
57
|
+
throw new DispatchError(`"${row.agent}" 列的 effort 留白,但 provider "${row.provider}" 未設 reasoning.default` +
|
|
58
|
+
`(allowed 為空 = 尚未驗證,該 provider 不可用)`, 2);
|
|
59
|
+
}
|
|
60
|
+
const agentTicket = ticket.perAgent.get(row.agent);
|
|
61
|
+
if (!agentTicket) {
|
|
62
|
+
// loadTicket() 已保證存在,此處為型別窄化與防禦
|
|
63
|
+
throw new DispatchError(`內部錯誤:找不到 "${row.agent}" 的工單內容`, 2);
|
|
64
|
+
}
|
|
65
|
+
// §4「針對 hub 會寫錯」:_docs/ 一律拒絕;允許清單逐一驗證存在,任一不存在即中止並指名。
|
|
66
|
+
const allowedReadsResolved = [];
|
|
67
|
+
for (const rel of agentTicket.allowedReads) {
|
|
68
|
+
const resolvedPath = path.resolve(repoRoot, rel);
|
|
69
|
+
if (isUnderDocsDir(repoRoot, resolvedPath)) {
|
|
70
|
+
throw new DispatchError(`"${row.agent}" 的允許讀取清單指向 _docs/(spoke 禁區):${rel}`, 2);
|
|
71
|
+
}
|
|
72
|
+
let real;
|
|
73
|
+
try {
|
|
74
|
+
real = fs.realpathSync(resolvedPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new DispatchError(`"${row.agent}" 的允許讀取清單指向不存在的路徑:${rel}`, 2);
|
|
78
|
+
}
|
|
79
|
+
allowedReadsResolved.push(real);
|
|
80
|
+
}
|
|
81
|
+
const agentBody = await readAgentBody(agentsDir, row.agent);
|
|
82
|
+
const sharedPath = path.join(ticket.ticketDir, "_shared.md");
|
|
83
|
+
const ownAgentPath = path.join(ticket.ticketDir, `${row.agent}.md`);
|
|
84
|
+
const allowSet = buildAllowSet([sharedPath, ownAgentPath, ...allowedReadsResolved]);
|
|
85
|
+
resolved.push({
|
|
86
|
+
agent: row.agent,
|
|
87
|
+
provider: row.provider,
|
|
88
|
+
providerConfig,
|
|
89
|
+
model: row.model,
|
|
90
|
+
effort: effectiveEffort,
|
|
91
|
+
agentBody,
|
|
92
|
+
questions: agentTicket.questions,
|
|
93
|
+
allowSet,
|
|
94
|
+
allowedReadsResolved,
|
|
95
|
+
allowedReadsRelative: agentTicket.allowedReads,
|
|
96
|
+
lang: agentTicket.lang,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// plan_dispatch_v1.4.md §7:白名單判定,集中於核心,不下放 adapter(三套 adapter 各自
|
|
2
|
+
// 實作必然漂移)。精確路徑集合比對,非前綴比對。純函式,供單元測試,不需 mock 任何 provider。
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// 入集端也做 realpath(§7)。呼叫前應已用 validateAllowedPathsExist 驗過存在性;
|
|
6
|
+
// 這裡若仍解析失敗就靜默略過——不在集合裡的路徑本來就會被拒絕,不需要另外報錯。
|
|
7
|
+
export function buildAllowSet(paths) {
|
|
8
|
+
const set = new Set();
|
|
9
|
+
for (const p of paths) {
|
|
10
|
+
try {
|
|
11
|
+
set.add(fs.realpathSync(path.resolve(p)));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// 略過:不存在的路徑不會進集合,checkAllowlist 對它的判定自然是拒絕
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
19
|
+
// 判定:allowSet.has(realpath(resolve(requested)))。路徑不存在時 realpathSync 拋錯——
|
|
20
|
+
// 視同拒絕,回傳「不存在或不在允許範圍」,不區分兩者(避免用錯誤訊息探測檔案系統);
|
|
21
|
+
// reason 分類僅供 run.jsonl 記錄用,不影響回給 spoke 的訊息。
|
|
22
|
+
export function checkAllowlist(allowSet, requestedPath, repoRoot) {
|
|
23
|
+
let real;
|
|
24
|
+
try {
|
|
25
|
+
real = fs.realpathSync(path.resolve(repoRoot, requestedPath));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { allowed: false, reason: "not_found" };
|
|
29
|
+
}
|
|
30
|
+
if (allowSet.has(real)) {
|
|
31
|
+
return { allowed: true, realPath: real };
|
|
32
|
+
}
|
|
33
|
+
const resolvedRoot = fs.realpathSync(path.resolve(repoRoot));
|
|
34
|
+
const insideRepo = real === resolvedRoot || real.startsWith(resolvedRoot + path.sep);
|
|
35
|
+
return { allowed: false, reason: insideRepo ? "not_in_allowlist" : "outside_repo" };
|
|
36
|
+
}
|
|
37
|
+
// 統一回給 spoke 的訊息:不區分「不存在」與「不在允許範圍」。
|
|
38
|
+
export const ALLOWLIST_REJECT_MESSAGE = "不存在或不在允許範圍";
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dowafu",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"cli",
|
|
8
|
+
"code-review",
|
|
9
|
+
"design-review",
|
|
10
|
+
"agent",
|
|
11
|
+
"openai",
|
|
12
|
+
"deepseek",
|
|
13
|
+
"gemini",
|
|
14
|
+
"anthropic"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Eddy Chang",
|
|
18
|
+
"homepage": "https://github.com/eyesofkids/dowafu#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/eyesofkids/dowafu.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/eyesofkids/dowafu/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"dowafu": "dist/cli.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"providers.json",
|
|
36
|
+
"publish"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"verify:providers": "tsx scripts/verify-providers.ts",
|
|
40
|
+
"dispatch": "tsx src/cli.ts",
|
|
41
|
+
"test": "tsx --test 'src/**/*.test.ts'",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"check:skills": "bash scripts/check-publish.sh",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
46
|
+
"prepublishOnly": "npm run test && npm run lint && npm run typecheck && npm run build"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"dotenv": "^16.4.5",
|
|
50
|
+
"openai": "^4.68.4"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@eslint/js": "^10.0.1",
|
|
54
|
+
"@types/node": "^22.10.2",
|
|
55
|
+
"eslint": "^10.8.1",
|
|
56
|
+
"tsx": "^4.19.2",
|
|
57
|
+
"typescript": "^5.7.2",
|
|
58
|
+
"typescript-eslint": "^8.66.0"
|
|
59
|
+
}
|
|
60
|
+
}
|