kenaz 0.3.15 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,11 @@
4
4
  * Bifrost executors for `kenaz subagent` (TASK_2422): run a dispatched card with a non-Claude model.
5
5
  * codex:<id> `codex exec` in the card's worktree (id = an entry of openai/models.json; `codex:` = default)
6
6
  * deepseek:<m> `claude -p` in the worktree against DeepSeek's Anthropic-compatible endpoint
7
+ * claude (TASK_2430) `claude -p --model <alias>` on the user's own Claude login: used by dispatch.js inside a
8
+ * `kenaz launch` session, where the Agent tool can only reach the other vendor
7
9
  *
8
10
  * CLI (dispatch.js prints the exact command):
9
- * node exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>
11
+ * node exec.js --adapter=codex|deepseek|claude --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>
10
12
  * The prompt goes to the child on stdin (never argv). The DeepSeek key is only in the child's env and
11
13
  * is redacted from everything written. The worktree is never removed. Exit 0 = child ok AND new commits.
12
14
  */
@@ -63,6 +65,7 @@ function buildChild({ adapter, id, worktree, lastFile, key, gitDir }) {
63
65
  if (gitDir) args.push('--add-dir', gitDir);
64
66
  return { bin: 'codex', args, env: {}, unset: [] };
65
67
  }
68
+ if (adapter === 'claude') return { bin: 'claude', args: ['-p', '--model', id, '--allowedTools', ...CLAUDE_TOOLS], env: {}, unset: CLAUDE_UNSET };
66
69
  return { bin: 'claude', args: ['-p', '--allowedTools', ...CLAUDE_TOOLS], env: deepseekEnv(id, key), unset: DEEPSEEK_UNSET };
67
70
  }
68
71
 
@@ -90,6 +93,10 @@ async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000)
90
93
  } catch { return null; }
91
94
  }
92
95
  const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
96
+ // The claude adapter must reach Claude, not the vendor `kenaz launch` pointed this session at: drop every override
97
+ // launch sets (the vendor key lives in ANTHROPIC_AUTH_TOKEN; DEEPSEEK_API_KEY is dropped too, the child does not need it).
98
+ const CLAUDE_UNSET = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
99
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'KENAZ_PROVIDER', 'DEEPSEEK_API_KEY'];
93
100
 
94
101
  const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
95
102
  // Same exclusions as the dispatch prompt's commit command (session telemetry never rides along).
