kenaz 0.3.11 → 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 (60) 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/.collaboration/core/task-2288-followups.test.js +2 -37
  7. package/core/.collaboration/core/task-2421-replica.test.js +229 -0
  8. package/core/.collaboration/core/task-api.js +221 -12
  9. package/core/.collaboration/core/task-replica.js +189 -0
  10. package/core/.formation/.merge-lock +1 -0
  11. package/core/.formation/bifrost/codex-doctor.js +12 -2
  12. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  13. package/core/.formation/bifrost/exec.js +147 -0
  14. package/core/.formation/bifrost/provider-launch.js +57 -0
  15. package/core/.formation/bifrost/provider-launch.test.js +62 -0
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
  18. package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
  19. package/core/.formation/tools/dispatch-model.js +15 -10
  20. package/core/.formation/tools/dispatch.js +46 -10
  21. package/core/.formation/tools/kenaz-stats.js +57 -16
  22. package/core/.formation/tools/kenaz-stats.test.js +2 -2
  23. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  24. package/core/.formation/tools/post-complete.js +129 -8
  25. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  26. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  27. package/core/.specialist/Ansuz/RULES.md +7 -2
  28. package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
  29. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  30. package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
  31. package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
  32. package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
  33. package/core/.specialist/tools/handoff-doc.js +95 -0
  34. package/core/.specialist/tools/lessons-harvest.js +243 -0
  35. package/core/.specialist/tools/lessons-lifecycle.js +103 -0
  36. package/core/.specialist/tools/lessons.js +149 -24
  37. package/core/.specialist/tools/rea-detect.js +6 -2
  38. package/core/.synapse/memory-shared.json.sync +1 -0
  39. package/core/KENAZ_CORE_VERSION +1 -1
  40. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
  41. package/core/dev/scripts/correction-detect-eval.js +70 -0
  42. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  43. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  44. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  45. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  46. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  47. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  48. package/core/plugins/kenaz/commands/Ansuz.md +11 -1
  49. package/core/plugins/kenaz/commands/kenaz.md +5 -3
  50. package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
  51. package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
  52. package/core/plugins/kenaz/scripts/statusline.js +60 -0
  53. package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
  54. package/core/plugins/kenaz/scripts/ygg.js +43 -4
  55. package/lib/cli.js +27 -10
  56. package/lib/cli.test.js +10 -0
  57. package/lib/dashboard.test.js +3 -3
  58. package/package.json +6 -6
  59. package/vendor/install-kenaz-cli.js +193 -16
  60. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
