kenaz 0.3.14 → 0.3.16

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.
@@ -1,52 +1,43 @@
1
1
  ## 專案路徑
2
- ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot 腳本與 task-api / dispatch 呼叫都附加 `--project-path "<PROJECT_PATH>"`。沒有就留空,什麼都不加。
2
+ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot、task-api、dispatch 都附加 `--project-path "<PROJECT_PATH>"`。沒有就不加。
3
3
 
4
4
  # Ansuz boot
5
5
 
6
- 你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,依情境決定自己動手或拆任務派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
-
8
- ## Boot(一次 Bash,無其他儀式)
9
- 1. Turn Context 若有 `[session] session_id=<id>`,記住 `<id>`。
10
- 2. 執行 `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
- 3. banner 有 `📎 Project handoff` 時,**先用 Read 讀 → 標記的那份全文**(不用問用戶),以它為準接續上一個 session。
12
- 4. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),依 handoff 的 R 段與 `📋 Open cards` 直接提出議程。
13
-
14
- ## 每件工作的做法
15
- - **自己做**:小而明確的修正(約 30 行內、單一檔案或少數檔案)→ 直接改,跑最近的測試,提交。
16
- - **開卡派工**:中型、可平行、或高風險路徑的工作:
17
- 1. 用 `kenaz_task_create` 開卡(`projectPath` = 目前專案;寫清楚 requirements、acceptance criteria、scope paths)。查卡、改狀態用 `kenaz_task_list` / `kenaz_task_get` / `kenaz_task_update`。
18
- 2. 產生派工內容:`node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent`(有 PROJECT_PATH 加 `--project-path`)。把輸出 JSON 的 `promptText` 交給 subagent,`agentParams` 照用(模型不要自己換)。
19
- 3. subagent 在 worktree 裡改完並 commit;你驗證後合併。
20
- 若輸出是 `executor` 區塊(模型為 `codex:<id>` / `deepseek:<model>`,無 agentParams):用 Bash run_in_background 執行 `executor.command`,等完成通知,讀 `executor.resultFile`(ok、commits;`no commits` = 失敗),再照常驗證並合併 `executor.branchName`。
21
- - 開卡前先確認 scope 與驗收條件對準真正的問題,不要照著錯的假設派工。
22
-
23
- ## 驗證(合併前一定做,強度跟風險相稱)
24
- - 走**真實路徑**:實際執行會被使用者用到的那條路(真的安裝、真的啟動、真的收發訊息),不只看單元測試。
25
- - 有**對照組**:證明測試在舊程式上會失敗,或修改前後結果不同。
26
- - subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
27
- - 合併:`git merge` 後跑相關測試(或 `node .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
28
-
29
- ## 交接(handoff,ISBAR)
30
- 用戶說交接/暫停/換 session、context 快滿、或結束時仍有未完成的工作 → 寫交接:
31
- 1. `node .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`(有 PROJECT_PATH 加 `--project-path`),建立 `<專案>/.kenaz/handoffs/<日期時間>-<主題>.md`,I 段已自動填好。
32
- 2. 用 Edit 填 S(現況)、B(背景:用戶決定原話、相關卡與 commit)、A(已驗證/未驗證/風險)、R(下一個 session 第一步、待用戶決定的事)。寫給「沒看過這段對話的人」,不寫空泛句。
33
- 3. 一份交接一個檔,不覆寫舊的;下一個 session 的 boot 會自動列出並讀最新那份。
6
+ 你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
+
8
+ ## Boot(一次 Bash)
9
+ 1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
10
+ 2. `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
+ 3. banner 有 `📎 Project handoff`:先 Read → 標記的那份全文,以它接續。
12
+ 4. 轉述 banner,依 handoff 的 R 段與 `📋 Open cards` 提出議程。`[lessons]`、`[learn]`、`[glossary]` 行照 RULES.md 的 Lessons / Project glossary 處理。
13
+
14
+ ## 工作方式
15
+ - **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
16
+ - **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
17
+ - 開卡前確認 scope 與驗收條件對準真正的問題。
18
+
19
+ ## 驗證(合併前,強度跟風險相稱)
20
+ - **真實路徑**:實際跑使用者會走的路,不只看單元測試。
21
+ - **對照組**:證明測試在舊程式上會失敗。
22
+ - subagent 的「未驗證」由你補驗或明講。合併後跑相關測試(含被改檔案的既有測試)。
23
+
24
+ ## 交接
25
+ 用戶說交接/暫停、context 快滿、或結束時有未完成工作:`node .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`,再填 S/B/A/R(見 RULES.md 的 Handoff)。
34
26
 
35
27
  ## 回報與提問
36
- - 一律用用戶的語言(中文訊息 → 中文回覆,含提問選項)。
37
- - 需要用戶決定時用 `AskUserQuestion`(2-4 個選項、建議的放第一個),不要在文字裡列選項等回覆;有證據能自己判斷的事不問。
38
- - 回報說清楚:做了什麼、怎麼驗證的、還有什麼沒驗證或需要用戶決定。不說「應該可以」。
28
+ - 用用戶的語言(中文訊息 → 中文,含提問選項)。
29
+ - 需要用戶決定時用 `AskUserQuestion`(2-4 選項、建議的放第一),有證據能自己判斷的不問。
30
+ - 說清楚做了什麼、怎麼驗證、什麼沒驗證或要用戶決定。不說「應該可以」。
39
31
 
40
- ## Kenaz 本身出問題時:先回報再繞過
41
- Kenaz 的工具、hook、安裝、peer、派工擋住或拖慢工作時,**繞過之前**先 `kenaz_wish_create`(`projectPath`;`scope` kenaz|project;`wish_type` bug|feature|question;`content` = 一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰)。一個問題報一次,報完照常完成工作並告訴用戶;用戶說「回報這個問題」也用它(`source`=`user`)。
32
+ ## Kenaz 本身出問題:先回報再繞過
33
+ 繞過之前先 `kenaz_wish_create`(`scope` kenaz|project;`wish_type` bug|feature|question;一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰),報完照常完成工作。
42
34
 
43
35
  ## 自主模式
44
- 用戶明確授權自主時:立刻 `node .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy。之後結束回合會被 Stop hook 擋回:自己決定、一路做完,不在結尾問「要我…嗎?」;只有必要停點才 `pause`,做完才 `done`。
36
+ 用戶明確授權自主時:`node .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy;只在必要停點 `pause`,做完 `done`。
45
37
 
46
- ## 參考產品(REA)與學習教訓
47
- - banner `[rea]` 行:用戶提到參考產品時,有 REA 就提議分析,沒有就提示一次 `npx rea-agents setup`(範圍見 RULES.md 的 REA)。
48
- - 用戶糾正/重複指示、推翻決定、或驗證抓到失敗時,自動寫教訓並在回報點名一行:`node .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`(規則見 RULES.md 的 Lessons)。
49
- - banner 出現 `[lessons] N to graduate`:跑 `kenaz learn review`,提議把排第一的教訓改成代碼(測試、hook 檢查或專案 CLAUDE.md/AGENTS.md 規則,這是首選結果);糾正若與既有教訓相符屬於重複(recurrence),不是新教訓(`add` 會自動累加)。
50
- - banner 出現 `[learn] N history items to review`:跑 `kenaz learn review`,每個歷史項目(返工、executor 失敗、blocked 卡、handoff 風險行)有現成的 `kenaz learn add ... --source harvest:<id>`,值得記的補上 why 後執行,其餘忽略;指標建議(各 rigor 一次過率、返工最多的檔案)只建議給用戶,不自行套用。詳見 RULES.md。
38
+ ## 教訓、詞彙、參考產品
39
+ - 用戶糾正、推翻決定、或驗證抓到失敗:`node .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`,回報點名一行。
40
+ - 用戶定義或拍板專案術語:`kenaz glossary add "<term>" --means "..."`;問術語前先讀 `.kenaz/GLOSSARY.md`。
41
+ - `[rea]` 行、教訓生命週期與歷程收割:見 RULES.md(REA、Lessons)。
51
42
 
52
- 需要更多規則時(授權自主、派工前、合併前、參考產品、寫教訓)讀 `.specialist/Ansuz/RULES.md`。
43
+ 需要更多規則時(自主、派工、合併、交接、教訓、REA)讀 `.specialist/Ansuz/RULES.md`。
@@ -4,9 +4,11 @@
4
4
  * Bifrost executors for `kenaz subagent` (TASK_2422): run a dispatched card with a non-Claude model.
5
5
  * codex:<id> `codex exec` in the card's worktree (id = an entry of openai/models.json; `codex:` = default)
6
6
  * deepseek:<m> `claude -p` in the worktree against DeepSeek's Anthropic-compatible endpoint
7
+ * claude (TASK_2430) `claude -p --model <alias>` on the user's own Claude login: used by dispatch.js inside a
8
+ * `kenaz launch` session, where the Agent tool can only reach the other vendor
7
9
  *
8
10
  * CLI (dispatch.js prints the exact command):
9
- * node exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>
11
+ * node exec.js --adapter=codex|deepseek|claude --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>
10
12
  * The prompt goes to the child on stdin (never argv). The DeepSeek key is only in the child's env and
11
13
  * is redacted from everything written. The worktree is never removed. Exit 0 = child ok AND new commits.
12
14
  */
@@ -63,6 +65,7 @@ function buildChild({ adapter, id, worktree, lastFile, key, gitDir }) {
63
65
  if (gitDir) args.push('--add-dir', gitDir);
64
66
  return { bin: 'codex', args, env: {}, unset: [] };
65
67
  }
68
+ if (adapter === 'claude') return { bin: 'claude', args: ['-p', '--model', id, '--allowedTools', ...CLAUDE_TOOLS], env: {}, unset: CLAUDE_UNSET };
66
69
  return { bin: 'claude', args: ['-p', '--allowedTools', ...CLAUDE_TOOLS], env: deepseekEnv(id, key), unset: DEEPSEEK_UNSET };
67
70
  }
68
71
 
@@ -90,6 +93,10 @@ async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000)
90
93
  } catch { return null; }
91
94
  }
92
95
  const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
96
+ // The claude adapter must reach Claude, not the vendor `kenaz launch` pointed this session at: drop every override
97
+ // launch sets (the vendor key lives in ANTHROPIC_AUTH_TOKEN; DEEPSEEK_API_KEY is dropped too, the child does not need it).
98
+ const CLAUDE_UNSET = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
99
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'KENAZ_PROVIDER', 'DEEPSEEK_API_KEY'];
93
100
 
94
101
  const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
95
102
  // Same exclusions as the dispatch prompt's commit command (session telemetry never rides along).
@@ -108,11 +115,13 @@ const winQuote = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : '"' + a.replace(/"/g
108
115
 
109
116
  /** Run the executor; resolves to the result object (also written to resultFile). Never throws for child failures. */
110
117
  async function run({ adapter, model = '', worktree, promptFile, resultFile, env = process.env, home = os.homedir() }) {
111
- if (!['codex', 'deepseek'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
118
+ if (!['codex', 'deepseek', 'claude'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
112
119
  const started = Date.now();
113
120
  const key = adapter === 'deepseek' ? deepseekKey(env, home) : null;
114
121
  if (adapter === 'deepseek' && !key) throw new Error('deepseek: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
115
- const redact = (s) => (key ? String(s).split(key).join('***') : String(s));
122
+ // claude adapter: the vendor key is not given to the child, but redact it (and the launch token) from anything written anyway.
123
+ const secrets = adapter === 'claude' ? [deepseekKey(env, home), env.ANTHROPIC_AUTH_TOKEN].filter(Boolean) : key ? [key] : [];
124
+ const redact = (s) => secrets.reduce((acc, k) => acc.split(k).join('***'), String(s));
116
125
  const base = (git(worktree, ['rev-parse', 'HEAD']).stdout || '').trim();
117
126
  const lastFile = resultFile + '.last.txt';
118
127
  const gitDir = (git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout || '').trim();
@@ -148,7 +157,7 @@ async function main(argv) {
148
157
  if (m) a[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
149
158
  }
150
159
  if (!a.adapter || !a.worktree || !a['prompt-file'] || !a['result-file']) {
151
- console.error('usage: exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
160
+ console.error('usage: exec.js --adapter=codex|deepseek|claude --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
152
161
  return 2;
153
162
  }
154
163
  try {
@@ -42,7 +42,8 @@ function plan(argv, env = process.env, home = os.homedir(), live = null) {
42
42
  if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
43
43
  const key = p.key(env, home);
44
44
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
45
- const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
45
+ const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
46
+ KENAZ_PROVIDER: provider }; // tools (dispatch.js) tell the session runs on another vendor
46
47
  for (const k of p.unset) delete childEnv[k];
47
48
  return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
48
49
  }
@@ -31,6 +31,7 @@ test('deepseek: every model slot points at DeepSeek, key only in the child env,
31
31
  assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
32
32
  assert.strictEqual(e.ANTHROPIC_API_KEY, undefined);
33
33
  assert.strictEqual(e.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1');
34
+ assert.strictEqual(e.KENAZ_PROVIDER, 'deepseek', 'tools can tell the session runs on another vendor');
34
35
  assert.strictEqual(e.PATH, 'p', 'the rest of the env is kept');
35
36
  assert.ok(!JSON.stringify(c.args).includes(KEY) && !lines.join('\n').includes(KEY), 'key never in argv or output');
36
37
  });
@@ -0,0 +1,73 @@
1
+ // TASK_2429: bug-diagnosis A/B arm in dispatch (temp dirs only).
2
+ // Usage: node .formation/tools/__tests__/diagnose-bug.test.js
3
+ 'use strict';
4
+ const { spawnSync } = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const D = require('../dispatch');
9
+
10
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
11
+ const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2429_'));
12
+ let pass = 0; let fail = 0;
13
+ const check = (name, cond, detail) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (cond ? '' : ' | ' + detail)); cond ? pass++ : fail++; };
14
+
15
+ // detection
16
+ const card = (o) => JSON.stringify(o);
17
+ check('tag bug', D.isBugCard(card({ title: 'x', tags: ['bug'] })), '');
18
+ check('tag Fix (case)', D.isBugCard(card({ title: 'x', tags: ['Fix'] })), '');
19
+ check('tag regression', D.isBugCard(card({ title: 'x', tags: ['ui', 'regression'] })), '');
20
+ check('type: bug', D.isBugCard(card({ title: 'x', type: 'bug' })), '');
21
+ check('title fix:', D.isBugCard(card({ title: 'Fix login crash' })), '');
22
+ check('title bug', D.isBugCard(card({ title: 'bug in stats' })), '');
23
+ check('title CJK 修正', D.isBugCard(card({ title: '修正登入錯誤' })), '');
24
+ check('title CJK 修', D.isBugCard(card({ title: '修 dashboard 閃退' })), '');
25
+ check('negative: feature', !D.isBugCard(card({ title: 'Add export', tags: ['feature'] })), '');
26
+ check('negative: fixture word', !D.isBugCard(card({ title: 'Fixture loader for tests' })), '');
27
+ check('negative: fix mid-title', !D.isBugCard(card({ title: 'Add quick fix menu' })), '');
28
+ check('negative: debug tag', !D.isBugCard(card({ title: 'x', tags: ['debugging'] })), '');
29
+ check('negative: null / garbage', !D.isBugCard(null) && !D.isBugCard('not json') && !D.isBugCard('{}'), '');
30
+
31
+ // arm assignment
32
+ check('arm stable for same id', D.diagnoseArmFor('TASK_2429') === D.diagnoseArmFor('TASK_2429'), '');
33
+ let withN = 0;
34
+ for (let i = 1000; i < 1200; i++) if (D.diagnoseArmFor('TASK_' + i) === 'with') withN++;
35
+ check('arm balanced over 200 ids (40-60%)', withN >= 80 && withN <= 120, 'with=' + withN);
36
+ check('override on/off', D.diagnoseArmFor('TASK_1', 'on') === 'with' && D.diagnoseArmFor('TASK_1', 'off') === 'without', '');
37
+
38
+ // stats record carries the arm
39
+ D.appendDispatchPromptStats({ taskId: 'TASK_7', promptText: 'p', formation: 'unit', model: 'm', projectPath: SB, diagnoseArm: 'with' });
40
+ D.appendDispatchPromptStats({ taskId: 'TASK_8', promptText: 'p', formation: 'unit', model: 'm', projectPath: SB });
41
+ const recs = fs.readFileSync(path.join(SB, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
42
+ check('stats record: diagnose_arm = with / null for non-bug', recs[0].diagnose_arm === 'with' && recs[1].diagnose_arm === null, JSON.stringify(recs));
43
+
44
+ // real dry-run dispatch path in a sandbox project
45
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(SB, '.kenaz', 'tasks', d), { recursive: true });
46
+ fs.mkdirSync(path.join(SB, '.kenaz', 'locks'), { recursive: true });
47
+ const env = { ...process.env }; delete env.CLAUDE_PROJECT_DIR;
48
+ const run = (script, args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env, windowsHide: true, cwd: ROOT });
49
+ const mk = (title, extra) => {
50
+ const r = run(path.join(ROOT, '.collaboration/core/task-api.js'), ['--project-path', SB, 'create', '--title', title, '--description', 'd', '--scope-paths', 'zzz/a.js',
51
+ '--requirements', 'r1', '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force', ...extra]);
52
+ return ((r.stdout || '').match(/TASK_\d+/) || [])[0];
53
+ };
54
+ const dry = (id, extra) => {
55
+ const r = run(path.join(ROOT, '.formation/tools/dispatch.js'), ['--skill=do', '--task=' + id, '--dispatch-mode=subagent', '--dry-run', '--project-path', SB, ...extra]);
56
+ try { return JSON.parse(r.stdout); } catch { return { err: (r.stdout + r.stderr).slice(-300) }; }
57
+ };
58
+ const bug = mk('Fix flaky stats crash', ['--tags', 'bug']);
59
+ const feat = mk('Add export button', ['--tags', 'feature']);
60
+ check('setup: cards created', !!bug && !!feat, bug + ' ' + feat);
61
+ if (bug && feat) {
62
+ const on = dry(bug, ['--diagnose', 'on']); const off = dry(bug, ['--diagnose', 'off']);
63
+ check('with arm: section + diagnoseArm', !!on.promptText && on.promptText.includes('## Bug diagnosis') && on.promptText.includes('diagnose-bug.md') && on.diagnoseArm === 'with', JSON.stringify(on).slice(0, 300));
64
+ check('without arm: no section, diagnoseArm=without', !!off.promptText && !off.promptText.includes('## Bug diagnosis') && off.diagnoseArm === 'without', JSON.stringify(off).slice(0, 300));
65
+ check('default arm follows the id hash', dry(bug, []).diagnoseArm === D.diagnoseArmFor(bug), '');
66
+ const fOn = dry(feat, ['--diagnose', 'on']); const fOff = dry(feat, ['--diagnose', 'off']);
67
+ check('non-bug: no section, no arm, prompt identical whatever --diagnose says', !!fOn.promptText && !fOn.promptText.includes('## Bug diagnosis') && fOn.diagnoseArm === undefined && fOn.promptText === fOff.promptText, JSON.stringify(fOn).slice(0, 300));
68
+ check('with-arm prompt = without-arm prompt + section only', !!on.promptText && !!off.promptText && on.promptText === off.promptText + '\n\n' + D.BUG_DIAGNOSIS_SECTION + '\n', '');
69
+ }
70
+
71
+ try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
72
+ console.log(`\n${pass} passed, ${fail} failed`);
73
+ process.exit(fail ? 1 : 0);
@@ -31,7 +31,7 @@ let stdin = ''; process.stdin.on('data', (d) => { stdin += d; });
31
31
  process.stdin.on('end', () => {
32
32
  const argv = process.argv.slice(2);
33
33
  fs.writeFileSync(path.join(process.env.STUB_OUT, name + '.json'), JSON.stringify({ argv, cwd: process.cwd(), stdin,
34
- env: { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } }));
34
+ env: { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, fullEnv: process.env }));
35
35
  if (process.env.STUB_WRITE_ONLY) fs.writeFileSync(path.join(process.cwd(), 'left.txt'), 'x'); // a sandboxed agent: edits, cannot commit
36
36
  if (process.env.STUB_COMMIT) {
37
37
  fs.writeFileSync(path.join(process.cwd(), 'out.txt'), 'x');
@@ -148,6 +148,62 @@ test('subagent set: unknown codex id and unknown prefix are refused with the val
148
148
  });
149
149
 
150
150
  // ---- dispatch.js: executor block vs the unchanged Claude control ----
151
+ const REMOVED = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
152
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'KENAZ_PROVIDER'];
153
+ test('claude executor (TASK_2430): --model alias, every launch override removed, vendor key nowhere', (t) => {
154
+ const launchEnv = Object.fromEntries(REMOVED.map((k) => [k, 'x-' + k])); launchEnv.ANTHROPIC_AUTH_TOKEN = KEY; launchEnv.STUB_ECHO_KEY = '1';
155
+ const x = runExec(t, { adapter: 'claude', model: 'opus', extraEnv: launchEnv });
156
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
157
+ assert.deepEqual(x.claude.argv, ['-p', '--model', 'opus', '--allowedTools', 'Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep']);
158
+ for (const k of [...REMOVED, 'DEEPSEEK_API_KEY']) assert.equal(x.claude.fullEnv[k], undefined, k + ' reached the claude child');
159
+ assert.match(x.claude.stdin, /^DO THE CARD\n\n## Executor note\n/);
160
+ assert.equal(JSON.stringify(x.claude.fullEnv).includes(KEY), false);
161
+ assert.equal(JSON.stringify(x.claude.argv).includes(KEY), false);
162
+ assert.equal((x.r.stdout + x.r.stderr).includes(KEY), false);
163
+ assert.equal(fs.readFileSync(x.resultPath, 'utf8').includes(KEY), false);
164
+ assert.equal(x.result.ok, true); assert.match(x.result.commits[0], /stub work/);
165
+ });
166
+
167
+ test('claude executor: edits left uncommitted are committed by exec.js; no commits = failure', (t) => {
168
+ const left = runExec(t, { adapter: 'claude', model: 'sonnet', extraEnv: { STUB_COMMIT: '', STUB_WRITE_ONLY: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
169
+ assert.equal(left.r.status, 0, left.r.stdout + left.r.stderr);
170
+ assert.ok(left.result.executorCommit);
171
+ const none = runExec(t, { adapter: 'claude', model: 'sonnet', extraEnv: { STUB_COMMIT: '' } });
172
+ assert.equal(none.r.status, 1); assert.equal(none.result.error, 'no commits');
173
+ });
174
+
175
+ test('dispatch under KENAZ_PROVIDER: Claude id -> executor adapter claude, no agentParams; without it agentParams as before', (t) => {
176
+ const sb = tmp(t, 'exec-dispatch-claude-');
177
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
178
+ fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
179
+ fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
180
+ git(sb, ['init', '-q']); git(sb, ['add', '-A']); git(sb, ['commit', '-q', '--no-verify', '-m', 'init']);
181
+ const base = { ...process.env }; delete base.CLAUDE_PROJECT_DIR; delete base.CODEX_SESSION_ID; delete base.CODEX_THREAD_ID; delete base.KENAZ_PROVIDER;
182
+ const sh = (script, args, env) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
183
+ const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2430', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
184
+ '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force'], base);
185
+ const id = ((mk.stdout || '').match(/TASK_\d+/) || [])[0]; assert.ok(id, mk.stdout + mk.stderr);
186
+ const disp = (model, provider) => {
187
+ fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: model }));
188
+ const env = { ...base, ...(provider ? { KENAZ_PROVIDER: provider } : {}), DEEPSEEK_API_KEY: KEY };
189
+ const r = sh(DISPATCH, ['--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force'], env);
190
+ return { r, j: (() => { try { return JSON.parse(r.stdout); } catch { return null; } })() };
191
+ };
192
+ const ds = disp('claude-opus-5', 'deepseek');
193
+ assert.ok(ds.j, ds.r.stdout + ds.r.stderr);
194
+ assert.equal(ds.j.agentParams, undefined);
195
+ assert.equal(ds.j.executor.adapter, 'claude'); assert.equal(ds.j.executor.model, 'opus');
196
+ assert.equal(ds.j.executor.branchName, `task/${id.toLowerCase()}`);
197
+ assert.ok(fs.existsSync(ds.j.executor.worktreePath)); assert.ok(ds.j.executor.promptFile && ds.j.executor.resultFile && ds.j.executor.note);
198
+ assert.match(ds.j.executor.command, /exec\.js" --adapter=claude --model="opus" --worktree/);
199
+ assert.equal(ds.r.stdout.includes(KEY) || ds.r.stderr.includes(KEY), false);
200
+ const an = disp('claude-sonnet-5-5', 'anthropic'); // control: provider anthropic = no executor
201
+ assert.equal(an.j.executor, undefined); assert.equal(an.j.agentParams.model, 'sonnet');
202
+ const none = disp('claude-sonnet-5-5', null); // control: no KENAZ_PROVIDER = byte-for-byte as before
203
+ assert.equal(none.j.executor, undefined);
204
+ assert.deepEqual(Object.keys(none.j.agentParams), ['description', 'model']); assert.equal(none.j.agentParams.model, 'sonnet');
205
+ });
206
+
151
207
  test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
152
208
  const sb = tmp(t, 'exec-dispatch-');
153
209
  for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
@@ -0,0 +1,23 @@
1
+ # Diagnosing a bug
2
+
3
+ Adapted from `diagnosing-bugs` in github.com/mattpocock/skills (MIT license). Applies to bug cards in the
4
+ `with` arm of the TASK_2429 A/B (see dev/research/self-improvement/DESIGN.md).
5
+
6
+ The loop comes first. Guessing at a fix without a loop that fails on this bug is the main cause of wrong fixes.
7
+
8
+ 1. **Red loop before any code change.** Build the cheapest thing that fails because of this bug: a test at the
9
+ nearest seam (the lowest function that still shows the wrong result), a CLI or HTTP repro script, a replayed
10
+ artifact (the log, payload or file that triggered it), or a bisect harness over commits. Run it and see it fail
11
+ for the reported reason, not for a missing fixture. Inline fixtures that differ from the real input are the usual
12
+ way a loop passes while the bug lives on: use the real path.
13
+ 2. **Tighten it.** Fast (seconds), specific (fails only on this bug), deterministic (no clock, network or order
14
+ dependence; run it 3 times). A flaky loop proves nothing.
15
+ 3. **Hypotheses, ranked.** Write 3 or fewer causes, most likely first. For each, name the observation that would
16
+ kill it (a value, a log line, a branch not taken). Instrument to get that observation; do not edit behavior to
17
+ test a guess. When a hypothesis dies, list the rest again instead of tweaking the same spot.
18
+ 4. **Fix** the cause the observation shows, at the place it originates, not where the symptom appears.
19
+ 5. **Prove it.** Loop green with the fix, and red again with the fix reverted (the control: green alone could
20
+ mean the loop never exercised the bug). Keep the test. Check one sibling: the same pattern elsewhere.
21
+ 6. **Redact secrets** (tokens, keys, cookies, personal data) in anything you print, log, commit or report.
22
+
23
+ Report: the loop command, its red output (one line), the green output, and the revert check.
@@ -627,6 +627,38 @@ function buildCortexContext(scopePaths, projectPath) {
627
627
  // TASK_1998 R1: dispatch-prompt telemetry
628
628
  // ─────────────────────────────────────────
629
629
 
630
+ // TASK_2429: bug-diagnosis discipline (adapted from mattpocock/skills diagnosing-bugs, MIT) with a
631
+ // randomized A/B arm per bug card, so its benefit is measured (dev/research/self-improvement/DESIGN.md).
632
+ function isBugCard(taskData) {
633
+ let t;
634
+ try { t = typeof taskData === 'string' ? JSON.parse(taskData) : taskData; } catch { return false; }
635
+ if (!t || typeof t !== 'object') return false;
636
+ const tags = Array.isArray(t.tags) ? t.tags : (typeof t.tags === 'string' ? t.tags.split(/[,\s]+/) : []);
637
+ if (tags.some((x) => /^(bug|fix|regression)$/i.test(String(x).trim()))) return true;
638
+ if (/^bug$/i.test(String(t.type || '').trim())) return true;
639
+ return /^\s*((fix(es|ed)?|bug)\b|修)/i.test(String(t.title || ''));
640
+ }
641
+
642
+ /** 'with' | 'without': stable 50/50 split by task id (FNV-1a), unless forced by --diagnose on|off. */
643
+ function diagnoseArmFor(taskId, override) {
644
+ if (override === 'on') return 'with';
645
+ if (override === 'off') return 'without';
646
+ let h = 2166136261;
647
+ for (const ch of String(taskId || '')) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; }
648
+ return (h >>> 7) % 2 === 0 ? 'with' : 'without';
649
+ }
650
+
651
+ const BUG_DIAGNOSIS_SECTION = [
652
+ '## Bug diagnosis',
653
+ 'Follow .formation/tools/dispatch-detail/diagnose-bug.md (Read it first):',
654
+ '1. Build a loop that goes red on this bug before touching code (test at the nearest seam, repro script, replayed artifact).',
655
+ '2. Tighten it: fast, specific, deterministic.',
656
+ '3. Rank hypotheses, each with the observation that would kill it.',
657
+ '4. Fix the cause the loop shows.',
658
+ '5. Prove it: loop green with the fix, red with the fix reverted; keep the test.',
659
+ '6. Redact secrets in anything you print or commit.',
660
+ ].join('\n');
661
+
630
662
  /**
631
663
  * Append one structured JSONL line to .kenaz/specialist/dispatch_prompt_stats.jsonl —
632
664
  * one per sub-agent dispatch (getSubagentDispatchParams). Mirrors
@@ -641,7 +673,7 @@ function buildCortexContext(scopePaths, projectPath) {
641
673
  * complexity (TASK_1998 two-axis policy): the normalized 'simple'|'medium'|'complex'
642
674
  * CONTEXT-volume key (extractComplexity() default). Optional for older readers.
643
675
  */
644
- function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promptText, model, modelSource, projectPath }) {
676
+ function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promptText, model, modelSource, projectPath, diagnoseArm }) {
645
677
  const root = projectPath || ROOT;
646
678
  const dir = path.join(root, '.kenaz', 'specialist');
647
679
  try {
@@ -656,6 +688,7 @@ function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promp
656
688
  sections: splitPromptSections(promptText || ''),
657
689
  model,
658
690
  model_source: modelSource || null,
691
+ diagnose_arm: diagnoseArm || null,
659
692
  });
660
693
  fs.appendFileSync(path.join(dir, 'dispatch_prompt_stats.jsonl'), line + '\n', 'utf8');
661
694
  } catch { /* best-effort — telemetry must never block dispatch */ }
@@ -743,6 +776,7 @@ Options:
743
776
  --formation <name> Skill folder: unit (default) or solo
744
777
  --skill <name> Skill to use (YAML filename without extension)
745
778
  --task <value> Task ID (TASK_XXX) or free-text description
779
+ --diagnose on|off Force the bug-diagnosis A/B arm for a bug card (default: stable hash of the task id)
746
780
  --dry-run Build and print the payload with no side effects
747
781
  --dispatch-mode <mode> Required: subagent (JSON params for the Agent tool)
748
782
  --harness <name> claude (default) | codex (Codex spawn_agent payload)
@@ -868,7 +902,7 @@ Sub-agent dispatch (Ansuz billing-aware path):
868
902
  console.error('❌ --dispatch-mode=subagent is required (Formation headless/squad/legion dispatch is retired)');
869
903
  process.exit(1);
870
904
  }
871
- getSubagentDispatchParams({ formation, skill, task, projectPath, force, useWorktree: useWorktreeCli, dispatchModelOverride: dispatchModelCli, dryRun, harness: harnessCli }).then(result => {
905
+ getSubagentDispatchParams({ formation, skill, task, projectPath, force, useWorktree: useWorktreeCli, dispatchModelOverride: dispatchModelCli, dryRun, harness: harnessCli, diagnose: getArg('--diagnose') }).then(result => {
872
906
  if (!result) { process.exitCode = 1; return; }
873
907
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
874
908
  }).catch(e => {
@@ -942,6 +976,7 @@ async function getSubagentDispatchParams(options) {
942
976
  dryRun = false,
943
977
  // TASK_2315: 'claude' (Agent tool) | 'codex' (Codex-native spawn_agent). Selects the payload shape.
944
978
  harness = 'claude',
979
+ diagnose = null, // TASK_2429: --diagnose on|off forces the bug-diagnosis arm
945
980
  } = options;
946
981
  if (harness !== 'claude' && harness !== 'codex') {
947
982
  console.error(`❌ --harness expects claude|codex (got '${harness}')`);
@@ -1028,6 +1063,12 @@ async function getSubagentDispatchParams(options) {
1028
1063
  return null;
1029
1064
  }
1030
1065
  }
1066
+ // TASK_2430: in a `kenaz launch` session the Agent tool only reaches the other vendor, so a Claude model
1067
+ // id runs as a `claude -p` executor on the user's own Claude login instead.
1068
+ if (!isCodex && !exec && process.env.KENAZ_PROVIDER && process.env.KENAZ_PROVIDER !== 'anthropic') {
1069
+ const fam = translateModelForAgentTool(rawModel).model;
1070
+ if (fam) exec = { adapter: 'claude', id: fam };
1071
+ }
1031
1072
  // TASK_2315: Codex inherits the session model — nothing to translate or pass.
1032
1073
  const translated = isCodex || exec ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
1033
1074
  if (!isCodex && !exec && !translated.model) {
@@ -1190,7 +1231,10 @@ async function getSubagentDispatchParams(options) {
1190
1231
  if (projectPath) promptOptions.projectPath = projectPath;
1191
1232
  if (taskInput && taskInput.startsWith('TASK_')) promptOptions.taskId = taskInput;
1192
1233
 
1193
- const rawPrompt = buildPrompt(skillDef, taskInput, taskData, promptOptions);
1234
+ let rawPrompt = buildPrompt(skillDef, taskInput, taskData, promptOptions);
1235
+ // TASK_2429: only bug cards get an arm; any other prompt stays byte-for-byte unchanged.
1236
+ const diagnoseArm = isBugCard(taskData) ? diagnoseArmFor(taskInput, diagnose) : null;
1237
+ if (diagnoseArm === 'with' && typeof rawPrompt === 'string') rawPrompt = rawPrompt + '\n\n' + BUG_DIAGNOSIS_SECTION + '\n';
1194
1238
 
1195
1239
  // 4. Cortex context (same as Formation dispatch)
1196
1240
  const taskScopePaths = (() => {
@@ -1335,7 +1379,7 @@ async function getSubagentDispatchParams(options) {
1335
1379
  // by dashboard vs dashboard-by-rigor vs cli without re-deriving it.
1336
1380
  // Two-axis policy: complexity rides along too, so dispatch-stats.js can
1337
1381
  // group by (rigor, complexity) instead of rigor alone.
1338
- if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath });
1382
+ if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath, diagnoseArm });
1339
1383
 
1340
1384
  // TASK_2032: mirror headless.js's formation-path auto-activation (~L272-290)
1341
1385
  // on the subagent path — every gate/guard above has passed and this function
@@ -1395,6 +1439,7 @@ async function getSubagentDispatchParams(options) {
1395
1439
  promptText, taskId: taskInput, formation, skill, dispatchMode, effectiveMode: 'subagent',
1396
1440
  ...(isCodex ? { harness: 'codex', cwd: crossWorktree ? crossWorktree.worktreePath : null, worktreePath: crossWorktree ? crossWorktree.worktreePath : null } : {}),
1397
1441
  modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), ...(executor ? { executor } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
1442
+ ...(diagnoseArm ? { diagnoseArm } : {}),
1398
1443
  ...(dryRun ? { dryRun: true } : {}),
1399
1444
  };
1400
1445
  }
@@ -1445,4 +1490,4 @@ function setupSubagentCrossProjectWorktree(projectPath, taskInput) {
1445
1490
  };
1446
1491
  }
1447
1492
 
1448
- module.exports = { resolveDispatchModelForRigor, translateModelForAgentTool, DEFAULT_DISPATCH_MODEL, getSubagentDispatchParams, checkDispatchScopeGate, releaseDispatchLock, resolveDispatchIsolation, _getProvenSessionId, _getOwnConversationId, _isOwnClaimMarker, _buildCortexContext: buildCortexContext };
1493
+ module.exports = { isBugCard, diagnoseArmFor, BUG_DIAGNOSIS_SECTION, appendDispatchPromptStats, resolveDispatchModelForRigor, translateModelForAgentTool, DEFAULT_DISPATCH_MODEL, getSubagentDispatchParams, checkDispatchScopeGate, releaseDispatchLock, resolveDispatchIsolation, _getProvenSessionId, _getOwnConversationId, _isOwnClaimMarker, _buildCortexContext: buildCortexContext };
@@ -209,6 +209,37 @@ function lessonStats({ home, cwd, now, days }) {
209
209
  } catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
210
210
  }
211
211
 
212
+ /** TASK_2429: bug cards per diagnosis arm (dispatch_prompt_stats.jsonl diagnose_arm) joined with the git first-pass verdicts of dispatchStats. */
213
+ function diagnoseAbStats({ cwd, now, days, dispatch }) {
214
+ const arms = {}; // card number -> arm (latest dispatch record wins)
215
+ try {
216
+ for (const l of fs.readFileSync(path.join(cwd, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl'), 'utf8').split('\n')) {
217
+ let r; try { r = JSON.parse(l); } catch (_) { continue; }
218
+ const m = r && r.diagnose_arm && /^TASK_(\d+)$/i.exec(r.task_id || '');
219
+ if (m && (ms(r.ts) || 0) >= now - days * DAY) arms[m[1]] = r.diagnose_arm;
220
+ }
221
+ } catch (_) { /* no records */ }
222
+ const rework = new Set(((dispatch && dispatch.rework) || []).map((x) => x.card));
223
+ const merged = new Set((dispatch && dispatch.mergedCards) || []);
224
+ const out = {};
225
+ for (const arm of ['with', 'without']) {
226
+ const cards = Object.keys(arms).filter((n) => arms[n] === arm).map((n) => 'TASK_' + n);
227
+ const m = cards.filter((c) => merged.has(c));
228
+ out[arm] = { cards: cards.length, merged: m.length, firstPass: m.filter((c) => !rework.has(c)).length };
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /** TASK_2428: glossary size/use, and first-pass of merged cards that got injected terms vs those that did not. */
234
+ function glossaryStats({ cwd, now, days, cur }) {
235
+ const g = require('../../.specialist/tools/glossary.js').stats(cwd, now, days);
236
+ const inj = new Set(g.cards);
237
+ const merged = cur.mergedCards || [];
238
+ const rework = new Set((cur.rework || []).map((r) => r.card));
239
+ const rate = (ids) => (ids.length ? { rate: ids.filter((c) => !rework.has(c)).length / ids.length, merged: ids.length } : null);
240
+ return { ...g, withTerms: rate(merged.filter((c) => inj.has(c))), withoutTerms: rate(merged.filter((c) => !inj.has(c))) };
241
+ }
242
+
212
243
  function selfImprovementStats({ home, cwd, now, days }) {
213
244
  const src = '<project>/.kenaz/specialist/correction-nudges.jsonl + lessons + .kenaz/knowledge/.harvested.json + git (this window vs the previous one)';
214
245
  try {
@@ -216,7 +247,8 @@ function selfImprovementStats({ home, cwd, now, days }) {
216
247
  const prev = dispatchStats({ repo: cwd, now: now - days * DAY, days, until: now - days * DAY });
217
248
  const pick = (d) => (d.firstPassRate == null ? null : { rate: d.firstPassRate, merged: d.merged });
218
249
  return { source: src, ...require('../../.specialist/tools/lessons-harvest.js').selfStats({ projectPath: cwd, home, now, days }),
219
- firstPass: { window: pick(cur), previous: pick(prev) } };
250
+ firstPass: { window: pick(cur), previous: pick(prev) }, diagnoseAb: diagnoseAbStats({ cwd, now, days, dispatch: cur }),
251
+ glossary: glossaryStats({ cwd, now, days, cur }) };
220
252
  } catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
221
253
  }
222
254
 
@@ -282,6 +314,13 @@ function render(s) {
282
314
  ` lessons by source: ${Object.entries(si.bySource).map(([k, v]) => `${k}=${v.lessons} (fired ${v.fired}, recurred before first fire ${v.recurBefore}, after ${v.recurAfter})`).join('; ') || 'none'}`,
283
315
  ` graduated ${si.graduated}; harvest items open ${si.harvestOpen}, recorded ${si.harvestRecorded}`,
284
316
  ` first-pass merge rate: this window ${r(si.firstPass.window)}, previous window ${r(si.firstPass.previous)}`);
317
+ if (si.diagnoseAb) {
318
+ const a = (x) => `${x.cards} bug cards, first-pass ${x.firstPass}/${x.merged}${x.merged ? ` (${(100 * x.firstPass / x.merged).toFixed(0)}%)` : ''}`;
319
+ L.push(` Bug diagnosis A/B: with: ${a(si.diagnoseAb.with)}; without: ${a(si.diagnoseAb.without)}${Math.min(si.diagnoseAb.with.merged, si.diagnoseAb.without.merged) < 10 ? ' (below 10 merged per arm: keep collecting)' : ''}`);
320
+ }
321
+ const g = si.glossary;
322
+ if (g) L.push(` glossary: ${g.terms} terms, ${g.added} added in window; ${g.injections} injections in ${g.cards.length} cards`,
323
+ ` first-pass merge rate, cards with injected terms ${r(g.withTerms)}, without ${r(g.withoutTerms)}`);
285
324
  }
286
325
  }
287
326
  return L.join('\n');
@@ -297,4 +336,4 @@ function main(argv, out = console.log, opts = {}) {
297
336
  }
298
337
 
299
338
  if (require.main === module) process.exit(main(process.argv.slice(2)));
300
- module.exports = { main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
339
+ module.exports = { diagnoseAbStats, main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
@@ -161,6 +161,23 @@ test('alaya: bija counts, activations, exported and seed imports in window; miss
161
161
  assert.ok(S.alayaStats({ home: path.join(tmp, 'nohome'), now: NOW, days: 14 }).note.startsWith('no data'));
162
162
  });
163
163
 
164
+ test('diagnose A/B: bug cards and first-pass per arm from fake records (TASK_2429)', () => {
165
+ const cwd = path.join(tmp, 'ab-proj');
166
+ const f = path.join(cwd, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl');
167
+ fs.mkdirSync(path.dirname(f), { recursive: true });
168
+ const rec = (id, arm, ts = iso(1)) => JSON.stringify({ ts, task_id: id, diagnose_arm: arm });
169
+ fs.writeFileSync(f, [rec('TASK_1', 'with'), rec('TASK_2', 'with'), rec('TASK_3', 'without'), rec('TASK_4', 'without'), rec('TASK_5', 'without'),
170
+ rec('TASK_6', 'with', iso(60)), JSON.stringify({ ts: iso(1), task_id: 'TASK_9', diagnose_arm: null }), 'not json'].join('\n') + '\n');
171
+ const dispatch = { mergedCards: ['TASK_1', 'TASK_2', 'TASK_3', 'TASK_4', 'TASK_9'], rework: [{ card: 'TASK_2' }, { card: 'TASK_3' }] };
172
+ const r = S.diagnoseAbStats({ cwd, now: NOW, days: 14, dispatch });
173
+ assert.deepStrictEqual(r.with, { cards: 2, merged: 2, firstPass: 1 });
174
+ assert.deepStrictEqual(r.without, { cards: 3, merged: 2, firstPass: 1 });
175
+ assert.deepStrictEqual(S.diagnoseAbStats({ cwd: path.join(tmp, 'none'), now: NOW, days: 14, dispatch }).with, { cards: 0, merged: 0, firstPass: 0 });
176
+ const text = S.render({ days: 14, peer: { source: '-', note: 'no data' }, dispatch: { source: '-', note: 'no data' }, fieldReports: { source: '-', note: 'no data' }, alaya: { source: '-', note: 'no data' },
177
+ selfImprovement: { source: '-', nudges: 0, converted: 0, bySource: {}, graduated: 0, harvestOpen: 0, harvestRecorded: 0, firstPass: {}, diagnoseAb: r } });
178
+ assert.ok(text.includes('Bug diagnosis A/B: with: 2 bug cards, first-pass 1/2 (50%); without: 3 bug cards, first-pass 1/2 (50%) (below 10 merged per arm: keep collecting)'), text);
179
+ });
180
+
164
181
  test('main: --json parses and all four sections print; bad --days exits 2', () => {
165
182
  const lines = [];
166
183
  assert.strictEqual(S.main(['--json'], (m) => lines.push(m), { home: path.join(tmp, 'home2'), cwd: path.join(tmp, 'repo'), now: NOW }), 0);
@@ -170,6 +170,9 @@ function buildSubagentPrompt(skillDef, taskInput, taskData, options = {}) {
170
170
  // TASK_2409: lessons the user's Ansuz learned for this project (bounded; nothing is added when there are none).
171
171
  const learned = projectLessons(taskData, options);
172
172
  if (learned.facts.length) lines.push('## Project lessons', ...learned.facts, '');
173
+ // TASK_2428: project glossary entries for the terms this card uses (nothing is added when none occur).
174
+ const terms = projectTerms(taskData, options);
175
+ if (terms.length) lines.push('## Project terms', ...terms, '');
173
176
 
174
177
  lines.push('## Verify and report');
175
178
  // Ponytail A/B 2026-10-10: the arm without this line broke an existing fixture test it never ran.
@@ -190,15 +193,33 @@ function buildSubagentPrompt(skillDef, taskInput, taskData, options = {}) {
190
193
  return lines.join('\n');
191
194
  }
192
195
 
196
+ // Same-project dispatch passes no --project-path (only cross-project does), so the project whose lessons and
197
+ // glossary apply is the Kenaz project around the cwd. Before this fallback, a plain dispatch injected neither
198
+ // (measured 2026-10-12: 0 checks without --project-path, 3 with it, same card).
199
+ function lessonProject(options) {
200
+ if (options.projectPath) return options.projectPath;
201
+ try { return require('../../.claude/hooks/_project-guard').findProjectRoot(process.cwd()); } catch { return null; }
202
+ }
203
+
193
204
  function projectLessons(taskData, options) {
194
205
  const none = { facts: [], checks: [] };
195
- if (!options.projectPath) return none;
206
+ const projectPath = lessonProject(options);
207
+ if (!projectPath) return none;
196
208
  try {
197
209
  const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
198
- return require('../../.specialist/tools/lessons').dispatchLessons(options.projectPath, (parsed && parsed.scope_paths) || []);
210
+ return require('../../.specialist/tools/lessons').dispatchLessons(projectPath, (parsed && parsed.scope_paths) || []);
199
211
  } catch { return none; }
200
212
  }
201
213
 
214
+ function projectTerms(taskData, options) {
215
+ const projectPath = lessonProject(options);
216
+ if (!projectPath) return [];
217
+ try {
218
+ const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
219
+ return require('../../.specialist/tools/glossary').dispatchTerms(projectPath, parsed || {}).lines;
220
+ } catch { return []; }
221
+ }
222
+
202
223
  function extractTaskId(taskData) {
203
224
  try {
204
225
  const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
@@ -10,6 +10,7 @@ const test = require('node:test');
10
10
  const assert = require('node:assert/strict');
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const os = require('os');
13
14
 
14
15
  const {
15
16
  buildPrompt,
@@ -310,7 +311,14 @@ test('TASK_2391: subagent prompt keeps card + commit rule + report, drops boiler
310
311
  requirements: ['REQ-ONE'], acceptance_criteria: ['AC-ONE'], technical_notes: ['NOTE-ONE'],
311
312
  complexity: 'complex', rigor: 'high',
312
313
  };
313
- const body = buildPrompt(loadDoSkill(), 'TASK_9001', JSON.stringify(card), { isSubagent: true, taskId: 'TASK_9001' });
314
+ // Built outside any Kenaz project: this measures the template, not the repo's own lessons/glossary
315
+ // (a dispatch without --project-path injects the cwd project's, see lessonProject in prompt.js).
316
+ const was = process.cwd();
317
+ let body;
318
+ try {
319
+ process.chdir(fs.mkdtempSync(path.join(os.tmpdir(), 'p2391-')));
320
+ body = buildPrompt(loadDoSkill(), 'TASK_9001', JSON.stringify(card), { isSubagent: true, taskId: 'TASK_9001' });
321
+ } finally { process.chdir(was); }
314
322
  for (const must of ['REQ-ONE', 'AC-ONE', 'NOTE-ONE', 'src/a.js', '--no-verify -m "[TASK_9001] task completed"', '<= 20 lines', 'commit sha', 'did not verify']) {
315
323
  assert.ok(body.includes(must), `missing: ${must}`);
316
324
  }
@@ -0,0 +1,7 @@
1
+ # Glossary
2
+
3
+ ## Ansuz
4
+ Kenaz's orchestrator persona: understands the request, does small fixes itself, dispatches cards to subagents, verifies on the real path, merges.
5
+
6
+ ## Yggdrasil
7
+ Kenaz's multi-node collaboration: a shared room (team key ygk_...) on the relay where nodes exchange peer mail and task replicas.
@@ -73,6 +73,7 @@ the real defect BEFORE carding or dispatching.
73
73
  - Executor model (`kenaz subagent set codex:<id>` or `deepseek:<model>`): dispatch.js prints an `executor` block instead of
74
74
  agentParams. Run `executor.command` with Bash run_in_background, wait for the completion notice, read `executor.resultFile`
75
75
  (ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
76
+ In a `kenaz launch` session (KENAZ_PROVIDER set), Claude subagents also come as an executor block (adapter `claude`, they use the user's Claude quota).
76
77
  - Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
77
78
  via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
78
79
 
@@ -106,3 +107,6 @@ REA (github.com/morluto/rea, MCP server `rea`) is optional and user-installed; t
106
107
  ## Code navigation
107
108
  Grep/Glob for text; kenaz_lsp_definition / kenaz_lsp_callers / kenaz_lsp_references for symbols.
108
109
  The memory system (synapse/hippocampus/cortex/alaya) is frozen since 2026-10-10: do not call it; lessons go to the native memory files.
110
+
111
+ ## Project glossary (TASK_2428)
112
+ When the user defines or corrects a project term, or a term is ambiguous and the user settles it, record it with `kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>]` (on an npm install: through core-resolve.js, like lessons). Read `.kenaz/GLOSSARY.md` before asking the user what a term means. Dispatch prompts get only the entries whose term the card mentions; benefit is measured in `kenaz stats` and judged under the rule in dev/research/self-improvement/DESIGN.md.
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+ // TASK_2428: project glossary. Temp projects and temp homes only.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+ const g = require('../glossary');
10
+ const { buildPrompt } = require('../../../.formation/tools/prompt');
11
+
12
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
13
+ const proj = () => fs.mkdtempSync(path.join(os.tmpdir(), 'k2428-'));
14
+ const skill = { name: 'dev', formation: 'unit' };
15
+ const prompt = (p, card) => buildPrompt(skill, 'do it', JSON.stringify(card), { projectPath: p, isSubagent: true, taskId: card.id });
16
+ const usage = (p) => { try { return fs.readFileSync(path.join(p, '.kenaz', 'specialist', 'glossary-usage.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((r) => r.terms); } catch { return []; } };
17
+
18
+ test('glossary: add / replace (case-insensitive) / list / rm, file created lazily', () => {
19
+ const p = proj();
20
+ assert.equal(fs.existsSync(path.join(p, '.kenaz', 'GLOSSARY.md')), false);
21
+ assert.equal(g.add(p, 'Lane A', 'the fast path', { not: 'lane B', see: 'ADR-3' }).ok, true);
22
+ assert.equal(g.add(p, 'lane a', 'the first lane').replaced, true);
23
+ const t = g.readTerms(p);
24
+ assert.equal(t.length, 1);
25
+ assert.equal(t[0].means[0], 'the first lane');
26
+ assert.equal(g.rm(p, 'LANE A').ok, true);
27
+ assert.equal(g.readTerms(p).length, 0);
28
+ assert.equal(g.rm(p, 'nope').ok, false);
29
+ });
30
+
31
+ test('glossary: 60-term cap refuses a new term but still replaces an existing one', () => {
32
+ const p = proj();
33
+ for (let i = 0; i < 60; i++) assert.equal(g.add(p, `T${i}`, 'm').ok, true);
34
+ const r = g.add(p, 'T60', 'm');
35
+ assert.equal(r.ok, false);
36
+ assert.match(r.message, /full \(60 terms\)/);
37
+ assert.equal(g.add(p, 't3', 'new').ok, true);
38
+ assert.equal(g.readTerms(p).length, 60);
39
+ });
40
+
41
+ test('glossary: boot line only with terms', () => {
42
+ const p = proj();
43
+ const boot = () => spawnSync(process.execPath, [path.join(ROOT, '.specialist', 'tools', 'ansuz-boot-fast.js'), '--project-path=' + p, '--conversation-id=s2428', '--mcp-base-url=http://127.0.0.1:1'],
44
+ { encoding: 'utf8', cwd: p, env: { ...process.env, CLAUDE_PROJECT_DIR: p }, timeout: 30000 }).stdout;
45
+ assert.doesNotMatch(boot(), /\[glossary\]/);
46
+ g.add(p, 'Prism', 'the renderer');
47
+ assert.match(boot(), /\[glossary\] 1 terms \(\.kenaz\/GLOSSARY\.md\)/);
48
+ });
49
+
50
+ test('dispatch: only terms the card mentions are injected; a card without terms gets no section; logged', () => {
51
+ const p = proj();
52
+ g.add(p, 'Prism', 'the renderer');
53
+ g.add(p, 'NGO', 'non-profit partner');
54
+ assert.doesNotMatch(prompt(p, { id: 'TASK_1', title: 'fix typo', description: 'plain' }), /## Project terms/);
55
+ assert.equal(usage(p).length, 0);
56
+ const out = prompt(p, { id: 'TASK_2', title: 'Update prism output', requirements: ['keep it fast'] });
57
+ assert.match(out, /## Project terms\n- Prism: the renderer/);
58
+ assert.doesNotMatch(out, /NGO: non-profit/);
59
+ assert.doesNotMatch(prompt(p, { id: 'TASK_3', title: 'prisms and ngos' }), /## Project terms/, 'whole word only');
60
+ const u = usage(p);
61
+ assert.equal(u.length, 1);
62
+ assert.deepEqual([u[0].task, u[0].terms], ['TASK_2', ['Prism']]);
63
+ assert.match(prompt(p, { id: 'TASK_4', title: 'x', acceptance_criteria: ['NGO flow works'] }), /NGO: non-profit/);
64
+ });
65
+
66
+ test('dispatch: at most 8 entries and 800 bytes; CJK term matches by substring', () => {
67
+ const p = proj();
68
+ const names = Array.from({ length: 12 }, (_, i) => `Term${i}`);
69
+ for (const n of names) g.add(p, n, 'short');
70
+ const r = g.dispatchTerms(p, { id: 'TASK_5', title: names.join(' ') });
71
+ assert.equal(r.terms.length, 8);
72
+ const q = proj();
73
+ for (const n of names) g.add(q, n, 'x'.repeat(200));
74
+ const r2 = g.dispatchTerms(q, { id: 'TASK_6', title: names.join(' ') });
75
+ assert.ok(Buffer.byteLength(r2.lines.join('\n')) <= 800);
76
+ assert.ok(r2.terms.length >= 1 && r2.terms.length < 8);
77
+ const c = proj();
78
+ g.add(c, '通道', '資料傳輸路徑');
79
+ assert.deepEqual(g.dispatchTerms(c, { id: 'TASK_7', description: '修正通道逾時' }).terms, ['通道']);
80
+ });
81
+
82
+ test('stats: terms added, injections and cards in the window', () => {
83
+ const p = proj();
84
+ g.add(p, 'Prism', 'r');
85
+ g.dispatchTerms(p, { id: 'TASK_9', title: 'prism' });
86
+ const s = g.stats(p, Date.now() + 1000, 30);
87
+ assert.deepEqual([s.terms, s.added, s.injections, s.cards], [1, 1, 1, ['TASK_9']]);
88
+ assert.equal(g.stats(p, Date.now() + 1000 + 40 * 86400000, 30).injections, 0);
89
+ });
90
+
91
+ test('cli: kenaz glossary through packages/kenaz/lib/cli.js with a staged core', () => {
92
+ const home = proj();
93
+ const p = proj();
94
+ const tools = path.join(home, '.kenaz', 'core', '.specialist', 'tools');
95
+ fs.mkdirSync(tools, { recursive: true });
96
+ fs.copyFileSync(path.join(ROOT, '.specialist', 'tools', 'glossary.js'), path.join(tools, 'glossary.js'));
97
+ const cli = path.join(ROOT, 'packages', 'kenaz', 'bin', 'kenaz.js');
98
+ const run = (...a) => spawnSync(process.execPath, [cli, 'glossary', ...a, '--project-path', p], { encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home } });
99
+ let r = run('add', 'Lane A', '--means', 'fast path', '--not', 'lane B');
100
+ assert.equal(r.status, 0, r.stdout + r.stderr);
101
+ r = run('list');
102
+ assert.match(r.stdout, /Lane A: fast path \(not lane B\)/);
103
+ assert.match(fs.readFileSync(path.join(p, '.kenaz', 'GLOSSARY.md'), 'utf8'), /## Lane A/);
104
+ assert.equal(run('rm', 'lane a').status, 0);
105
+ });
106
+
107
+ test('same-project dispatch (no projectPath) still injects terms: the Kenaz project around the cwd applies', () => {
108
+ const p = proj();
109
+ fs.mkdirSync(path.join(p, '.kenaz'), { recursive: true });
110
+ fs.writeFileSync(path.join(p, '.kenaz', 'project.yaml'), 'name: t\n');
111
+ g.add(p, 'Prism', 'the replacement network layer');
112
+ fs.mkdirSync(path.join(p, 'src'));
113
+ const was = process.cwd();
114
+ try {
115
+ process.chdir(path.join(p, 'src'));
116
+ const card = { id: 'TASK_9', title: 'Port the Prism handshake' };
117
+ const out = buildPrompt(skill, 'do it', JSON.stringify(card), { isSubagent: true, taskId: card.id });
118
+ assert.match(out, /## Project terms\n- Prism: the replacement network layer/);
119
+ } finally { process.chdir(was); }
120
+ // control: outside any Kenaz project nothing is injected
121
+ const bare = proj();
122
+ try {
123
+ process.chdir(bare);
124
+ assert.doesNotMatch(buildPrompt(skill, 'do it', JSON.stringify({ id: 'TASK_9', title: 'Port the Prism handshake' }), { isSubagent: true, taskId: 'TASK_9' }), /## Project terms/);
125
+ } finally { process.chdir(was); }
126
+ });
@@ -1027,6 +1027,7 @@ async function main() {
1027
1027
  if (landedLine) lines.push(landedLine);
1028
1028
  try { const rea = require('./rea-detect'); const l = rea.reaBannerLine(rea.detectRea({ projectPath: BASE })); if (l) lines.push(l); } catch { /* optional */ }
1029
1029
  try { for (const l of require('./lessons').bannerLines(BASE)) lines.push(l); } catch { /* optional */ }
1030
+ try { for (const l of require('./glossary').bannerLines(BASE)) lines.push(l); } catch { /* optional */ }
1030
1031
  const peerLines = await fetchPendingPeerLines(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : process.env.CLAUDE_PROJECT_DIR || process.cwd());
1031
1032
  for (const l of peerLines) lines.push(l);
1032
1033
  lines.push('(boot 完成:' + (findHandoffDocs(BASE).length ? '先讀 → 標記的 handoff 全文,再' : '') + '依 Open cards 提議程;小修正直接做,中型工作開卡派工)');
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Project glossary (TASK_2428): each project keeps its own terms so agents use its language
5
+ * instead of re-deriving it. Idea adapted from the domain-modeling skill of
6
+ * github.com/mattpocock/skills (MIT license, Matt Pocock).
7
+ *
8
+ * File: <project>/.kenaz/GLOSSARY.md (a root GLOSSARY.md is also read). Format per term:
9
+ * ## <Term>
10
+ * 1-3 lines of meaning
11
+ * Not: <confusable term>
12
+ * See: <ADR/link>
13
+ * Created lazily on the first term. Injected into dispatch prompts only for terms the card mentions.
14
+ * <project>/.kenaz/specialist/glossary-usage.jsonl: one line per injection {ts, task, terms}
15
+ * and per added term {ts, added} (read by kenaz stats).
16
+ *
17
+ * kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>] [--project-path <dir>]
18
+ * kenaz glossary list | rm <term>
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const MAX_TERMS = 60;
24
+ const INJECT_MAX_ENTRIES = 8;
25
+ const INJECT_MAX_BYTES = 800;
26
+
27
+ const fileOf = (p) => path.join(p, '.kenaz', 'GLOSSARY.md');
28
+ const logOf = (p) => path.join(p, '.kenaz', 'specialist', 'glossary-usage.jsonl');
29
+ const key = (t) => String(t).trim().toLowerCase();
30
+
31
+ function parse(text) {
32
+ const terms = [];
33
+ let cur = null;
34
+ for (const line of String(text || '').split(/\r?\n/)) {
35
+ const h = /^##\s+(.+?)\s*$/.exec(line);
36
+ if (h) { cur = { term: h[1], means: [], not: '', see: '' }; terms.push(cur); continue; }
37
+ if (!cur) continue;
38
+ const n = /^Not:\s*(.*)$/.exec(line);
39
+ const s = /^See:\s*(.*)$/.exec(line);
40
+ if (n) cur.not = n[1].trim();
41
+ else if (s) cur.see = s[1].trim();
42
+ else if (line.trim()) cur.means.push(line.trim());
43
+ }
44
+ return terms;
45
+ }
46
+
47
+ function render(terms) {
48
+ return '# Glossary\n\n' + terms.map((t) => `## ${t.term}\n${t.means.join('\n')}\n${t.not ? `Not: ${t.not}\n` : ''}${t.see ? `See: ${t.see}\n` : ''}`).join('\n');
49
+ }
50
+
51
+ function log(projectPath, row) {
52
+ try {
53
+ fs.mkdirSync(path.dirname(logOf(projectPath)), { recursive: true });
54
+ fs.appendFileSync(logOf(projectPath), JSON.stringify(row) + '\n');
55
+ } catch { /* measurement only */ }
56
+ }
57
+
58
+ /** Terms from .kenaz/GLOSSARY.md first, then a root GLOSSARY.md (the .kenaz one wins on a duplicate term). */
59
+ function readTerms(projectPath) {
60
+ const out = [];
61
+ const seen = new Set();
62
+ for (const f of [fileOf(projectPath), path.join(projectPath, 'GLOSSARY.md')]) {
63
+ let txt = '';
64
+ try { txt = fs.readFileSync(f, 'utf8'); } catch { continue; }
65
+ for (const t of parse(txt)) if (!seen.has(key(t.term))) { seen.add(key(t.term)); out.push(t); }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function add(projectPath, term, means, extra = {}) {
71
+ term = String(term || '').trim();
72
+ means = String(means || '').trim();
73
+ if (!term || !means) return { ok: false, message: 'usage: kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>]' };
74
+ let own = [];
75
+ try { own = parse(fs.readFileSync(fileOf(projectPath), 'utf8')); } catch { /* created lazily */ }
76
+ const entry = { term, means: means.split(/\r?\n/).slice(0, 3), not: extra.not || '', see: extra.see || '' };
77
+ const i = own.findIndex((t) => key(t.term) === key(term));
78
+ if (i < 0 && own.length >= MAX_TERMS) return { ok: false, message: `glossary is full (${MAX_TERMS} terms): remove one with \`kenaz glossary rm <term>\` first` };
79
+ if (i >= 0) own[i] = entry; else own.push(entry);
80
+ fs.mkdirSync(path.dirname(fileOf(projectPath)), { recursive: true });
81
+ fs.writeFileSync(fileOf(projectPath), render(own));
82
+ if (i < 0) log(projectPath, { ts: new Date().toISOString(), added: term });
83
+ return { ok: true, replaced: i >= 0, count: own.length };
84
+ }
85
+
86
+ function rm(projectPath, term) {
87
+ let own = [];
88
+ try { own = parse(fs.readFileSync(fileOf(projectPath), 'utf8')); } catch { return { ok: false }; }
89
+ const left = own.filter((t) => key(t.term) !== key(term));
90
+ if (left.length === own.length) return { ok: false };
91
+ fs.writeFileSync(fileOf(projectPath), render(left));
92
+ return { ok: true };
93
+ }
94
+
95
+ const CJK = /[ -鿿가-힯＀-￯]/;
96
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+
98
+ /** Whole word, case-insensitive; CJK terms by substring (no word boundaries in CJK text). */
99
+ function mentions(text, term) {
100
+ if (CJK.test(term)) return text.toLowerCase().includes(term.toLowerCase());
101
+ return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc(term)}(?![\\p{L}\\p{N}_])`, 'iu').test(text);
102
+ }
103
+
104
+ function cardText(card) {
105
+ const list = (v) => (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join('\n') : String(v || ''));
106
+ return [card.title, card.description, list(card.requirements), list(card.acceptance_criteria)].filter(Boolean).join('\n');
107
+ }
108
+
109
+ /** Entries (<= 8, <= 800 bytes) for the glossary terms that occur in the card; logs the injection. */
110
+ function dispatchTerms(projectPath, card, now = Date.now()) {
111
+ const none = { lines: [], terms: [] };
112
+ try {
113
+ const text = cardText(card || {});
114
+ const lines = [];
115
+ const used = [];
116
+ let bytes = 0;
117
+ for (const t of readTerms(projectPath)) {
118
+ if (used.length >= INJECT_MAX_ENTRIES || !mentions(text, t.term)) continue;
119
+ const line = `- ${t.term}: ${t.means.join(' ')}${t.not ? ` (not ${t.not})` : ''}${t.see ? ` [see ${t.see}]` : ''}`;
120
+ if (bytes + Buffer.byteLength(line) + 1 > INJECT_MAX_BYTES) break;
121
+ bytes += Buffer.byteLength(line) + 1;
122
+ lines.push(line);
123
+ used.push(t.term);
124
+ }
125
+ if (!used.length) return none;
126
+ log(projectPath, { ts: new Date(now).toISOString(), task: (card && card.id) || null, terms: used });
127
+ return { lines, terms: used };
128
+ } catch { return none; }
129
+ }
130
+
131
+ function bannerLines(projectPath) {
132
+ const n = readTerms(projectPath).length;
133
+ return n ? [`[glossary] ${n} terms (.kenaz/GLOSSARY.md)`] : [];
134
+ }
135
+
136
+ /** For kenaz stats: terms now, terms added / injections / cards with injections inside the window. */
137
+ function stats(projectPath, now, days) {
138
+ const since = now - days * 86400000;
139
+ const rows = [];
140
+ try {
141
+ for (const l of fs.readFileSync(logOf(projectPath), 'utf8').split('\n')) {
142
+ if (!l.trim()) continue;
143
+ try { const r = JSON.parse(l); if (Date.parse(r.ts) >= since && Date.parse(r.ts) <= now) rows.push(r); } catch { /* skip bad line */ }
144
+ }
145
+ } catch { /* no log yet */ }
146
+ const inj = rows.filter((r) => r.terms);
147
+ return {
148
+ terms: readTerms(projectPath).length,
149
+ added: rows.filter((r) => r.added).length,
150
+ injections: inj.length,
151
+ cards: [...new Set(inj.map((r) => r.task).filter(Boolean))],
152
+ };
153
+ }
154
+
155
+ function main(argv, out = console.log) {
156
+ const args = argv.slice();
157
+ const take = (flag) => { const i = args.indexOf(flag); if (i < 0) return undefined; const v = args[i + 1]; args.splice(i, 2); return v; };
158
+ const projectPath = path.resolve(take('--project-path') || process.cwd());
159
+ const means = take('--means');
160
+ const not = take('--not');
161
+ const see = take('--see');
162
+ const [cmd, ...rest] = args;
163
+ if (cmd === 'add') {
164
+ const r = add(projectPath, rest.join(' '), means, { not, see });
165
+ out(r.ok ? `${r.replaced ? 'replaced' : 'added'} "${rest.join(' ')}" (${r.count} terms)` : r.message);
166
+ return r.ok ? 0 : 1;
167
+ }
168
+ if (cmd === 'rm' && rest.length) {
169
+ const r = rm(projectPath, rest.join(' '));
170
+ out(r.ok ? `removed "${rest.join(' ')}"` : `no such term: ${rest.join(' ')}`);
171
+ return r.ok ? 0 : 1;
172
+ }
173
+ if (cmd === 'list') {
174
+ const t = readTerms(projectPath);
175
+ if (!t.length) out('no glossary terms');
176
+ else for (const x of t) out(`${x.term}: ${x.means.join(' ')}${x.not ? ` (not ${x.not})` : ''}${x.see ? ` [see ${x.see}]` : ''}`);
177
+ return 0;
178
+ }
179
+ out('usage: kenaz glossary <add "<term>" --means "<meaning>" [--not <x>] [--see <ref>] | list | rm <term>> [--project-path <dir>]');
180
+ return 2;
181
+ }
182
+
183
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
184
+ module.exports = { main, add, rm, readTerms, dispatchTerms, bannerLines, stats, parse, mentions };
@@ -168,7 +168,11 @@ function openCount(projectPath, now = Date.now(), o = {}) {
168
168
  try { c = JSON.parse(fs.readFileSync(cacheFile(projectPath), 'utf8')); } catch (_) { /* no cache */ }
169
169
  if (c && now - c.t < CACHE_TTL_MS) return c.n;
170
170
  if (o.sync) return writeCount(projectPath, now);
171
- (o.refresh || ((p) => require('child_process').spawn(process.execPath, [__filename, '--refresh-cache', p], { detached: true, stdio: 'ignore', windowsHide: true }).unref()))(projectPath);
171
+ // No git, no history to scan: never start a background process (its git calls hold the folder open on Windows).
172
+ if (!fs.existsSync(path.join(root(projectPath), '.git'))) return c ? c.n : 0;
173
+ // cwd = home, not the project: on Windows a process whose cwd is the project locks that folder for the ~20 s
174
+ // the scan takes (no delete/rename; broke two boot tests' temp-dir cleanup with EPERM). The path is absolute.
175
+ (o.refresh || ((p) => require('child_process').spawn(process.execPath, [__filename, '--refresh-cache', path.resolve(p)], { cwd: require('os').homedir(), detached: true, stdio: 'ignore', windowsHide: true }).unref()))(projectPath);
172
176
  return c ? c.n : 0;
173
177
  }
174
178
 
@@ -1 +1 @@
1
- 0.3.14
1
+ 0.3.16
@@ -1,52 +1,43 @@
1
1
  ## 專案路徑
2
- ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot 腳本與 task-api / dispatch 呼叫都附加 `--project-path "<PROJECT_PATH>"`。沒有就留空,什麼都不加。
2
+ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot、task-api、dispatch 都附加 `--project-path "<PROJECT_PATH>"`。沒有就不加。
3
3
 
4
4
  # Ansuz boot
5
5
 
6
- 你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,依情境決定自己動手或拆任務派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
-
8
- ## Boot(一次 Bash,無其他儀式)
9
- 1. Turn Context 若有 `[session] session_id=<id>`,記住 `<id>`。
10
- 2. 執行 `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
- 3. banner 有 `📎 Project handoff` 時,**先用 Read 讀 → 標記的那份全文**(不用問用戶),以它為準接續上一個 session。
12
- 4. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),依 handoff 的 R 段與 `📋 Open cards` 直接提出議程。
13
-
14
- ## 每件工作的做法
15
- - **自己做**:小而明確的修正(約 30 行內、單一檔案或少數檔案)→ 直接改,跑最近的測試,提交。
16
- - **開卡派工**:中型、可平行、或高風險路徑的工作:
17
- 1. 用 `kenaz_task_create` 開卡(`projectPath` = 目前專案;寫清楚 requirements、acceptance criteria、scope paths)。查卡、改狀態用 `kenaz_task_list` / `kenaz_task_get` / `kenaz_task_update`。
18
- 2. 產生派工內容:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent`(有 PROJECT_PATH 加 `--project-path`)。把輸出 JSON 的 `promptText` 交給 subagent,`agentParams` 照用(模型不要自己換)。
19
- 3. subagent 在 worktree 裡改完並 commit;你驗證後合併。
20
- 若輸出是 `executor` 區塊(模型為 `codex:<id>` / `deepseek:<model>`,無 agentParams):用 Bash run_in_background 執行 `executor.command`,等完成通知,讀 `executor.resultFile`(ok、commits;`no commits` = 失敗),再照常驗證並合併 `executor.branchName`。
21
- - 開卡前先確認 scope 與驗收條件對準真正的問題,不要照著錯的假設派工。
22
-
23
- ## 驗證(合併前一定做,強度跟風險相稱)
24
- - 走**真實路徑**:實際執行會被使用者用到的那條路(真的安裝、真的啟動、真的收發訊息),不只看單元測試。
25
- - 有**對照組**:證明測試在舊程式上會失敗,或修改前後結果不同。
26
- - subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
27
- - 合併:`git merge` 後跑相關測試(或 `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
28
-
29
- ## 交接(handoff,ISBAR)
30
- 用戶說交接/暫停/換 session、context 快滿、或結束時仍有未完成的工作 → 寫交接:
31
- 1. `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`(有 PROJECT_PATH 加 `--project-path`),建立 `<專案>/.kenaz/handoffs/<日期時間>-<主題>.md`,I 段已自動填好。
32
- 2. 用 Edit 填 S(現況)、B(背景:用戶決定原話、相關卡與 commit)、A(已驗證/未驗證/風險)、R(下一個 session 第一步、待用戶決定的事)。寫給「沒看過這段對話的人」,不寫空泛句。
33
- 3. 一份交接一個檔,不覆寫舊的;下一個 session 的 boot 會自動列出並讀最新那份。
6
+ 你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
7
+
8
+ ## Boot(一次 Bash)
9
+ 1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
10
+ 2. `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
+ 3. banner 有 `📎 Project handoff`:先 Read → 標記的那份全文,以它接續。
12
+ 4. 轉述 banner,依 handoff 的 R 段與 `📋 Open cards` 提出議程。`[lessons]`、`[learn]`、`[glossary]` 行照 RULES.md 的 Lessons / Project glossary 處理。
13
+
14
+ ## 工作方式
15
+ - **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
16
+ - **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
17
+ - 開卡前確認 scope 與驗收條件對準真正的問題。
18
+
19
+ ## 驗證(合併前,強度跟風險相稱)
20
+ - **真實路徑**:實際跑使用者會走的路,不只看單元測試。
21
+ - **對照組**:證明測試在舊程式上會失敗。
22
+ - subagent 的「未驗證」由你補驗或明講。合併後跑相關測試(含被改檔案的既有測試)。
23
+
24
+ ## 交接
25
+ 用戶說交接/暫停、context 快滿、或結束時有未完成工作:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`,再填 S/B/A/R(見 RULES.md 的 Handoff)。
34
26
 
35
27
  ## 回報與提問
36
- - 一律用用戶的語言(中文訊息 → 中文回覆,含提問選項)。
37
- - 需要用戶決定時用 `AskUserQuestion`(2-4 個選項、建議的放第一個),不要在文字裡列選項等回覆;有證據能自己判斷的事不問。
38
- - 回報說清楚:做了什麼、怎麼驗證的、還有什麼沒驗證或需要用戶決定。不說「應該可以」。
28
+ - 用用戶的語言(中文訊息 → 中文,含提問選項)。
29
+ - 需要用戶決定時用 `AskUserQuestion`(2-4 選項、建議的放第一),有證據能自己判斷的不問。
30
+ - 說清楚做了什麼、怎麼驗證、什麼沒驗證或要用戶決定。不說「應該可以」。
39
31
 
40
- ## Kenaz 本身出問題時:先回報再繞過
41
- Kenaz 的工具、hook、安裝、peer、派工擋住或拖慢工作時,**繞過之前**先 `kenaz_wish_create`(`projectPath`;`scope` kenaz|project;`wish_type` bug|feature|question;`content` = 一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰)。一個問題報一次,報完照常完成工作並告訴用戶;用戶說「回報這個問題」也用它(`source`=`user`)。
32
+ ## Kenaz 本身出問題:先回報再繞過
33
+ 繞過之前先 `kenaz_wish_create`(`scope` kenaz|project;`wish_type` bug|feature|question;一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰),報完照常完成工作。
42
34
 
43
35
  ## 自主模式
44
- 用戶明確授權自主時:立刻 `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy。之後結束回合會被 Stop hook 擋回:自己決定、一路做完,不在結尾問「要我…嗎?」;只有必要停點才 `pause`,做完才 `done`。
36
+ 用戶明確授權自主時:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy;只在必要停點 `pause`,做完 `done`。
45
37
 
46
- ## 參考產品(REA)與學習教訓
47
- - banner `[rea]` 行:用戶提到參考產品時,有 REA 就提議分析,沒有就提示一次 `npx rea-agents setup`(範圍見 RULES.md 的 REA)。
48
- - 用戶糾正/重複指示、推翻決定、或驗證抓到失敗時,自動寫教訓並在回報點名一行:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`(規則見 RULES.md 的 Lessons)。
49
- - banner 出現 `[lessons] N to graduate`:跑 `kenaz learn review`,提議把排第一的教訓改成代碼(測試、hook 檢查或專案 CLAUDE.md/AGENTS.md 規則,這是首選結果);糾正若與既有教訓相符屬於重複(recurrence),不是新教訓(`add` 會自動累加)。
50
- - banner 出現 `[learn] N history items to review`:跑 `kenaz learn review`,每個歷史項目(返工、executor 失敗、blocked 卡、handoff 風險行)有現成的 `kenaz learn add ... --source harvest:<id>`,值得記的補上 why 後執行,其餘忽略;指標建議(各 rigor 一次過率、返工最多的檔案)只建議給用戶,不自行套用。詳見 RULES.md。
38
+ ## 教訓、詞彙、參考產品
39
+ - 用戶糾正、推翻決定、或驗證抓到失敗:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`,回報點名一行。
40
+ - 用戶定義或拍板專案術語:`kenaz glossary add "<term>" --means "..."`;問術語前先讀 `.kenaz/GLOSSARY.md`。
41
+ - `[rea]` 行、教訓生命週期與歷程收割:見 RULES.md(REA、Lessons)。
51
42
 
52
- 需要更多規則時(授權自主、派工前、合併前、參考產品、寫教訓)讀 `${CLAUDE_PLUGIN_ROOT}/../../.specialist/Ansuz/RULES.md`。
43
+ 需要更多規則時(自主、派工、合併、交接、教訓、REA)讀 `${CLAUDE_PLUGIN_ROOT}/../../.specialist/Ansuz/RULES.md`。
package/lib/cli.js CHANGED
@@ -14,7 +14,7 @@ const dash = require('./dashboard');
14
14
  const PKG_ROOT = path.resolve(__dirname, '..');
15
15
  const PKG = require('../package.json');
16
16
  const MIN_NODE = 18;
17
- const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg create [--name N]|ygg status|ygg leave|alaya status|alaya register|subagent [set <id>|unset] [--rigor r]|launch --provider deepseek [--model id]|stats [--days N] [--json]|learn <add|list|rm|review|merge|graduate>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
17
+ const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg create [--name N]|ygg status|ygg leave|alaya status|alaya register|subagent [set <id>|unset] [--rigor r]|launch --provider deepseek [--model id]|stats [--days N] [--json]|learn <add|list|rm|review|merge|graduate>|glossary <add|list|rm>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
18
18
 
19
19
  function parseArgs(argv) {
20
20
  const o = { cmd: argv[0], flags: {} };
@@ -35,13 +35,15 @@ function installer() {
35
35
 
36
36
  /** A core module from the installed core, else this checkout; null when neither is there. */
37
37
  function coreModule(p, ...rel) {
38
- for (const f of [path.join(p.core, ...rel), path.join(__dirname, '..', '..', '..', ...rel)]) {
38
+ // installed core, then the core bundled in this npm package (npx before `kenaz install`), then the repo checkout
39
+ for (const f of [path.join(p.core, ...rel), path.join(PKG_ROOT, 'core', ...rel), path.join(__dirname, '..', '..', '..', ...rel)]) {
39
40
  if (fs.existsSync(f)) return require(f);
40
41
  }
41
42
  return null;
42
43
  }
43
44
  const reaDetect = (p) => coreModule(p, '.specialist', 'tools', 'rea-detect.js');
44
45
  const lessons = (p) => coreModule(p, '.specialist', 'tools', 'lessons.js');
46
+ const glossary = (p) => coreModule(p, '.specialist', 'tools', 'glossary.js');
45
47
  const dispatchModel = (p) => coreModule(p, '.formation', 'tools', 'dispatch-model.js');
46
48
  const providerLaunch = (p) => coreModule(p, '.formation', 'bifrost', 'provider-launch.js');
47
49
  const kenazStats = (p) => coreModule(p, '.formation', 'tools', 'kenaz-stats.js');
@@ -356,6 +358,11 @@ function run(argv, out = console.log, deps = {}) {
356
358
  if (!ks) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
357
359
  return ks.main(argv.slice(1), out);
358
360
  }
361
+ if (argv[0] === 'glossary') {
362
+ const gl = glossary(paths(os.homedir()));
363
+ if (!gl) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
364
+ return gl.main(argv.slice(1), out);
365
+ }
359
366
  if (argv[0] === 'learn') {
360
367
  const hi = argv.indexOf('--home');
361
368
  const lm = lessons(paths(hi >= 0 && argv[hi + 1] ? path.resolve(argv[hi + 1]) : os.homedir()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
5
5
  "bin": {
6
6
  "kenaz": "bin/kenaz.js"
@@ -20,11 +20,11 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.14",
24
- "@kenazai/server-darwin-x64": "0.3.14",
25
- "@kenazai/server-linux-arm64": "0.3.14",
26
- "@kenazai/server-linux-x64": "0.3.14",
27
- "@kenazai/server-win32-x64": "0.3.14"
23
+ "@kenazai/server-darwin-arm64": "0.3.16",
24
+ "@kenazai/server-darwin-x64": "0.3.16",
25
+ "@kenazai/server-linux-arm64": "0.3.16",
26
+ "@kenazai/server-linux-x64": "0.3.16",
27
+ "@kenazai/server-win32-x64": "0.3.16"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {