kenaz 0.3.12 → 0.3.13

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 +147 -0
  7. package/core/.formation/bifrost/provider-launch.js +57 -0
  8. package/core/.formation/bifrost/provider-launch.test.js +62 -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,147 @@
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
+ const DEEPSEEK_MODELS = ['deepseek-chat', 'deepseek-reasoner'];
19
+ const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/anthropic';
20
+ const DEEPSEEK_CONTEXT_TOKENS = 128000;
21
+ const CLAUDE_TOOLS = ['Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep'];
22
+
23
+ const codexSpec = (id) => require('./openai/relay.js').resolveCodexModelSpec(id);
24
+ const codexIds = () => JSON.parse(fs.readFileSync(path.join(__dirname, 'openai', 'models.json'), 'utf8')).models.map((m) => m.id);
25
+
26
+ /** null = not an executor id (a plain Claude id); {adapter, id} for a valid one; throws (message lists the valid ids) otherwise. */
27
+ function parseExecutorModel(raw) {
28
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(String(raw == null ? '' : raw).trim());
29
+ if (!m) return null;
30
+ const adapter = m[1].toLowerCase(); const id = m[2].trim();
31
+ if (adapter === 'codex') {
32
+ if (!codexSpec(id).matched) throw new Error(`unknown codex model '${id}'; valid: ${codexIds().map((i) => 'codex:' + i).join(', ')} (codex: alone = Codex default)`);
33
+ return { adapter, id };
34
+ }
35
+ if (adapter === 'deepseek') {
36
+ if (!DEEPSEEK_MODELS.includes(id)) throw new Error(`unknown deepseek model '${id}'; valid: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
37
+ return { adapter, id };
38
+ }
39
+ throw new Error(`unknown executor prefix '${m[1]}:'; valid prefixes: codex:<id>, deepseek:<model>`);
40
+ }
41
+
42
+ /** DEEPSEEK_API_KEY, else deepseek_api_key in ~/.kenaz/credentials.yaml; null when neither. */
43
+ function deepseekKey(env = process.env, home = os.homedir()) {
44
+ if (env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY.trim()) return env.DEEPSEEK_API_KEY.trim();
45
+ try {
46
+ const m = /^\s*deepseek_api_key\s*:\s*["']?([^"'\r\n#]+?)["']?\s*(?:#.*)?$/m.exec(fs.readFileSync(path.join(home, '.kenaz', 'credentials.yaml'), 'utf8'));
47
+ return m ? m[1].trim() : null;
48
+ } catch { return null; }
49
+ }
50
+
51
+ /** argv + extra env for the child; the prompt is NOT in here (stdin). */
52
+ function buildChild({ adapter, id, worktree, lastFile, key, gitDir }) {
53
+ if (adapter === 'codex') {
54
+ const spec = codexSpec(id);
55
+ const args = ['exec', '-C', worktree];
56
+ if (spec.model) args.push('-m', spec.model);
57
+ args.push('-c', `model_reasoning_effort=${spec.reasoning_effort}`, '--sandbox', 'workspace-write', '-o', lastFile);
58
+ // A worktree's index, refs and objects live in the main repo's .git, outside -C. On Windows the sandbox still
59
+ // refuses those writes with --add-dir (measured 2026-10-11), so run() commits the leftovers after the agent.
60
+ if (gitDir) args.push('--add-dir', gitDir);
61
+ return { bin: 'codex', args, env: {}, unset: [] };
62
+ }
63
+ return { bin: 'claude', args: ['-p', '--allowedTools', ...CLAUDE_TOOLS], env: deepseekEnv(id, key), unset: DEEPSEEK_UNSET };
64
+ }
65
+
66
+ /** Claude Code env for DeepSeek's Anthropic-compatible endpoint (shared by exec.js and `kenaz launch --provider deepseek`).
67
+ * Every model slot points at DeepSeek: background requests (small/fast = haiku) and Agent-tool subagents
68
+ * (sonnet/opus aliases) would otherwise ask DeepSeek for Claude model names it does not serve. */
69
+ function deepseekEnv(id, key) {
70
+ return {
71
+ ANTHROPIC_BASE_URL: DEEPSEEK_BASE_URL, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: id, ANTHROPIC_SMALL_FAST_MODEL: id,
72
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: id, ANTHROPIC_DEFAULT_SONNET_MODEL: id, ANTHROPIC_DEFAULT_OPUS_MODEL: id, CLAUDE_CODE_SUBAGENT_MODEL: id,
73
+ // Claude Code does not know DeepSeek ids and assumes a 200k window; DeepSeek serves 128k, so auto-compact must start earlier.
74
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(DEEPSEEK_CONTEXT_TOKENS),
75
+ };
76
+ }
77
+ const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
78
+
79
+ const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
80
+ // Same exclusions as the dispatch prompt's commit command (session telemetry never rides along).
81
+ const COMMIT_EXCLUDES = [':!.kenaz/sessions', ':!.kenaz/specialist', ':!.formation/.dispatch-branch', ':!.formation/analytics', ':!.kenaz/channel_heartbeats.jsonl'];
82
+ const EXECUTOR_NOTE = '\n\n## Executor note\nThe executor commits your changes after you finish: a sandbox may refuse git writes in this worktree. ' +
83
+ 'If git add/commit fails, do not retry or work around it; finish the edits and report.\n';
84
+ /** Commit what the agent left uncommitted (a sandboxed Codex cannot write the worktree's .git). @returns sha or null */
85
+ function commitLeftovers(worktree, adapter) {
86
+ if (!(git(worktree, ['status', '--porcelain', '--', '.', ...COMMIT_EXCLUDES]).stdout || '').trim()) return null;
87
+ git(worktree, ['add', '-A', '--', '.', ...COMMIT_EXCLUDES]);
88
+ const branch = (git(worktree, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout || '').trim();
89
+ if (git(worktree, ['commit', '--no-verify', '-m', `[${branch}] ${adapter} executor: agent's changes`]).status !== 0) return null;
90
+ return (git(worktree, ['rev-parse', '--short', 'HEAD']).stdout || '').trim();
91
+ }
92
+ const winQuote = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : '"' + a.replace(/"/g, '\\"') + '"');
93
+
94
+ /** Run the executor; resolves to the result object (also written to resultFile). Never throws for child failures. */
95
+ async function run({ adapter, model = '', worktree, promptFile, resultFile, env = process.env, home = os.homedir() }) {
96
+ if (!['codex', 'deepseek'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
97
+ const started = Date.now();
98
+ const key = adapter === 'deepseek' ? deepseekKey(env, home) : null;
99
+ if (adapter === 'deepseek' && !key) throw new Error('deepseek: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
100
+ const redact = (s) => (key ? String(s).split(key).join('***') : String(s));
101
+ const base = (git(worktree, ['rev-parse', 'HEAD']).stdout || '').trim();
102
+ const lastFile = resultFile + '.last.txt';
103
+ const gitDir = (git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout || '').trim();
104
+ const c = buildChild({ adapter, id: model, worktree, lastFile, key, gitDir });
105
+ const childEnv = { ...env, ...c.env };
106
+ for (const k of c.unset) delete childEnv[k];
107
+ const win = process.platform === 'win32';
108
+ const child = spawn(win ? [c.bin, ...c.args].map(winQuote).join(' ') : c.bin, win ? [] : c.args, { cwd: worktree, env: childEnv, shell: win, windowsHide: true });
109
+ let out = '';
110
+ child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { out += d; });
111
+ child.stdin.on('error', () => {});
112
+ child.stdin.end(Buffer.concat([fs.readFileSync(promptFile), Buffer.from(EXECUTOR_NOTE)]));
113
+ 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)); });
114
+ let last = '';
115
+ if (adapter === 'codex') { try { last = fs.readFileSync(lastFile, 'utf8'); } catch { /* none */ } } else last = out;
116
+ const executorCommit = commitLeftovers(worktree, adapter);
117
+ const commits = (git(worktree, ['log', '--format=%h %s', base + '..HEAD']).stdout || '').split('\n').filter(Boolean);
118
+ const ok = code === 0 && commits.length > 0;
119
+ const result = {
120
+ adapter, model, ok, exitCode: code, ...(executorCommit ? { executorCommit } : {}), durationMs: Date.now() - started, worktree,
121
+ ...(code === 0 && !commits.length ? { error: 'no commits' } : {}),
122
+ lastMessage: redact(last).slice(-8000), commits, log: redact(out).slice(-20000),
123
+ };
124
+ fs.mkdirSync(path.dirname(resultFile), { recursive: true });
125
+ fs.writeFileSync(resultFile, JSON.stringify(result, null, 2) + '\n');
126
+ return result;
127
+ }
128
+
129
+ async function main(argv) {
130
+ const a = {};
131
+ for (let i = 0; i < argv.length; i++) {
132
+ const m = /^--([a-z-]+)(?:=(.*))?$/.exec(argv[i]);
133
+ if (m) a[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
134
+ }
135
+ if (!a.adapter || !a.worktree || !a['prompt-file'] || !a['result-file']) {
136
+ console.error('usage: exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
137
+ return 2;
138
+ }
139
+ try {
140
+ const r = await run({ adapter: a.adapter, model: a.model || '', worktree: a.worktree, promptFile: a['prompt-file'], resultFile: a['result-file'] });
141
+ console.log(`${r.ok ? 'OK' : 'FAILED'} ${a.adapter} exit=${r.exitCode}${r.error ? ' ' + r.error : ''} commits=${r.commits.length} result=${a['result-file']}`);
142
+ return r.ok ? 0 : 1;
143
+ } catch (e) { console.error('exec.js: ' + e.message); return 2; }
144
+ }
145
+
146
+ if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
147
+ module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * `kenaz launch --provider deepseek [--model deepseek-chat|deepseek-reasoner] [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, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
13
+
14
+ const PROVIDERS = {
15
+ deepseek: {
16
+ models: DEEPSEEK_MODELS, key: deepseekKey, env: deepseekEnv, 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
+ /** Child process plan: { bin, args, env }. Throws with a user-facing message. */
23
+ function plan(argv, env = process.env, home = os.homedir()) {
24
+ const rest = []; let provider = null; let model = null;
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const m = /^--(provider|model)(?:=(.*))?$/.exec(argv[i]);
27
+ if (!m) { rest.push(argv[i]); continue; }
28
+ const v = m[2] !== undefined ? m[2] : argv[++i];
29
+ if (m[1] === 'provider') provider = v; else model = v;
30
+ }
31
+ const p = PROVIDERS[provider];
32
+ if (!p) throw new Error((provider ? `unknown provider '${provider}'. ` : '') + USAGE);
33
+ const id = model || p.models[0];
34
+ if (!p.models.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${p.models.join(', ')}`);
35
+ const key = p.key(env, home);
36
+ if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
37
+ const childEnv = { ...env, ...p.env(id, key), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
38
+ for (const k of p.unset) delete childEnv[k];
39
+ return { bin: 'claude', args: rest, env: childEnv, provider, model: id };
40
+ }
41
+
42
+ /** Resolves to the child's exit code. deps: { spawn, env, home, platform } for tests. */
43
+ function main(argv, out = console.log, deps = {}) {
44
+ let pl;
45
+ try { pl = plan(argv, deps.env || process.env, deps.home || os.homedir()); } catch (e) { out(e.message); return Promise.resolve(2); }
46
+ out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}); no Claude quota is used in this session`);
47
+ const win = (deps.platform || process.platform) === 'win32'; // claude is a .cmd shim on npm installs
48
+ const child = (deps.spawn || spawn)(win ? [pl.bin, ...pl.args].map(winQuote).join(' ') : pl.bin, win ? [] : pl.args,
49
+ { stdio: 'inherit', env: pl.env, shell: win });
50
+ return new Promise((res) => {
51
+ child.on('error', (e) => { out('kenaz launch: cannot start claude: ' + e.message); res(127); });
52
+ child.on('close', (n) => res(n == null ? 1 : n));
53
+ });
54
+ }
55
+
56
+ if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
57
+ module.exports = { plan, main, PROVIDERS };
@@ -0,0 +1,62 @@
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), 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_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
28
+ assert.strictEqual(e[k], 'deepseek-chat', k);
29
+ }
30
+ assert.strictEqual(e.ANTHROPIC_API_KEY, undefined);
31
+ assert.strictEqual(e.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1');
32
+ assert.strictEqual(e.PATH, 'p', 'the rest of the env is kept');
33
+ assert.ok(!JSON.stringify(c.args).includes(KEY) && !lines.join('\n').includes(KEY), 'key never in argv or output');
34
+ });
35
+
36
+ test('--model, key from ~/.kenaz/credentials.yaml, Windows goes through the shell', async () => {
37
+ const h = home(); fs.mkdirSync(path.join(h, '.kenaz'));
38
+ fs.writeFileSync(path.join(h, '.kenaz', 'credentials.yaml'), `relay: x\ndeepseek_api_key: "${KEY}"\n`);
39
+ const calls = [];
40
+ const rc = await main(['--provider=deepseek', '--model', 'deepseek-reasoner'], () => {}, { spawn: fakeSpawn(calls), env: {}, home: h, platform: 'win32' });
41
+ assert.strictEqual(rc, 0);
42
+ assert.strictEqual(calls[0].cmd, 'claude');
43
+ assert.strictEqual(calls[0].opts.shell, true);
44
+ assert.strictEqual(calls[0].opts.env.ANTHROPIC_MODEL, 'deepseek-reasoner');
45
+ assert.ok(!calls[0].cmd.includes(KEY));
46
+ });
47
+
48
+ test('control: no key, unknown provider or model -> exit 2 and claude is never started', async () => {
49
+ for (const [argv, env, re] of [
50
+ [['--provider', 'deepseek'], {}, /no API key/],
51
+ [['--provider', 'nope'], { DEEPSEEK_API_KEY: KEY }, /unknown provider 'nope'/],
52
+ [[], { DEEPSEEK_API_KEY: KEY }, /usage: kenaz launch --provider <deepseek>/],
53
+ [['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-chat, deepseek-reasoner/],
54
+ ]) {
55
+ const calls = [], lines = [];
56
+ const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), env, home: home(), platform: 'linux' });
57
+ assert.strictEqual(rc, 2, argv.join(' '));
58
+ assert.strictEqual(calls.length, 0, 'claude not started: ' + argv.join(' '));
59
+ assert.match(lines.join('\n'), re);
60
+ }
61
+ assert.throws(() => plan(['--provider', 'deepseek'], {}, home()), /no API key/);
62
+ });
@@ -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');