kenaz 0.3.12 → 0.3.14

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.
Files changed (34) hide show
  1. package/core/.claude/commands/Ansuz.md +11 -1
  2. package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
  3. package/core/.claude/hooks/_run-user-prompt.js +2 -0
  4. package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
  5. package/core/.claude/hooks/correction-detect.js +99 -0
  6. package/core/.formation/bifrost/exec.js +162 -0
  7. package/core/.formation/bifrost/provider-launch.js +69 -0
  8. package/core/.formation/bifrost/provider-launch.test.js +75 -0
  9. package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
  10. package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
  11. package/core/.formation/tools/dispatch-model.js +15 -10
  12. package/core/.formation/tools/dispatch.js +46 -10
  13. package/core/.formation/tools/kenaz-stats.js +57 -16
  14. package/core/.formation/tools/kenaz-stats.test.js +2 -2
  15. package/core/.specialist/Ansuz/RULES.md +7 -2
  16. package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
  17. package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
  18. package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
  19. package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
  20. package/core/.specialist/tools/handoff-doc.js +95 -0
  21. package/core/.specialist/tools/lessons-harvest.js +243 -0
  22. package/core/.specialist/tools/lessons-lifecycle.js +103 -0
  23. package/core/.specialist/tools/lessons.js +149 -24
  24. package/core/KENAZ_CORE_VERSION +1 -1
  25. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +1 -1
  26. package/core/dev/scripts/correction-detect-eval.js +70 -0
  27. package/core/plugins/kenaz/commands/Ansuz.md +11 -1
  28. package/core/plugins/kenaz/commands/kenaz.md +5 -3
  29. package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
  30. package/core/plugins/kenaz/scripts/statusline.js +60 -0
  31. package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
  32. package/lib/cli.js +11 -4
  33. package/package.json +6 -6
  34. package/vendor/install-kenaz-cli.js +27 -1
@@ -8,7 +8,8 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
8
8
  ## Boot(一次 Bash,無其他儀式)
9
9
  1. Turn Context 若有 `[session] session_id=<id>`,記住 `<id>`。
10
10
  2. 執行 `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
- 3. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),並依 `📋 Open cards` 直接提出議程。
11
+ 3. banner 有 `📎 Project handoff` 時,**先用 Read 讀 → 標記的那份全文**(不用問用戶),以它為準接續上一個 session。
12
+ 4. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),依 handoff 的 R 段與 `📋 Open cards` 直接提出議程。
12
13
 
13
14
  ## 每件工作的做法
14
15
  - **自己做**:小而明確的修正(約 30 行內、單一檔案或少數檔案)→ 直接改,跑最近的測試,提交。
@@ -16,6 +17,7 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
16
17
  1. 用 `kenaz_task_create` 開卡(`projectPath` = 目前專案;寫清楚 requirements、acceptance criteria、scope paths)。查卡、改狀態用 `kenaz_task_list` / `kenaz_task_get` / `kenaz_task_update`。
17
18
  2. 產生派工內容:`node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent`(有 PROJECT_PATH 加 `--project-path`)。把輸出 JSON 的 `promptText` 交給 subagent,`agentParams` 照用(模型不要自己換)。
18
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`。
19
21
  - 開卡前先確認 scope 與驗收條件對準真正的問題,不要照著錯的假設派工。
20
22
 
21
23
  ## 驗證(合併前一定做,強度跟風險相稱)
@@ -24,6 +26,12 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
24
26
  - subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
