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.
- package/core/.claude/commands/Ansuz.md +11 -1
- package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
- package/core/.claude/hooks/_run-user-prompt.js +2 -0
- package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
- package/core/.claude/hooks/correction-detect.js +99 -0
- package/core/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -0
- package/core/.formation/bifrost/exec.js +147 -0
- package/core/.formation/bifrost/provider-launch.js +57 -0
- package/core/.formation/bifrost/provider-launch.test.js +62 -0
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
- package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
- package/core/.formation/tools/dispatch-model.js +15 -10
- package/core/.formation/tools/dispatch.js +46 -10
- package/core/.formation/tools/kenaz-stats.js +57 -16
- package/core/.formation/tools/kenaz-stats.test.js +2 -2
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/Ansuz/RULES.md +7 -2
- package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
- package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
- package/core/.specialist/tools/handoff-doc.js +95 -0
- package/core/.specialist/tools/lessons-harvest.js +243 -0
- package/core/.specialist/tools/lessons-lifecycle.js +103 -0
- package/core/.specialist/tools/lessons.js +149 -24
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/scripts/correction-detect-eval.js +70 -0
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
- package/core/plugins/kenaz/commands/Ansuz.md +11 -1
- package/core/plugins/kenaz/commands/kenaz.md +5 -3
- package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/statusline.js +60 -0
- package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +27 -10
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +193 -16
- 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
|
|
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
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
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
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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.
|
|
341
|
-
log('kenaz hooks
|
|
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);
|
|
@@ -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
|
+
});
|