@@ -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
+ });
@@ -225,7 +225,7 @@ function uninstall(options = {}) {
225
225
  for (let n = 0; n < segs.length; n++) {
226
226
  const [h, body] = segs[n], nx = segs[n + 1];
227
227
  if (h && /^\s*\[\[hooks\.\w+\]\]\s*$/.test(h) && nx && nx[0] && /^\s*\[\[hooks\.\w+\.hooks\]\]/.test(nx[0]) &&
228
- nx[1].join('\n').split('\\').join('/').includes(HOOK_SHIM_REL)) { n++; res.hooks = 'removed'; continue; }
228
+ isKenazHandler(nx)) { n++; res.hooks = 'removed'; continue; } // TASK_2418: any checkout's shim, same identity rule as missingHookEvents
229
229
  out.push(...body.filter((l) => !/^# kenaz-dashboard native hooks/.test(l)));
230
230
  }
231
231
  if (res.hooks === 'removed' && !dryRun) fs.writeFileSync(cfg, out.join('\n').replace(/\n{3,}/g, '\n\n'), 'utf8');
@@ -283,23 +283,82 @@ function getHookShimPath(_projectRoot) {
283
283
  return path.resolve(__dirname, '..', '..', HOOK_SHIM_REL);
284
284
  }
285
285
 
286
+ // TASK_2418: what counts as a kenaz hook, and what counts as a CURRENT one.
287
+ // A handler is kenaz's when its command runs codex-hook-shim.js (any checkout). It is current only when it
288
+ // runs exactly this core's shim (absolute path, slashes normalized, Windows case-insensitive) and the
289
+ // UserPromptSubmit one carries --pull (TASK_2379). Stale ones are replaced in place, never duplicated; handlers
290
+ // that do not run our shim are never touched.
291
+ const HOOK_SHIM_NAME = path.posix.basename(HOOK_SHIM_REL);
292
+ const HOOK_MARKER = /^# kenaz-dashboard native hooks/;
293
+ const HANDLER_HEADER = /^\s*\[\[hooks\.(\w+)\.hooks\]\]\s*$/;
294
+ const GROUP_HEADER = /^\s*\[\[hooks\.(\w+)\]\]\s*$/;
295
+ const slashes = (s) => String(s).replace(/\\/g, '/');
296
+ const foldCase = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
297
+
298
+ function parseSegments(text) { // [header line | null, body lines]
299
+ const segs = [];
300
+ for (const line of text.split(/\r?\n/)) {
301
+ if (/^\s*\[/.test(line) || !segs.length) segs.push([/^\s*\[/.test(line) ? line : null, [line]]);
302
+ else segs[segs.length - 1][1].push(line);
303
+ }
304
+ return segs;
305
+ }
306
+ const isKenazHandler = (seg) => slashes(seg[1].join('\n')).includes(HOOK_SHIM_NAME);
307
+ function isCurrentHandler(ev, seg, projectRoot) {
308
+ const m = slashes(seg[1].join('\n')).match(/^\s*command\s*=\s*(.*)$/m);
309
+ if (!m) return false;
310
+ const cmd = foldCase(m[1]);
311
+ const shim = foldCase(slashes(getHookShimPath(projectRoot)));
312
+ const at = cmd.indexOf('"' + shim + '"');
313
+ if (at < 0) return false;
314
+ return ev !== 'UserPromptSubmit' || /(^|\s)--pull(\s|['"]|$)/.test(cmd.slice(at + shim.length + 2));
315
+ }
316
+ /** Per event: segment indexes of kenaz handlers, split into current / stale. */
317
+ function scanKenazHooks(text, projectRoot) {
318
+ const segs = parseSegments(text), found = {};
319
+ segs.forEach((seg, i) => {
320
+ const m = seg[0] && seg[0].match(HANDLER_HEADER);
321
+ if (!m || !HOOK_EVENTS.includes(m[1]) || !isKenazHandler(seg)) return;
322
+ const f = found[m[1]] || (found[m[1]] = { current: [], stale: [] });
323
+ (isCurrentHandler(m[1], seg, projectRoot) ? f.current : f.stale).push(i);
324
+ });
325
+ return { segs, found };
326
+ }
327
+ /** config text without the kenaz handlers of `events` (and the empty group each one leaves behind). */
328
+ function stripKenazHooks(text, events, projectRoot) {
329
+ const { segs, found } = scanKenazHooks(text, projectRoot);
330
+ const drop = new Set();
331
+ for (const ev of events) {
332
+ for (const i of [...(found[ev] ? found[ev].current : []), ...(found[ev] ? found[ev].stale : [])]) {
333
+ drop.add(i);
334
+ const prev = segs[i - 1], next = segs[i + 1];
335
+ const g = prev && prev[0] && prev[0].match(GROUP_HEADER);
336
+ if (g && g[1] === ev && !(next && next[0] && HANDLER_HEADER.test(next[0]) && next[0].includes('.' + ev + '.'))
337
+ && prev[1].slice(1).every((l) => !l.trim())) drop.add(i - 1);
338
+ }
339
+ }
340
+ const out = [];
341
+ segs.forEach((seg, i) => {
342
+ if (!drop.has(i)) { out.push(...seg[1]); return; }
343
+ out.push(...seg[1].slice(1).filter((l) => /^\s*#/.test(l) && !HOOK_MARKER.test(l))); // keep neighbours' comments
344
+ });
345
+ return out.join('\n');
346
+ }
347
+
286
348
  function areHooksAlreadyRegistered(projectRoot) {
287
349
  const configPath = getCodexConfigPath();
288
350
  if (!fs.existsSync(configPath)) return false;
289
- const content = fs.readFileSync(configPath, 'utf8');
290
- const shimPath = getHookShimPath(projectRoot);
291
- // Registered if config contains our shim path in any hooks entry
292
- return content.includes(HOOK_SHIM_REL) || content.includes(shimPath.split('\\').join('/'));
351
+ // at least one CURRENT kenaz handler: a block left by an old checkout does not run this core's shim
352
+ const { found } = scanKenazHooks(fs.readFileSync(configPath, 'utf8'), projectRoot);
353
+ return Object.values(found).some((f) => f.current.length > 0);
293
354
  }
294
355
 
295
- // TASK_2299: HOOK_EVENTS that have no [[hooks.EV.hooks]] block pointing at our shim in config.toml.
296
- function missingHookEvents() {
356
+ // TASK_2299: HOOK_EVENTS without exactly one current kenaz handler. TASK_2418: stale (old checkout path,
357
+ // UserPromptSubmit without --pull) and duplicated ones count as missing, so registerHooks rewrites them.
358
+ function missingHookEvents(projectRoot) {
297
359
  const configPath = getCodexConfigPath();
298
- const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8').replace(/\\/g, '/') : '';
299
- return HOOK_EVENTS.filter((ev) => {
300
- const blocks = content.match(new RegExp('\\[\\[hooks\\.' + ev + '\\.hooks\\]\\][^\\[]*', 'g')) || [];
301
- return !blocks.some((blk) => blk.includes(HOOK_SHIM_REL));
302
- });
360
+ const { found } = scanKenazHooks(fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '', projectRoot);
361
+ return HOOK_EVENTS.filter((ev) => !found[ev] || found[ev].stale.length > 0 || found[ev].current.length !== 1);
303
362
  }
304
363
 
305
364
  // True when the codex CLI both supports hooks AND our shim is registered.
@@ -313,6 +372,7 @@ function nativeHooksActive(projectRoot) {
313
372
  function registerHooks(log, projectRoot, events) {
314
373
  const shimPath = getHookShimPath(projectRoot).split('\\').join('/');
315
374
  const configPath = getCodexConfigPath();
375
+ const evs = events || HOOK_EVENTS;
316
376
 
317
377
  // Ensure ~/.codex dir exists
318
378
  const codexDir = path.dirname(configPath);
@@ -322,8 +382,16 @@ function registerHooks(log, projectRoot, events) {
322
382
 
323
383
  // [[hooks.X]] array-of-tables entries are additive-safe TOML: appending is valid
324
384
  // even if the user already has their own [[hooks.X]] entries from other tools.
325
- const lines = ['', '# kenaz-dashboard native hooks (TASK_1733) -- auto-registered by setup-codex.js'];
326
- for (const ev of events || HOOK_EVENTS) {
385
+ // TASK_2418: kenaz handlers already present for these events (stale or not) are dropped first, so each
386
+ // event ends with exactly one current handler.
387
+ let kept = '';
388
+ try {
389
+ kept = fs.existsSync(configPath) ? stripKenazHooks(fs.readFileSync(configPath, 'utf8'), evs, projectRoot) : '';
390
+ if (!Object.keys(scanKenazHooks(kept, projectRoot).found).length) kept = kept.split('\n').filter((l) => !HOOK_MARKER.test(l)).join('\n');
391
+ } catch (err) { log('hook registration failed: ' + err.message); return { success: false, action: 'hooks_failed', error: err.message }; }
392
+ const lines = [''];
393
+ if (!kept.split('\n').some((l) => HOOK_MARKER.test(l))) lines.push('# kenaz-dashboard native hooks (TASK_1733) -- auto-registered by setup-codex.js');
394
+ for (const ev of evs) {
327
395
  lines.push(
328
396
  '[[hooks.' + ev + ']]',
329
397
  '',
@@ -337,8 +405,8 @@ function registerHooks(log, projectRoot, events) {
337
405
  const hookBlock = lines.join('\n');
338
406
 
339
407
  try {
340
- fs.appendFileSync(configPath, hookBlock, 'utf8');
341
- log('kenaz hooks appended to ' + configPath);
408
+ fs.writeFileSync(configPath, kept + (kept && !kept.endsWith('\n') ? '\n' : '') + hookBlock, 'utf8');
409
+ log('kenaz hooks written to ' + configPath);
342
410
  return { success: true, action: 'hooks_registered' };
343
411
  } catch (err) {
344
412
  log('hook registration failed: ' + err.message);
@@ -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');
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+ // TASK_2422: codex:/deepseek: executors. Stub `codex` and `claude` on PATH, temp dirs only
3
+ // (never the real ~/.codex, ~/.claude, ~/.kenaz; no real codex/claude/wscript is ever started).
4
+ const { test } = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+ const { spawnSync } = require('node:child_process');
10
+
11
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
12
+ const EXEC = path.join(ROOT, '.formation', 'bifrost', 'exec.js');
13
+ const DISPATCH = path.join(ROOT, '.formation', 'tools', 'dispatch.js');
14
+ const TASKAPI = path.join(ROOT, '.collaboration', 'core', 'task-api.js');
15
+ const dm = require('../dispatch-model');
16
+ const KEY = 'sk-test-DEEPSEEK-SECRET-1234567890';
17
+
18
+ const tmp = (t, p) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), p)); t.after(() => fs.rmSync(d, { recursive: true, force: true })); return d; };
19
+ const git = (cwd, args) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, encoding: 'utf8', windowsHide: true });
20
+ function repo(t) {
21
+ const d = tmp(t, 'exec-wt-');
22
+ fs.writeFileSync(path.join(d, 'README.md'), 'x'); git(d, ['init', '-q']); git(d, ['add', '-A']); git(d, ['commit', '-q', '--no-verify', '-m', 'init']);
23
+ return d;
24
+ }
25
+
26
+ // One stub script serves both binaries: records argv/env/stdin to STUB_OUT/<name>.json, optionally commits.
27
+ const STUB_JS = `
28
+ const fs = require('fs'), path = require('path'), cp = require('child_process');
29
+ const name = path.basename(process.argv[1], '.js');
30
+ let stdin = ''; process.stdin.on('data', (d) => { stdin += d; });
31
+ process.stdin.on('end', () => {
32
+ const argv = process.argv.slice(2);
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 } }));
35
+ if (process.env.STUB_WRITE_ONLY) fs.writeFileSync(path.join(process.cwd(), 'left.txt'), 'x'); // a sandboxed agent: edits, cannot commit
36
+ if (process.env.STUB_COMMIT) {
37
+ fs.writeFileSync(path.join(process.cwd(), 'out.txt'), 'x');
38
+ cp.spawnSync('git', ['add', '-A'], { cwd: process.cwd() });
39
+ cp.spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--no-verify', '-m', 'stub work'], { cwd: process.cwd() });
40
+ }
41
+ const o = argv.indexOf('-o'); if (o >= 0) fs.writeFileSync(argv[o + 1], 'codex last message');
42
+ if (name === 'claude') console.log('claude says ' + (process.env.STUB_ECHO_KEY ? process.env.ANTHROPIC_AUTH_TOKEN : 'done'));
43
+ process.exit(Number(process.env.STUB_EXIT || 0));
44
+ });`;
45
+ function stubs(t) {
46
+ const bin = tmp(t, 'exec-bin-');
47
+ for (const n of ['codex', 'claude']) {
48
+ fs.writeFileSync(path.join(bin, n + '.js'), STUB_JS);
49
+ if (process.platform === 'win32') fs.writeFileSync(path.join(bin, n + '.cmd'), `@node "%~dp0${n}.js" %*\r\n`);
50
+ else { fs.writeFileSync(path.join(bin, n), `#!/bin/sh\nexec node "$(dirname "$0")/${n}.js" "$@"\n`); fs.chmodSync(path.join(bin, n), 0o755); }
51
+ }
52
+ return bin;
53
+ }
54
+ function runExec(t, { adapter, model, extraEnv = {}, key = KEY }) {
55
+ const wt = repo(t); const bin = stubs(t); const out = tmp(t, 'exec-out-'); const home = tmp(t, 'exec-home-');
56
+ const prompt = path.join(out, 'p.md'); fs.writeFileSync(prompt, 'DO THE CARD');
57
+ const result = path.join(out, 'r.json');
58
+ const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH, STUB_OUT: out, HOME: home, USERPROFILE: home, STUB_COMMIT: '1', ...extraEnv };
59
+ delete env.DEEPSEEK_API_KEY; delete env.ANTHROPIC_API_KEY; if (key) env.DEEPSEEK_API_KEY = key;
60
+ const r = spawnSync(process.execPath, [EXEC, `--adapter=${adapter}`, `--model=${model}`, '--worktree', wt, '--prompt-file', prompt, '--result-file', result], { env, encoding: 'utf8', windowsHide: true });
61
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(out, f), 'utf8')); } catch { return null; } };
62
+ return { r, wt, out, result: fs.existsSync(result) ? JSON.parse(fs.readFileSync(result, 'utf8')) : null, resultPath: result, codex: read('codex.json'), claude: read('claude.json'), home };
63
+ }
64
+
65
+ test('codex executor: exact argv, prompt on stdin, result file with commits', (t) => {
66
+ const x = runExec(t, { adapter: 'codex', model: 'gpt-5.6-sol-high' });
67
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
68
+ const gitDir = require('child_process').execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: x.wt, encoding: 'utf8' }).trim();
69
+ assert.deepEqual(x.codex.argv, ['exec', '-C', x.wt, '-m', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=high', '--sandbox', 'workspace-write', '-o', x.resultPath + '.last.txt', '--add-dir', gitDir]);
70
+ assert.match(x.codex.stdin, /^DO THE CARD\n\n## Executor note\n/);
71
+ assert.equal(fs.realpathSync(x.codex.cwd), fs.realpathSync(x.wt));
72
+ assert.equal(x.result.ok, true); assert.equal(x.result.exitCode, 0);
73
+ assert.equal(x.result.lastMessage, 'codex last message');
74
+ assert.match(x.result.commits[0], /stub work/);
75
+ assert.ok(fs.existsSync(x.wt), 'worktree is never removed');
76
+ });
77
+
78
+ test('codex default (empty id): no -m, project default effort', (t) => {
79
+ const x = runExec(t, { adapter: 'codex', model: '' });
80
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
81
+ assert.equal(x.codex.argv.includes('-m'), false);
82
+ assert.ok(x.codex.argv.includes('model_reasoning_effort=medium'));
83
+ });
84
+
85
+ test('codex without commits is reported as failure (no commits)', (t) => {
86
+ const x = runExec(t, { adapter: 'codex', model: '', extraEnv: { STUB_COMMIT: '' } });
87
+ assert.equal(x.r.status, 1);
88
+ assert.equal(x.result.ok, false); assert.equal(x.result.error, 'no commits');
89
+ });
90
+
91
+ test('deepseek executor: env only in the child, key absent from argv, stdout, result file', (t) => {
92
+ const x = runExec(t, { adapter: 'deepseek', model: 'deepseek-chat', extraEnv: { ANTHROPIC_API_KEY: 'real-anthropic-key', STUB_ECHO_KEY: '1' } });
93
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
94
+ assert.deepEqual(x.claude.argv, ['-p', '--allowedTools', 'Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep']);
95
+ assert.deepEqual(x.claude.env, { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', ANTHROPIC_AUTH_TOKEN: KEY, ANTHROPIC_MODEL: 'deepseek-chat' });
96
+ assert.match(x.claude.stdin, /^DO THE CARD\n\n## Executor note\n/);
97
+ assert.equal(JSON.stringify(x.claude.argv).includes(KEY), false);
98
+ assert.equal((x.r.stdout + x.r.stderr).includes(KEY), false);
99
+ const raw = fs.readFileSync(x.resultPath, 'utf8');
100
+ assert.equal(raw.includes(KEY), false);
101
+ assert.match(x.result.lastMessage, /claude says \*\*\*/); // the stub echoed the key: it was redacted
102
+ assert.equal(JSON.stringify(x.result).includes('real-anthropic-key'), false);
103
+ });
104
+
105
+ test('deepseek key from ~/.kenaz/credentials.yaml; missing key fails before anything starts', (t) => {
106
+ const none = runExec(t, { adapter: 'deepseek', model: 'deepseek-chat', key: null });
107
+ assert.equal(none.r.status, 2); assert.match(none.r.stderr, /no API key/);
108
+ assert.equal(none.claude, null); assert.equal(none.result, null);
109
+ // credentials file route (home is a temp dir)
110
+ const wt = repo(t); const bin = stubs(t); const out = tmp(t, 'exec-out-'); const home = tmp(t, 'exec-home-');
111
+ fs.mkdirSync(path.join(home, '.kenaz')); fs.writeFileSync(path.join(home, '.kenaz', 'credentials.yaml'), `other: 1\ndeepseek_api_key: "${KEY}"\n`);
112
+ const prompt = path.join(out, 'p.md'); fs.writeFileSync(prompt, 'P');
113
+ const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH, STUB_OUT: out, HOME: home, USERPROFILE: home, STUB_COMMIT: '1' };
114
+ delete env.DEEPSEEK_API_KEY;
115
+ const r = spawnSync(process.execPath, [EXEC, '--adapter=deepseek', '--model=deepseek-reasoner', '--worktree', wt, '--prompt-file', prompt, '--result-file', path.join(out, 'r.json')], { env, encoding: 'utf8', windowsHide: true });
116
+ assert.equal(r.status, 0, r.stdout + r.stderr);
117
+ assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'claude.json'), 'utf8')).env.ANTHROPIC_AUTH_TOKEN, KEY);
118
+ });
119
+
120
+ // ---- kenaz subagent set/unset/show ----
121
+ const dmRun = (argv, cwd) => { const out = []; const code = dm.main(argv, (l) => out.push(l), cwd); return { code, out: out.join('\n') }; };
122
+ const modelsOf = (p) => JSON.parse(fs.readFileSync(path.join(p, '.kenaz', 'dashboard-models.json'), 'utf8'));
123
+
124
+ test('subagent set/unset/show: codex:, deepseek:, codex: alone', (t) => {
125
+ const p = tmp(t, 'dm-');
126
+ assert.equal(dmRun(['set', 'codex:gpt-5.6-sol'], p).code, 0);
127
+ assert.equal(dmRun(['set', 'deepseek:deepseek-chat', '--rigor', 'low'], p).code, 0);
128
+ assert.equal(dmRun(['set', 'codex:', '--rigor', 'high'], p).code, 0);
129
+ assert.deepEqual(modelsOf(p), { dispatchModel: 'codex:gpt-5.6-sol', dispatchModelByRigor: { low: 'deepseek:deepseek-chat', high: 'codex:' } });
130
+ const shown = dmRun([], p).out;
131
+ assert.match(shown, /low\s+deepseek:deepseek-chat -> executor deepseek/);
132
+ assert.match(shown, /medium\s+codex:gpt-5.6-sol -> executor codex/);
133
+ assert.match(shown, /high\s+codex: -> executor codex/);
134
+ dmRun(['unset', '--rigor', 'low'], p); dmRun(['unset'], p);
135
+ assert.deepEqual(modelsOf(p), { dispatchModelByRigor: { high: 'codex:' } });
136
+ });
137
+
138
+ test('subagent set: unknown codex id and unknown prefix are refused with the valid list; nothing written', (t) => {
139
+ const p = tmp(t, 'dm-');
140
+ const a = dmRun(['set', 'codex:nope'], p);
141
+ assert.equal(a.code, 1); assert.match(a.out, /unknown codex model 'nope'.*codex:gpt-5\.6-sol/);
142
+ const b = dmRun(['set', 'deepseek:gpt'], p);
143
+ assert.equal(b.code, 1); assert.match(b.out, /deepseek:deepseek-chat, deepseek:deepseek-reasoner/);
144
+ const c = dmRun(['set', 'gemini:pro'], p);
145
+ assert.equal(c.code, 1); assert.match(c.out, /unknown executor prefix 'gemini:'.*codex:<id>, deepseek:<model>/);
146
+ assert.equal(fs.existsSync(path.join(p, '.kenaz', 'dashboard-models.json')), false);
147
+ assert.equal(dmRun(['set', 'opus'], p).code, 0, 'plain Claude ids keep working');
148
+ });
149
+
150
+ // ---- dispatch.js: executor block vs the unchanged Claude control ----
151
+ test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
152
+ const sb = tmp(t, 'exec-dispatch-');
153
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
154
+ fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
155
+ fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
156
+ git(sb, ['init', '-q']); git(sb, ['add', '-A']); git(sb, ['commit', '-q', '--no-verify', '-m', 'init']);
157
+ const env = { ...process.env, DEEPSEEK_API_KEY: KEY }; delete env.CLAUDE_PROJECT_DIR; delete env.CODEX_SESSION_ID; delete env.CODEX_THREAD_ID;
158
+ const sh = (script, args) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
159
+ const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2422', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
160
+ '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force']);
161
+ const id = ((mk.stdout || '').match(/TASK_\d+/) || [])[0]; assert.ok(id, mk.stdout + mk.stderr);
162
+ const disp = (model) => {
163
+ fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: model }));
164
+ const r = sh(DISPATCH, ['--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force']);
165
+ return { r, j: (() => { try { return JSON.parse(r.stdout); } catch { return null; } })() };
166
+ };
167
+ const cx = disp('codex:gpt-5.6-sol');
168
+ assert.ok(cx.j, cx.r.stdout + cx.r.stderr);
169
+ assert.equal(cx.j.agentParams, undefined);
170
+ assert.equal(cx.j.executor.adapter, 'codex'); assert.equal(cx.j.executor.branchName, `task/${id.toLowerCase()}`);
171
+ assert.ok(fs.existsSync(cx.j.executor.worktreePath), 'worktree created');
172
+ assert.equal(fs.readFileSync(cx.j.executor.promptFile, 'utf8'), cx.j.promptText);
173
+ assert.match(cx.j.executor.command, /exec\.js" --adapter=codex --model="gpt-5\.6-sol" --worktree/);
174
+ const ds = disp('deepseek:deepseek-chat');
175
+ assert.equal(ds.j.executor.adapter, 'deepseek');
176
+ assert.equal(ds.r.stdout.includes(KEY) || ds.r.stderr.includes(KEY), false, 'key never in the dispatch JSON or logs');
177
+ assert.equal(fs.readFileSync(ds.j.executor.promptFile, 'utf8').includes(KEY), false);
178
+ // control: Claude id -> the existing agentParams output, no executor block
179
+ const cl = disp('claude-sonnet-5-5');
180
+ assert.equal(cl.j.executor, undefined);
181
+ assert.equal(cl.j.agentParams.model, 'sonnet'); // cross-project sandbox: Agent isolation omitted, as before
182
+ assert.deepEqual(Object.keys(cl.j.agentParams), ['description', 'model']);
183
+ // a missing DeepSeek key is refused before any worktree/side effect
184
+ const noKey = { ...env }; delete noKey.DEEPSEEK_API_KEY; noKey.USERPROFILE = noKey.HOME = tmp(t, 'nokey-');
185
+ fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: 'deepseek:deepseek-chat' }));
186
+ const nk = spawnSync(process.execPath, [DISPATCH, '--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force'], { env: noKey, encoding: 'utf8', windowsHide: true, cwd: ROOT });
187
+ assert.notEqual(nk.status, 0); assert.match(nk.stderr, /no API key/);
188
+ });
189
+
190
+ test('codex that edits but cannot commit (sandbox): exec.js commits the leftovers, telemetry excluded', (t) => {
191
+ const id = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
192
+ const x = runExec(t, { adapter: 'codex', model: '', extraEnv: { STUB_COMMIT: '', STUB_WRITE_ONLY: '1', ...id } });
193
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
194
+ assert.equal(x.result.ok, true); assert.ok(x.result.executorCommit);
195
+ assert.match(x.result.commits[0], /codex executor: agent's changes/);
196
+ const files = require('child_process').execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { cwd: x.wt, encoding: 'utf8' }).trim();
197
+ assert.strictEqual(files, 'left.txt');
198
+ });