25
27
  - 合併:`git merge` 後跑相關測試(或 `node .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
26
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 會自動列出並讀最新那份。
34
+
27
35
  ## 回報與提問
28
36
  - 一律用用戶的語言(中文訊息 → 中文回覆,含提問選項)。
29
37
  - 需要用戶決定時用 `AskUserQuestion`(2-4 個選項、建議的放第一個),不要在文字裡列選項等回覆;有證據能自己判斷的事不問。
@@ -38,5 +46,7 @@ Kenaz 的工具、hook、安裝、peer、派工擋住或拖慢工作時,**繞
38
46
  ## 參考產品(REA)與學習教訓
39
47
  - banner `[rea]` 行:用戶提到參考產品時,有 REA 就提議分析,沒有就提示一次 `npx rea-agents setup`(範圍見 RULES.md 的 REA)。
40
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。
41
51
 
42
52
  需要更多規則時(授權自主、派工前、合併前、參考產品、寫教訓)讀 `.specialist/Ansuz/RULES.md`。
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // TASK_2426: correction detector fires on corrections only, is rate limited per session,
3
+ // and honours the mode gate. Temp project dirs only.
4
+ const test = require('node:test');
5
+ const assert = require('node:assert');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+ const { isCorrection } = require('../correction-detect');
11
+
12
+ const HOOK = path.join(__dirname, '..', 'correction-detect.js');
13
+
14
+ function tmpProject(mode) {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-corr-'));
16
+ fs.mkdirSync(path.join(dir, '.kenaz'), { recursive: true });
17
+ fs.writeFileSync(path.join(dir, '.kenaz', 'project.yaml'), 'name: t\n');
18
+ if (mode) fs.writeFileSync(path.join(dir, '.kenaz', 'kenaz.yaml'), 'mode: ' + mode + '\n');
19
+ return dir;
20
+ }
21
+
22
+ function run(dir, prompt, session, extraEnv) {
23
+ const home = path.join(dir, 'home'); // keep the real ~/.kenaz/kenaz.yaml out of the test
24
+ const r = spawnSync(process.execPath, [HOOK], {
25
+ input: JSON.stringify({ session_id: session || 's1', prompt }),
26
+ encoding: 'utf8',
27
+ env: { ...process.env, CLAUDE_PROJECT_DIR: dir, USERPROFILE: home, HOME: home, KENAZ_MODE: '', ...extraEnv },
28
+ });
29
+ assert.strictEqual(r.status, 0);
30
+ return r.stdout;
31
+ }
32
+
33
+ test('corrections fire (Chinese and English)', () => {
34
+ for (const p of ['我們原本有bifrost', '不是這樣', '錯了,應該是用 lessons.js', '為什麼又壞了', '你還是沒修好',
35
+ 'no, use the other one', "that's wrong", 'we already have a helper for this', 'I told you to use temp dirs', 'still not working']) {
36
+ assert.ok(isCorrection(p), p);
37
+ }
38
+ });
39
+
40
+ test('neutral requests, slash commands and long pasted output do not fire', () => {
41
+ for (const p of ['發版', '然後確認一下還有沒有report', '做 kenaz launch --provider deepseek', 'please add a test',
42
+ '/review 不對', 'x'.repeat(1600) + ' 不是這樣', '<system-reminder>不對</system-reminder>']) {
43
+ assert.ok(!isCorrection(p), p);
44
+ }
45
+ });
46
+
47
+ test('emits one line with the right command per layout', () => {
48
+ const dir = tmpProject();
49
+ const out = run(dir, '不是這樣');
50
+ assert.strictEqual(out.trim().split('\n').length, 1);
51
+ assert.match(out, /kenaz learn add/);
52
+ const core = tmpProject();
53
+ fs.mkdirSync(path.join(core, '.specialist', 'tools'), { recursive: true });
54
+ fs.writeFileSync(path.join(core, '.specialist', 'tools', 'lessons.js'), '');
55
+ assert.match(run(core, '不是這樣'), /node \.specialist\/tools\/lessons\.js add/);
56
+ });
57
+
58
+ test('second correction in the same session within 10 minutes is silent; other session fires', () => {
59
+ const dir = tmpProject();
60
+ assert.ok(run(dir, '不是這樣', 'a'));
61
+ assert.strictEqual(run(dir, '錯了', 'a'), '');
62
+ assert.ok(run(dir, '錯了', 'b'));
63
+ });
64
+
65
+ test('mode off and assist are silent; neutral prompt is silent', () => {
66
+ assert.strictEqual(run(tmpProject('off'), '不是這樣'), '');
67
+ assert.strictEqual(run(tmpProject('assist'), '不是這樣'), '');
68
+ assert.strictEqual(run(tmpProject(), '不是這樣', 's', { KENAZ_MODE: 'off' }), '');
69
+ assert.strictEqual(run(tmpProject(), '發版'), '');
70
+ });
71
+
72
+ test('runner default list includes the hook', () => {
73
+ assert.ok(require('../_run-user-prompt').DEFAULT_HOOKS.includes('correction-detect.js'));
74
+ });
@@ -37,6 +37,8 @@ const DEFAULT_HOOKS = [
37
37
  // TASK_2218: ansuz-session-pin.js is called from turn-context.js (every install layout runs that hook).
38
38
  'turn-context.js',
39
39
  'task-bridge.js',
40
+ // TASK_2426: nudges Ansuz to record a lesson when the user corrects it.
41
+ 'correction-detect.js',
40
42
  ];
41
43
 
42
44
  const HARD_CAP_MS = 10000;
@@ -69,11 +69,11 @@ test('runner replays stdin, traps exits, concatenates stdout, exits 0', () => {
69
69
  assert.ok(elapsed < 5000, 'runner drained promptly, took ' + elapsed + ' ms');
70
70
  });
71
71
 
72
- test('default list is the three UserPromptSubmit hooks in settings order', () => {
72
+ test('default list is the UserPromptSubmit hooks in settings order', () => {
73
73
  // TASK_2218: the pin writer is invoked from turn-context.js, not listed here.
74
74
  const { DEFAULT_HOOKS } = require(RUNNER);
75
75
  assert.deepStrictEqual(DEFAULT_HOOKS, [
76
- 'daemon-outbox-hook.js', 'turn-context.js', 'task-bridge.js',
76
+ 'daemon-outbox-hook.js', 'turn-context.js', 'task-bridge.js', 'correction-detect.js',
77
77
  ]);
78
78
  for (const f of DEFAULT_HOOKS) assert.ok(fs.existsSync(path.join(__dirname, f)), f + ' exists');
79
79
  });
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * correction-detect.js — UserPromptSubmit (TASK_2426)
4
+ * RULES.md says Ansuz records a lesson when the user corrects it; in practice it never did.
5
+ * A deterministic pattern match on the user's prompt turns a correction into a one-line
6
+ * nudge. Silent on any error, mode off/assist, slash commands, pasted output, and when the
7
+ * session was already nudged in the last 10 minutes.
8
+ */
9
+ 'use strict';
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ const MAX_PROMPT_CHARS = 1500;
15
+ const RATE_LIMIT_MS = 10 * 60 * 1000;
16
+
17
+ // Exported, case-insensitive. Kept narrow on purpose (eval target: <= 10% of prompts).
18
+ const CORRECTION_PATTERNS = [
19
+ /不是這樣|不是這個|不是這麼|不是這種|^\s*不是[,,。 ]|不對|不正確|錯了|搞錯|弄錯|理解錯/,
20
+ /我們原本有|我們本來有|原本就有|之前說過|我說過|跟你說過|不是說過|早就說過/,
21
+ /還是沒|還是不(行|對|能)|仍然沒|又(壞|錯|失敗|出錯|沒|忘|漏|搞)|為什麼又|怎麼又|為何又/,
22
+ /(?<!要)不要再|別再|應該是[^嗎吧呢??]{1,40}(才對|而不是)|應該要.{1,30}而不是/,
23
+ /^\s*no[,.!]\s|\bthat'?s (wrong|not (right|correct|what))|\bthat is (wrong|not (right|correct|what))/i,
24
+ /\bwe already (have|had|did)\b|\bi (already )?(told|said)\b|\bi just (told|said)\b|\bdon'?t do that again\b/i,
25
+ /\bwhy (did|do|are) you .{1,40}\bagain\b|\byou (did|made|broke|forgot).{1,40}\bagain\b|\bstill (not|broken|fails?|failing|wrong|doesn'?t)\b/i,
26
+ /\bnot what i (asked|meant|wanted)\b|\b(use|do) .{1,40} instead of\b|\bshould (be|have been) .{1,40}\bnot\b/i,
27
+ ];
28
+
29
+ function isCorrection(prompt) {
30
+ if (typeof prompt !== 'string') return false;
31
+ const p = prompt.trim();
32
+ if (!p || p.length > MAX_PROMPT_CHARS) return false;
33
+ if (p.startsWith('/')) return false;
34
+ if (/^\s*<(system-reminder|command-name|local-command|task-notification)|^\[(Turn Context|[A-Z_]+_CONTEXT)|^## (Role|Formation)\b/.test(p)) return false;
35
+ return CORRECTION_PATTERNS.some((re) => re.test(p));
36
+ }
37
+
38
+ function readYamlMode(file) {
39
+ try {
40
+ const m = fs.readFileSync(file, 'utf8').match(/^\s*mode:\s*['"]?(\w+)['"]?\s*(#.*)?$/m);
41
+ return m ? m[1].toLowerCase() : null;
42
+ } catch (_) { return null; }
43
+ }
44
+
45
+ // Same precedence as plugins/kenaz/hooks/mode.js (not importable: the plugin is not in every layout).
46
+ function resolveMode(dir) {
47
+ const ok = (m) => (m === 'off' || m === 'assist' || m === 'full' ? m : null);
48
+ return ok((process.env.KENAZ_MODE || '').toLowerCase())
49
+ || ok(readYamlMode(path.join(dir, '.kenaz', 'kenaz.yaml')))
50
+ || ok(readYamlMode(path.join(process.env.USERPROFILE || os.homedir(), '.kenaz', 'kenaz.yaml')))
51
+ || 'full';
52
+ }
53
+
54
+ function nudgeLine(dir) {
55
+ const core = fs.existsSync(path.join(dir, '.specialist', 'tools', 'lessons.js'));
56
+ // Same argument form both CLIs accept (--scope is required; project = this repo's pitfalls/checks, user = preferences).
57
+ const args = ' add --scope project|user --type check|fact|pref --text "<one-line lesson>" --source correction-nudge';
58
+ const cmd = (core ? 'node .specialist/tools/lessons.js' : 'kenaz learn') + args;
59
+ return '[NUDGE_CONTEXT: Background system signal, not user input.] This prompt looks like a correction: if it reveals a reusable lesson, record it with `' + cmd + '` (a repeat counts as a recurrence automatically); if it is a one-off, ignore this.';
60
+ }
61
+
62
+ /** True (and records the time) when this session has not been nudged within the window. */
63
+ function takeNudgeSlot(dir, sessionId, now) {
64
+ const file = path.join(dir, '.kenaz', 'specialist', 'correction-nudge.json');
65
+ let state = {};
66
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch (_) { /* first run */ }
67
+ const key = String(sessionId || 'unknown');
68
+ if (now - (Number(state[key]) || 0) < RATE_LIMIT_MS) return false;
69
+ state[key] = now;
70
+ for (const k of Object.keys(state)) if (now - Number(state[k]) > 24 * 3600 * 1000) delete state[k];
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.writeFileSync(file, JSON.stringify(state));
73
+ return true;
74
+ }
75
+
76
+ /** One line per nudge for `kenaz stats` (conversion rate). Time and session only, never the prompt text. */
77
+ function logNudge(dir, sessionId, now) {
78
+ const file = path.join(dir, '.kenaz', 'specialist', 'correction-nudges.jsonl');
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date(now).toISOString(), session_id: sessionId || null }) + '\n');
81
+ }
82
+
83
+ function main() {
84
+ try {
85
+ const raw = fs.readFileSync(0, 'utf8').trim();
86
+ const payload = raw ? JSON.parse(raw) : {};
87
+ if (!isCorrection(payload.prompt)) return;
88
+ const dir = require('./_project-guard').findProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
89
+ if (!dir || resolveMode(dir) !== 'full') return;
90
+ const now = Date.now();
91
+ if (!takeNudgeSlot(dir, payload.session_id, now)) return;
92
+ try { logNudge(dir, payload.session_id, now); } catch (_) { /* the nudge matters more than its log */ }
93
+ process.stdout.write(nudgeLine(dir) + '\n');
94
+ } catch (_) { /* silent */ }
95
+ }
96
+
97
+ module.exports = { CORRECTION_PATTERNS, isCorrection, resolveMode, nudgeLine, takeNudgeSlot, main };
98
+
99
+ if (require.main === module || require.main === undefined) main();
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * Bifrost executors for `kenaz subagent` (TASK_2422): run a dispatched card with a non-Claude model.
5
+ * codex:<id> `codex exec` in the card's worktree (id = an entry of openai/models.json; `codex:` = default)
6
+ * deepseek:<m> `claude -p` in the worktree against DeepSeek's Anthropic-compatible endpoint
7
+ *
8
+ * CLI (dispatch.js prints the exact command):
9
+ * node exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>
10
+ * The prompt goes to the child on stdin (never argv). The DeepSeek key is only in the child's env and
11
+ * is redacted from everything written. The worktree is never removed. Exit 0 = child ok AND new commits.
12
+ */
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { spawn, spawnSync } = require('child_process');
17
+
18
+ // First = default. From GET https://api.deepseek.com/models on 2026-10-11 (V4 ids, 1M context); the V3 ids still
19
+ // answer but are no longer listed. `kenaz launch` re-reads the context window from that endpoint at start.
20
+ const DEEPSEEK_MODELS = ['deepseek-v4-pro', 'deepseek-flash', 'deepseek-chat', 'deepseek-reasoner'];
21
+ const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/anthropic';
22
+ const DEEPSEEK_CONTEXT_TOKENS = { 'deepseek-v4-pro': 1048576, 'deepseek-flash': 1048576, 'deepseek-chat': 128000, 'deepseek-reasoner': 128000 };
23
+ const DEEPSEEK_FAST = 'deepseek-flash'; // Claude Code's small/fast slot (titles, summaries): the cheap model
24
+ const CLAUDE_TOOLS = ['Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep'];
25
+
26
+ const codexSpec = (id) => require('./openai/relay.js').resolveCodexModelSpec(id);
27
+ const codexIds = () => JSON.parse(fs.readFileSync(path.join(__dirname, 'openai', 'models.json'), 'utf8')).models.map((m) => m.id);
28
+
29
+ /** null = not an executor id (a plain Claude id); {adapter, id} for a valid one; throws (message lists the valid ids) otherwise. */
30
+ function parseExecutorModel(raw) {
31
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(String(raw == null ? '' : raw).trim());
32
+ if (!m) return null;
33
+ const adapter = m[1].toLowerCase(); const id = m[2].trim();
34
+ if (adapter === 'codex') {
35
+ if (!codexSpec(id).matched) throw new Error(`unknown codex model '${id}'; valid: ${codexIds().map((i) => 'codex:' + i).join(', ')} (codex: alone = Codex default)`);
36
+ return { adapter, id };
37
+ }
38
+ if (adapter === 'deepseek') {
39
+ if (!DEEPSEEK_MODELS.includes(id)) throw new Error(`unknown deepseek model '${id}'; valid: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
40
+ return { adapter, id };
41
+ }
42
+ throw new Error(`unknown executor prefix '${m[1]}:'; valid prefixes: codex:<id>, deepseek:<model>`);
43
+ }
44
+
45
+ /** DEEPSEEK_API_KEY, else deepseek_api_key in ~/.kenaz/credentials.yaml; null when neither. */
46
+ function deepseekKey(env = process.env, home = os.homedir()) {
47
+ if (env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY.trim()) return env.DEEPSEEK_API_KEY.trim();
48
+ try {
49
+ const m = /^\s*deepseek_api_key\s*:\s*["']?([^"'\r\n#]+?)["']?\s*(?:#.*)?$/m.exec(fs.readFileSync(path.join(home, '.kenaz', 'credentials.yaml'), 'utf8'));
50
+ return m ? m[1].trim() : null;
51
+ } catch { return null; }
52
+ }
53
+
54
+ /** argv + extra env for the child; the prompt is NOT in here (stdin). */
55
+ function buildChild({ adapter, id, worktree, lastFile, key, gitDir }) {
56
+ if (adapter === 'codex') {
57
+ const spec = codexSpec(id);
58
+ const args = ['exec', '-C', worktree];
59
+ if (spec.model) args.push('-m', spec.model);
60
+ args.push('-c', `model_reasoning_effort=${spec.reasoning_effort}`, '--sandbox', 'workspace-write', '-o', lastFile);
61
+ // A worktree's index, refs and objects live in the main repo's .git, outside -C. On Windows the sandbox still
62
+ // refuses those writes with --add-dir (measured 2026-10-11), so run() commits the leftovers after the agent.
63
+ if (gitDir) args.push('--add-dir', gitDir);
64
+ return { bin: 'codex', args, env: {}, unset: [] };
65
+ }
66
+ return { bin: 'claude', args: ['-p', '--allowedTools', ...CLAUDE_TOOLS], env: deepseekEnv(id, key), unset: DEEPSEEK_UNSET };
67
+ }
68
+
69
+ /** Claude Code env for DeepSeek's Anthropic-compatible endpoint (shared by exec.js and `kenaz launch --provider deepseek`).
70
+ * Every model slot points at DeepSeek: background requests (small/fast = haiku) and Agent-tool subagents
71
+ * (sonnet/opus aliases) would otherwise ask DeepSeek for Claude model names it does not serve. */
72
+ function deepseekEnv(id, key, contextTokens) {
73
+ return {
74
+ ANTHROPIC_BASE_URL: DEEPSEEK_BASE_URL, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: id, ANTHROPIC_SMALL_FAST_MODEL: DEEPSEEK_FAST,
75
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: DEEPSEEK_FAST, ANTHROPIC_DEFAULT_SONNET_MODEL: id, ANTHROPIC_DEFAULT_OPUS_MODEL: id, CLAUDE_CODE_SUBAGENT_MODEL: id,
76
+ // Claude Code does not know DeepSeek ids and assumes a 200k window: tell it the real one (V4 = 1M; a 128k
77
+ // value here made a V4 session compact right after the Ansuz boot).
78
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens || DEEPSEEK_CONTEXT_TOKENS[id] || 128000),
79
+ };
80
+ }
81
+
82
+ /** {id: context_window} from GET /models (the live list); null on any failure (callers fall back to the table). */
83
+ async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000) {
84
+ try {
85
+ const r = await fetchFn('https://api.deepseek.com/models', { headers: { Authorization: 'Bearer ' + key }, signal: AbortSignal.timeout(timeoutMs) });
86
+ const j = await r.json();
87
+ const out = {};
88
+ for (const m of (j && j.data) || []) if (m && m.id) out[m.id] = Number(m.context_window) || null;
89
+ return Object.keys(out).length ? out : null;
90
+ } catch { return null; }
91
+ }
92
+ const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
93
+
94
+ const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
95
+ // Same exclusions as the dispatch prompt's commit command (session telemetry never rides along).
96
+ const COMMIT_EXCLUDES = [':!.kenaz/sessions', ':!.kenaz/specialist', ':!.formation/.dispatch-branch', ':!.formation/analytics', ':!.kenaz/channel_heartbeats.jsonl'];
97
+ const EXECUTOR_NOTE = '\n\n## Executor note\nThe executor commits your changes after you finish: a sandbox may refuse git writes in this worktree. ' +
98
+ 'If git add/commit fails, do not retry or work around it; finish the edits and report.\n';
99
+ /** Commit what the agent left uncommitted (a sandboxed Codex cannot write the worktree's .git). @returns sha or null */
100
+ function commitLeftovers(worktree, adapter) {
101
+ if (!(git(worktree, ['status', '--porcelain', '--', '.', ...COMMIT_EXCLUDES]).stdout || '').trim()) return null;
102
+ git(worktree, ['add', '-A', '--', '.', ...COMMIT_EXCLUDES]);
103
+ const branch = (git(worktree, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout || '').trim();
104
+ if (git(worktree, ['commit', '--no-verify', '-m', `[${branch}] ${adapter} executor: agent's changes`]).status !== 0) return null;
105
+ return (git(worktree, ['rev-parse', '--short', 'HEAD']).stdout || '').trim();
106
+ }
107
+ const winQuote = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : '"' + a.replace(/"/g, '\\"') + '"');
108
+
109
+ /** Run the executor; resolves to the result object (also written to resultFile). Never throws for child failures. */
110
+ 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}'`);
112
+ const started = Date.now();
113
+ const key = adapter === 'deepseek' ? deepseekKey(env, home) : null;
114
+ 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));
116
+ const base = (git(worktree, ['rev-parse', 'HEAD']).stdout || '').trim();
117
+ const lastFile = resultFile + '.last.txt';
118
+ const gitDir = (git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout || '').trim();
119
+ const c = buildChild({ adapter, id: model, worktree, lastFile, key, gitDir });
120
+ const childEnv = { ...env, ...c.env };
121
+ for (const k of c.unset) delete childEnv[k];
122
+ const win = process.platform === 'win32';
123
+ const child = spawn(win ? [c.bin, ...c.args].map(winQuote).join(' ') : c.bin, win ? [] : c.args, { cwd: worktree, env: childEnv, shell: win, windowsHide: true });
124
+ let out = '';
125
+ child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { out += d; });
126
+ child.stdin.on('error', () => {});
127
+ child.stdin.end(Buffer.concat([fs.readFileSync(promptFile), Buffer.from(EXECUTOR_NOTE)]));
128
+ const code = await new Promise((res) => { child.on('error', (e) => { out += `spawn error: ${e.message}\n`; res(127); }); child.on('close', (n) => res(n == null ? 1 : n)); });
129
+ let last = '';
130
+ if (adapter === 'codex') { try { last = fs.readFileSync(lastFile, 'utf8'); } catch { /* none */ } } else last = out;
131
+ const executorCommit = commitLeftovers(worktree, adapter);
132
+ const commits = (git(worktree, ['log', '--format=%h %s', base + '..HEAD']).stdout || '').split('\n').filter(Boolean);
133
+ const ok = code === 0 && commits.length > 0;
134
+ const result = {
135
+ adapter, model, ok, exitCode: code, ...(executorCommit ? { executorCommit } : {}), durationMs: Date.now() - started, worktree,
136
+ ...(code === 0 && !commits.length ? { error: 'no commits' } : {}),
137
+ lastMessage: redact(last).slice(-8000), commits, log: redact(out).slice(-20000),
138
+ };
139
+ fs.mkdirSync(path.dirname(resultFile), { recursive: true });
140
+ fs.writeFileSync(resultFile, JSON.stringify(result, null, 2) + '\n');
141
+ return result;
142
+ }
143
+
144
+ async function main(argv) {
145
+ const a = {};
146
+ for (let i = 0; i < argv.length; i++) {
147
+ const m = /^--([a-z-]+)(?:=(.*))?$/.exec(argv[i]);
148
+ if (m) a[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
149
+ }
150
+ 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>');
152
+ return 2;
153
+ }
154
+ try {
155
+ const r = await run({ adapter: a.adapter, model: a.model || '', worktree: a.worktree, promptFile: a['prompt-file'], resultFile: a['result-file'] });
156
+ console.log(`${r.ok ? 'OK' : 'FAILED'} ${a.adapter} exit=${r.exitCode}${r.error ? ' ' + r.error : ''} commits=${r.commits.length} result=${a['result-file']}`);
157
+ return r.ok ? 0 : 1;
158
+ } catch (e) { console.error('exec.js: ' + e.message); return 2; }
159
+ }
160
+
161
+ if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
162
+ module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * `kenaz launch --provider deepseek [--model deepseek-v4-pro|deepseek-flash|...] [claude args...]`
5
+ * Starts Claude Code with every model request sent to another vendor's Anthropic-compatible endpoint, so
6
+ * Ansuz (/kenaz:Ansuz), its subagents and background requests run on that vendor and use no Claude quota.
7
+ * The key is read from the env or ~/.kenaz/credentials.yaml and set only in the claude child's env: never in
8
+ * argv, never in ~/.claude/settings.json. Other terminals keep using Claude as usual.
9
+ */
10
+ const os = require('os');
11
+ const { spawn } = require('child_process');
12
+ const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
13
+
14
+ const PROVIDERS = {
15
+ deepseek: {
16
+ models: DEEPSEEK_MODELS, key: deepseekKey, env: deepseekEnv, live: deepseekModels, unset: DEEPSEEK_UNSET,
17
+ keyHelp: 'set DEEPSEEK_API_KEY, or add `deepseek_api_key: <key>` to ~/.kenaz/credentials.yaml',
18
+ },
19
+ };
20
+ const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id>] [claude args...]';
21
+
22
+ function parse(argv) {
23
+ const rest = []; let provider = null; let model = null;
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const m = /^--(provider|model)(?:=(.*))?$/.exec(argv[i]);
26
+ if (!m) { rest.push(argv[i]); continue; }
27
+ const v = m[2] !== undefined ? m[2] : argv[++i];
28
+ if (m[1] === 'provider') provider = v; else model = v;
29
+ }
30
+ return { rest, provider, model };
31
+ }
32
+
33
+ /** Child process plan: { bin, args, env }. live = {id: context_window} from the vendor's model list (or null):
34
+ * a listed id is valid even if it is newer than the built-in list, and its window drives auto-compact.
35
+ * Throws with a user-facing message. */
36
+ function plan(argv, env = process.env, home = os.homedir(), live = null) {
37
+ const { rest, provider, model } = parse(argv);
38
+ const p = PROVIDERS[provider];
39
+ if (!p) throw new Error((provider ? `unknown provider '${provider}'. ` : '') + USAGE);
40
+ const id = model || p.models[0];
41
+ const valid = [...new Set([...p.models, ...Object.keys(live || {})])];
42
+ if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
43
+ const key = p.key(env, home);
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' };
46
+ for (const k of p.unset) delete childEnv[k];
47
+ return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
48
+ }
49
+
50
+ /** Resolves to the child's exit code. deps: { spawn, env, home, platform } for tests. */
51
+ async function main(argv, out = console.log, deps = {}) {
52
+ const env = deps.env || process.env, home = deps.home || os.homedir();
53
+ const p = PROVIDERS[parse(argv).provider];
54
+ const key = p && p.key(env, home);
55
+ const live = key ? await (deps.live || p.live)(key) : null;
56
+ let pl;
57
+ try { pl = plan(argv, env, home, live); } catch (e) { out(e.message); return 2; }
58
+ out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}, context ${pl.context} tokens); no Claude quota is used in this session`);
59
+ const win = (deps.platform || process.platform) === 'win32'; // claude is a .cmd shim on npm installs
60
+ const child = (deps.spawn || spawn)(win ? [pl.bin, ...pl.args].map(winQuote).join(' ') : pl.bin, win ? [] : pl.args,
61
+ { stdio: 'inherit', env: pl.env, shell: win });
62
+ return new Promise((res) => {
63
+ child.on('error', (e) => { out('kenaz launch: cannot start claude: ' + e.message); res(127); });
64
+ child.on('close', (n) => res(n == null ? 1 : n));
65
+ });
66
+ }
67
+
68
+ if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
69
+ module.exports = { plan, main, PROVIDERS };
@@ -0,0 +1,75 @@
1
+ // kenaz launch --provider deepseek (provider-launch.js): fake spawn and temp homes only, never the real claude.
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { EventEmitter } = require('events');
8
+ const { main, plan } = require('./provider-launch');
9
+
10
+ const KEY = 'sk-test-deepseek-0123456789';
11
+ const home = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-launch-'));
12
+ function fakeSpawn(calls, code = 0) {
13
+ return (cmd, args, opts) => { calls.push({ cmd, args, opts }); const c = new EventEmitter(); setImmediate(() => c.emit('close', code)); return c; };
14
+ }
15
+
16
+ test('deepseek: every model slot points at DeepSeek, key only in the child env, Anthropic key removed', async () => {
17
+ const calls = [], lines = [];
18
+ const rc = await main(['--provider', 'deepseek', '--resume', 'abc'], (l) => lines.push(l),
19
+ { spawn: fakeSpawn(calls), live: async () => null, env: { DEEPSEEK_API_KEY: KEY, ANTHROPIC_API_KEY: 'real-anthropic', PATH: 'p' }, home: home(), platform: 'linux' });
20
+ assert.strictEqual(rc, 0);
21
+ const c = calls[0];
22
+ assert.strictEqual(c.cmd, 'claude');
23
+ assert.deepStrictEqual(c.args, ['--resume', 'abc'], 'other args pass through to claude');
24
+ const e = c.opts.env;
25
+ assert.strictEqual(e.ANTHROPIC_BASE_URL, 'https://api.deepseek.com/anthropic');
26
+ assert.strictEqual(e.ANTHROPIC_AUTH_TOKEN, KEY);
27
+ for (const k of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
28
+ assert.strictEqual(e[k], 'deepseek-v4-pro', k);
29
+ }
30
+ assert.strictEqual(e.ANTHROPIC_SMALL_FAST_MODEL, 'deepseek-flash'); assert.strictEqual(e.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'deepseek-flash');
31
+ assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
32
+ assert.strictEqual(e.ANTHROPIC_API_KEY, undefined);
33
+ assert.strictEqual(e.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1');
34
+ assert.strictEqual(e.PATH, 'p', 'the rest of the env is kept');
35
+ assert.ok(!JSON.stringify(c.args).includes(KEY) && !lines.join('\n').includes(KEY), 'key never in argv or output');
36
+ });
37
+
38
+ test('--model, key from ~/.kenaz/credentials.yaml, Windows goes through the shell', async () => {
39
+ const h = home(); fs.mkdirSync(path.join(h, '.kenaz'));
40
+ fs.writeFileSync(path.join(h, '.kenaz', 'credentials.yaml'), `relay: x\ndeepseek_api_key: "${KEY}"\n`);
41
+ const calls = [];
42
+ const rc = await main(['--provider=deepseek', '--model', 'deepseek-reasoner'], () => {}, { spawn: fakeSpawn(calls), live: async () => null, env: {}, home: h, platform: 'win32' });
43
+ assert.strictEqual(rc, 0);
44
+ assert.strictEqual(calls[0].cmd, 'claude');
45
+ assert.strictEqual(calls[0].opts.shell, true);
46
+ assert.strictEqual(calls[0].opts.env.ANTHROPIC_MODEL, 'deepseek-reasoner');
47
+ assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '128000', 'legacy V3 id keeps its 128k window');
48
+ assert.ok(!calls[0].cmd.includes(KEY));
49
+ });
50
+
51
+ test('control: no key, unknown provider or model -> exit 2 and claude is never started', async () => {
52
+ for (const [argv, env, re] of [
53
+ [['--provider', 'deepseek'], {}, /no API key/],
54
+ [['--provider', 'nope'], { DEEPSEEK_API_KEY: KEY }, /unknown provider 'nope'/],
55
+ [[], { DEEPSEEK_API_KEY: KEY }, /usage: kenaz launch --provider <deepseek>/],
56
+ [['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-v4-pro, deepseek-flash, deepseek-chat, deepseek-reasoner/],
57
+ ]) {
58
+ const calls = [], lines = [];
59
+ const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env, home: home(), platform: 'linux' });
60
+ assert.strictEqual(rc, 2, argv.join(' '));
61
+ assert.strictEqual(calls.length, 0, 'claude not started: ' + argv.join(' '));
62
+ assert.match(lines.join('\n'), re);
63
+ }
64
+ assert.throws(() => plan(['--provider', 'deepseek'], {}, home()), /no API key/);
65
+ });
66
+
67
+ test('live model list: a newer listed id is accepted and its context window drives auto-compact', async () => {
68
+ const calls = [], lines = [];
69
+ const live = async (k) => { assert.strictEqual(k, KEY); return { 'deepseek-v5-preview': 2097152, 'deepseek-v4-pro': 1048576 }; };
70
+ const rc = await main(['--provider', 'deepseek', '--model', 'deepseek-v5-preview'], (l) => lines.push(l), { spawn: fakeSpawn(calls), live, env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' });
71
+ assert.strictEqual(rc, 0);
72
+ assert.strictEqual(calls[0].opts.env.ANTHROPIC_MODEL, 'deepseek-v5-preview');
73
+ assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '2097152');
74
+ assert.match(lines[0], /context 2097152 tokens/);
75
+ });
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // `kenaz model` (dispatch-model.js): temp project dirs only.
2
+ // `kenaz subagent` (dispatch-model.js): temp project dirs only.
3
3
  const { test } = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
5
  const fs = require('node:fs');