@@ -108,11 +115,13 @@ const winQuote = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : '"' + a.replace(/"/g
108
115
 
109
116
  /** Run the executor; resolves to the result object (also written to resultFile). Never throws for child failures. */
110
117
  async function run({ adapter, model = '', worktree, promptFile, resultFile, env = process.env, home = os.homedir() }) {
111
- if (!['codex', 'deepseek'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
118
+ if (!['codex', 'deepseek', 'claude'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
112
119
  const started = Date.now();
113
120
  const key = adapter === 'deepseek' ? deepseekKey(env, home) : null;
114
121
  if (adapter === 'deepseek' && !key) throw new Error('deepseek: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
115
- const redact = (s) => (key ? String(s).split(key).join('***') : String(s));
122
+ // claude adapter: the vendor key is not given to the child, but redact it (and the launch token) from anything written anyway.
123
+ const secrets = adapter === 'claude' ? [deepseekKey(env, home), env.ANTHROPIC_AUTH_TOKEN].filter(Boolean) : key ? [key] : [];
124
+ const redact = (s) => secrets.reduce((acc, k) => acc.split(k).join('***'), String(s));
116
125
  const base = (git(worktree, ['rev-parse', 'HEAD']).stdout || '').trim();
117
126
  const lastFile = resultFile + '.last.txt';
118
127
  const gitDir = (git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout || '').trim();
@@ -148,7 +157,7 @@ async function main(argv) {
148
157
  if (m) a[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
149
158
  }
150
159
  if (!a.adapter || !a.worktree || !a['prompt-file'] || !a['result-file']) {
151
- console.error('usage: exec.js --adapter=codex|deepseek --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
160
+ console.error('usage: exec.js --adapter=codex|deepseek|claude --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
152
161
  return 2;
153
162
  }
154
163
  try {
@@ -42,7 +42,8 @@ function plan(argv, env = process.env, home = os.homedir(), live = null) {
42
42
  if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
43
43
  const key = p.key(env, home);
44
44
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
45
- const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
45
+ const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
46
+ KENAZ_PROVIDER: provider }; // tools (dispatch.js) tell the session runs on another vendor
46
47
  for (const k of p.unset) delete childEnv[k];
47
48
  return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
48
49
  }
@@ -31,6 +31,7 @@ test('deepseek: every model slot points at DeepSeek, key only in the child env,
31
31
  assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
32
32
  assert.strictEqual(e.ANTHROPIC_API_KEY, undefined);
33
33
  assert.strictEqual(e.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1');
34
+ assert.strictEqual(e.KENAZ_PROVIDER, 'deepseek', 'tools can tell the session runs on another vendor');
34
35
  assert.strictEqual(e.PATH, 'p', 'the rest of the env is kept');
35
36
  assert.ok(!JSON.stringify(c.args).includes(KEY) && !lines.join('\n').includes(KEY), 'key never in argv or output');
36
37
  });
@@ -31,7 +31,7 @@ let stdin = ''; process.stdin.on('data', (d) => { stdin += d; });
31
31
  process.stdin.on('end', () => {
32
32
  const argv = process.argv.slice(2);
33
33
  fs.writeFileSync(path.join(process.env.STUB_OUT, name + '.json'), JSON.stringify({ argv, cwd: process.cwd(), stdin,
34
- env: { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } }));
34
+ env: { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }, fullEnv: process.env }));
35
35
  if (process.env.STUB_WRITE_ONLY) fs.writeFileSync(path.join(process.cwd(), 'left.txt'), 'x'); // a sandboxed agent: edits, cannot commit
36
36
  if (process.env.STUB_COMMIT) {
37
37
  fs.writeFileSync(path.join(process.cwd(), 'out.txt'), 'x');
@@ -148,6 +148,62 @@ test('subagent set: unknown codex id and unknown prefix are refused with the val
148
148
  });
149
149
 
150
150
  // ---- dispatch.js: executor block vs the unchanged Claude control ----
151
+ const REMOVED = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
152
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'KENAZ_PROVIDER'];
153
+ test('claude executor (TASK_2430): --model alias, every launch override removed, vendor key nowhere', (t) => {
154
+ const launchEnv = Object.fromEntries(REMOVED.map((k) => [k, 'x-' + k])); launchEnv.ANTHROPIC_AUTH_TOKEN = KEY; launchEnv.STUB_ECHO_KEY = '1';
155
+ const x = runExec(t, { adapter: 'claude', model: 'opus', extraEnv: launchEnv });
156
+ assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
157
+ assert.deepEqual(x.claude.argv, ['-p', '--model', 'opus', '--allowedTools', 'Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep']);
158
+ for (const k of [...REMOVED, 'DEEPSEEK_API_KEY']) assert.equal(x.claude.fullEnv[k], undefined, k + ' reached the claude child');
159
+ assert.match(x.claude.stdin, /^DO THE CARD\n\n## Executor note\n/);
160
+ assert.equal(JSON.stringify(x.claude.fullEnv).includes(KEY), false);
161
+ assert.equal(JSON.stringify(x.claude.argv).includes(KEY), false);
162
+ assert.equal((x.r.stdout + x.r.stderr).includes(KEY), false);
163
+ assert.equal(fs.readFileSync(x.resultPath, 'utf8').includes(KEY), false);
164
+ assert.equal(x.result.ok, true); assert.match(x.result.commits[0], /stub work/);
165
+ });
166
+
167
+ test('claude executor: edits left uncommitted are committed by exec.js; no commits = failure', (t) => {
168
+ const left = runExec(t, { adapter: 'claude', model: 'sonnet', extraEnv: { STUB_COMMIT: '', STUB_WRITE_ONLY: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
169
+ assert.equal(left.r.status, 0, left.r.stdout + left.r.stderr);
170
+ assert.ok(left.result.executorCommit);
171
+ const none = runExec(t, { adapter: 'claude', model: 'sonnet', extraEnv: { STUB_COMMIT: '' } });
172
+ assert.equal(none.r.status, 1); assert.equal(none.result.error, 'no commits');
173
+ });
174
+
175
+ test('dispatch under KENAZ_PROVIDER: Claude id -> executor adapter claude, no agentParams; without it agentParams as before', (t) => {
176
+ const sb = tmp(t, 'exec-dispatch-claude-');
177
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
178
+ fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
179
+ fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
180
+ git(sb, ['init', '-q']); git(sb, ['add', '-A']); git(sb, ['commit', '-q', '--no-verify', '-m', 'init']);
181
+ const base = { ...process.env }; delete base.CLAUDE_PROJECT_DIR; delete base.CODEX_SESSION_ID; delete base.CODEX_THREAD_ID; delete base.KENAZ_PROVIDER;
182
+ const sh = (script, args, env) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
183
+ const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2430', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
184
+ '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force'], base);
185
+ const id = ((mk.stdout || '').match(/TASK_\d+/) || [])[0]; assert.ok(id, mk.stdout + mk.stderr);
186
+ const disp = (model, provider) => {
187
+ fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: model }));
188
+ const env = { ...base, ...(provider ? { KENAZ_PROVIDER: provider } : {}), DEEPSEEK_API_KEY: KEY };
189
+ const r = sh(DISPATCH, ['--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force'], env);
190
+ return { r, j: (() => { try { return JSON.parse(r.stdout); } catch { return null; } })() };
191
+ };
192
+ const ds = disp('claude-opus-5', 'deepseek');
193
+ assert.ok(ds.j, ds.r.stdout + ds.r.stderr);
194
+ assert.equal(ds.j.agentParams, undefined);
195
+ assert.equal(ds.j.executor.adapter, 'claude'); assert.equal(ds.j.executor.model, 'opus');
196
+ assert.equal(ds.j.executor.branchName, `task/${id.toLowerCase()}`);
197
+ assert.ok(fs.existsSync(ds.j.executor.worktreePath)); assert.ok(ds.j.executor.promptFile && ds.j.executor.resultFile && ds.j.executor.note);
198
+ assert.match(ds.j.executor.command, /exec\.js" --adapter=claude --model="opus" --worktree/);
199
+ assert.equal(ds.r.stdout.includes(KEY) || ds.r.stderr.includes(KEY), false);
200
+ const an = disp('claude-sonnet-5-5', 'anthropic'); // control: provider anthropic = no executor
201
+ assert.equal(an.j.executor, undefined); assert.equal(an.j.agentParams.model, 'sonnet');
202
+ const none = disp('claude-sonnet-5-5', null); // control: no KENAZ_PROVIDER = byte-for-byte as before
203
+ assert.equal(none.j.executor, undefined);
204
+ assert.deepEqual(Object.keys(none.j.agentParams), ['description', 'model']); assert.equal(none.j.agentParams.model, 'sonnet');
205
+ });
206
+
151
207
  test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
152
208
  const sb = tmp(t, 'exec-dispatch-');
153
209
  for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
@@ -1063,6 +1063,12 @@ async function getSubagentDispatchParams(options) {
1063
1063
  return null;
1064
1064
  }
1065
1065
  }
1066
+ // TASK_2430: in a `kenaz launch` session the Agent tool only reaches the other vendor, so a Claude model
1067
+ // id runs as a `claude -p` executor on the user's own Claude login instead.
1068
+ if (!isCodex && !exec && process.env.KENAZ_PROVIDER && process.env.KENAZ_PROVIDER !== 'anthropic') {
1069
+ const fam = translateModelForAgentTool(rawModel).model;
1070
+ if (fam) exec = { adapter: 'claude', id: fam };
1071
+ }
1066
1072
  // TASK_2315: Codex inherits the session model — nothing to translate or pass.
1067
1073
  const translated = isCodex || exec ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
1068
1074
  if (!isCodex && !exec && !translated.model) {
@@ -73,6 +73,7 @@ the real defect BEFORE carding or dispatching.
73
73
  - Executor model (`kenaz subagent set codex:<id>` or `deepseek:<model>`): dispatch.js prints an `executor` block instead of
74
74
  agentParams. Run `executor.command` with Bash run_in_background, wait for the completion notice, read `executor.resultFile`
75
75
  (ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
76
+ In a `kenaz launch` session (KENAZ_PROVIDER set), Claude subagents also come as an executor block (adapter `claude`, they use the user's Claude quota).
76
77
  - Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
77
78
  via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
78
79
 
@@ -1 +1 @@
1
- 0.3.15
1
+ 0.3.16
package/lib/cli.js CHANGED
@@ -35,7 +35,8 @@ function installer() {
35
35
 
36
36
  /** A core module from the installed core, else this checkout; null when neither is there. */
37
37
  function coreModule(p, ...rel) {
38
- for (const f of [path.join(p.core, ...rel), path.join(__dirname, '..', '..', '..', ...rel)]) {
38
+ // installed core, then the core bundled in this npm package (npx before `kenaz install`), then the repo checkout
39
+ for (const f of [path.join(p.core, ...rel), path.join(PKG_ROOT, 'core', ...rel), path.join(__dirname, '..', '..', '..', ...rel)]) {
39
40
  if (fs.existsSync(f)) return require(f);
40
41
  }
41
42
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
4
4
  "description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
5
5
  "bin": {
6
6
  "kenaz": "bin/kenaz.js"
@@ -20,11 +20,11 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.15",
24
- "@kenazai/server-darwin-x64": "0.3.15",
25
- "@kenazai/server-linux-arm64": "0.3.15",
26
- "@kenazai/server-linux-x64": "0.3.15",
27
- "@kenazai/server-win32-x64": "0.3.15"
23
+ "@kenazai/server-darwin-arm64": "0.3.16",
24
+ "@kenazai/server-darwin-x64": "0.3.16",
25
+ "@kenazai/server-linux-arm64": "0.3.16",
26
+ "@kenazai/server-linux-x64": "0.3.16",
27
+ "@kenazai/server-win32-x64": "0.3.16"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {