kenaz 0.3.15 → 0.3.17
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 +1 -0
- package/core/.claude/hooks/__tests__/vendor-agent-guard.test.js +44 -0
- package/core/.claude/hooks/vendor-agent-guard.js +40 -0
- package/core/.claude/settings.json +9 -0
- package/core/.formation/bifrost/exec.js +21 -5
- package/core/.formation/bifrost/provider-launch.js +2 -1
- package/core/.formation/bifrost/provider-launch.test.js +1 -0
- package/core/.formation/tools/__tests__/subagent-executor.test.js +63 -2
- package/core/.formation/tools/dispatch.js +8 -0
- package/core/.specialist/Ansuz/RULES.md +6 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/plugins/kenaz/commands/Ansuz.md +1 -0
- package/core/plugins/kenaz/hooks/hooks.json +145 -21
- package/core/plugins/kenaz/hooks/kenaz-hook.js +1 -0
- package/lib/cli.js +3 -1
- package/package.json +6 -6
|
@@ -14,6 +14,7 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
|
|
|
14
14
|
## 工作方式
|
|
15
15
|
- **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
|
|
16
16
|
- **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
|
|
17
|
+
- 用戶指定 model 就照做:單張卡加 `--dispatch-model <id>`(不改設定);「之後都用」才 `kenaz subagent set <id> [--rigor r]`。沒指定就照設定,不自己換。
|
|
17
18
|
- 開卡前確認 scope 與驗收條件對準真正的問題。
|
|
18
19
|
|
|
19
20
|
## 驗證(合併前,強度跟風險相稱)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// vendor-agent-guard.js: in another-vendor session, an Agent call that asks for a Claude model is denied.
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { decide } = require('../vendor-agent-guard');
|
|
7
|
+
|
|
8
|
+
const DS = { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', KENAZ_PROVIDER: 'deepseek' };
|
|
9
|
+
const call = (model, tool = 'Agent') => ({ tool_name: tool, tool_input: { description: 'x', prompt: 'y', ...(model ? { model } : {}) } });
|
|
10
|
+
|
|
11
|
+
test('deepseek session: Agent/Task asking for a Claude model is denied with the dispatch.js route', () => {
|
|
12
|
+
for (const [m, t] of [['opus', 'Agent'], ['sonnet', 'Task'], ['claude-opus-5-5', 'Agent']]) {
|
|
13
|
+
const d = decide(call(m, t), DS);
|
|
14
|
+
assert.strictEqual(d.hookSpecificOutput.permissionDecision, 'deny', m);
|
|
15
|
+
assert.match(d.hookSpecificOutput.permissionDecisionReason, /dispatch\.js .*executor/);
|
|
16
|
+
}
|
|
17
|
+
// base URL alone (a session launched before KENAZ_PROVIDER existed) is enough
|
|
18
|
+
assert.ok(decide(call('opus'), { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic' }));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('controls: no model, a non-Claude model, a Claude session, or another tool are allowed', () => {
|
|
22
|
+
assert.strictEqual(decide(call(null), DS), null, 'no model = runs on the session vendor, as intended');
|
|
23
|
+
assert.strictEqual(decide(call('deepseek-v4-pro'), DS), null);
|
|
24
|
+
assert.strictEqual(decide(call('opus'), {}), null, 'normal Claude session');
|
|
25
|
+
assert.strictEqual(decide(call('opus'), { ANTHROPIC_BASE_URL: 'https://api.anthropic.com' }), null);
|
|
26
|
+
assert.strictEqual(decide({ tool_name: 'Bash', tool_input: { command: 'ls' } }, DS), null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('real hook path: the plugin dispatcher runs it in a user project and prints the deny JSON', () => {
|
|
30
|
+
const root = path.resolve(__dirname, '..', '..', '..');
|
|
31
|
+
// a user project (inside the core repo the plugin yields to the repo's own .claude/settings.json hooks)
|
|
32
|
+
const proj = require('fs').mkdtempSync(path.join(require('os').tmpdir(), 'vag-'));
|
|
33
|
+
require('fs').mkdirSync(path.join(proj, '.kenaz')); require('fs').writeFileSync(path.join(proj, '.kenaz', 'project.yaml'), 'name: p\n');
|
|
34
|
+
const r = spawnSync(process.execPath, [path.join(root, 'plugins', 'kenaz', 'hooks', 'kenaz-hook.js'), 'vendor-agent-guard'], {
|
|
35
|
+
input: JSON.stringify(call('opus')), encoding: 'utf8',
|
|
36
|
+
env: { ...process.env, ...DS, CLAUDE_PROJECT_DIR: proj, KENAZ_MODE: 'full' }, cwd: proj,
|
|
37
|
+
});
|
|
38
|
+
assert.match(r.stdout, /"permissionDecision":"deny"/, r.stdout + r.stderr);
|
|
39
|
+
const ok = spawnSync(process.execPath, [path.join(root, 'plugins', 'kenaz', 'hooks', 'kenaz-hook.js'), 'vendor-agent-guard'], {
|
|
40
|
+
input: JSON.stringify(call('opus')), encoding: 'utf8',
|
|
41
|
+
env: { ...process.env, ANTHROPIC_BASE_URL: '', KENAZ_PROVIDER: '', CLAUDE_PROJECT_DIR: proj, KENAZ_MODE: 'full' }, cwd: proj,
|
|
42
|
+
});
|
|
43
|
+
assert.strictEqual(ok.stdout.trim(), '', 'control: Claude session prints nothing');
|
|
44
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PreToolUse(Agent|Task): in a session whose model requests go to another vendor (kenaz launch --provider
|
|
3
|
+
// deepseek, or the env set by hand), an Agent-tool subagent runs on that vendor too, whatever `model` says.
|
|
4
|
+
// Asking it for a Claude model is therefore always a silent substitution: deny it and point at the path that
|
|
5
|
+
// does reach Claude (dispatch.js -> executor block -> claude -p on the user's own login). Agent calls without
|
|
6
|
+
// a Claude model are allowed (they run on the session's vendor, as expected). Fails open on any error.
|
|
7
|
+
// Field report 2026-10-12: a DeepSeek Ansuz asked the Agent tool for opus and the subagent ran deepseek-v4-pro.
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const CLAUDE_MODEL = /^(opus|sonnet|haiku|fable)$|^claude-/i;
|
|
11
|
+
|
|
12
|
+
function decide(input, env = process.env) {
|
|
13
|
+
if (!input || !['Agent', 'Task'].includes(input.tool_name)) return null;
|
|
14
|
+
const model = String((input.tool_input && input.tool_input.model) || '').trim();
|
|
15
|
+
if (!CLAUDE_MODEL.test(model)) return null;
|
|
16
|
+
let other = false;
|
|
17
|
+
try { other = require(path.join(__dirname, '..', '..', '.formation', 'bifrost', 'exec.js')).otherVendorSession(env); } catch (_) { return null; }
|
|
18
|
+
if (!other) return null;
|
|
19
|
+
return {
|
|
20
|
+
hookSpecificOutput: {
|
|
21
|
+
hookEventName: 'PreToolUse',
|
|
22
|
+
permissionDecision: 'deny',
|
|
23
|
+
permissionDecisionReason: `This session's model requests go to ${env.KENAZ_PROVIDER || 'another vendor'} (${env.ANTHROPIC_BASE_URL || 'KENAZ_PROVIDER'}), so an Agent-tool subagent would run there, not on Claude '${model}'. ` +
|
|
24
|
+
'For a Claude subagent run dispatch.js (card first): node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent [--dispatch-model ' + model + '] ' +
|
|
25
|
+
'-> it prints an `executor` block; run executor.command with Bash run_in_background (claude -p on the user\'s Claude login). ' +
|
|
26
|
+
'To use this session\'s model instead, call the Agent tool again without `model`.',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = require('fs').readFileSync(0, 'utf8');
|
|
34
|
+
const out = decide(JSON.parse(raw || '{}'));
|
|
35
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
36
|
+
} catch (_) { /* fail open */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (require.main === module) main(); // hook wrappers require() it and call main() (same as memory-guard.js)
|
|
40
|
+
module.exports = { decide, main };
|
|
@@ -71,6 +71,15 @@
|
|
|
71
71
|
}
|
|
72
72
|
],
|
|
73
73
|
"matcher": "mcp__kenaz-dashboard__.*|Read|Edit|Write"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"matcher": "Agent|Task",
|
|
77
|
+
"hooks": [
|
|
78
|
+
{
|
|
79
|
+
"type": "command",
|
|
80
|
+
"command": "node -e \"const d=process.env.CLAUDE_PROJECT_DIR;process.chdir(d);require(d+'/.claude/hooks/vendor-agent-guard.js').main()\""
|
|
81
|
+
}
|
|
82
|
+
]
|
|
74
83
|
}
|
|
75
84
|
],
|
|
76
85
|
"PostToolUse": [
|
|
@@ -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
|
|
|
@@ -89,7 +92,18 @@ async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000)
|
|
|
89
92
|
return Object.keys(out).length ? out : null;
|
|
90
93
|
} catch { return null; }
|
|
91
94
|
}
|
|
95
|
+
/** True in a Claude Code session whose model requests go to another vendor (kenaz launch, or the env set by
|
|
96
|
+
* hand): KENAZ_PROVIDER, or ANTHROPIC_BASE_URL not on anthropic.com. The Agent tool there reaches only that vendor. */
|
|
97
|
+
function otherVendorSession(env = process.env) {
|
|
98
|
+
if (env.KENAZ_PROVIDER && env.KENAZ_PROVIDER !== 'anthropic') return true;
|
|
99
|
+
const u = String(env.ANTHROPIC_BASE_URL || '').replace(/^https?:\/\//i, '');
|
|
100
|
+
return !!u && !/(^|\.)anthropic\.com(\/|:|$)/i.test(u);
|
|
101
|
+
}
|
|
92
102
|
const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
|
|
103
|
+
// The claude adapter must reach Claude, not the vendor `kenaz launch` pointed this session at: drop every override
|
|
104
|
+
// launch sets (the vendor key lives in ANTHROPIC_AUTH_TOKEN; DEEPSEEK_API_KEY is dropped too, the child does not need it).
|
|
105
|
+
const CLAUDE_UNSET = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
106
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'KENAZ_PROVIDER', 'DEEPSEEK_API_KEY'];
|
|
93
107
|
|
|
94
108
|
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
95
109
|
// Same exclusions as the dispatch prompt's commit command (session telemetry never rides along).
|
|
@@ -108,11 +122,13 @@ const winQuote = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : '"' + a.replace(/"/g
|
|
|
108
122
|
|
|
109
123
|
/** Run the executor; resolves to the result object (also written to resultFile). Never throws for child failures. */
|
|
110
124
|
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}'`);
|
|
125
|
+
if (!['codex', 'deepseek', 'claude'].includes(adapter)) throw new Error(`unknown adapter '${adapter}'`);
|
|
112
126
|
const started = Date.now();
|
|
113
127
|
const key = adapter === 'deepseek' ? deepseekKey(env, home) : null;
|
|
114
128
|
if (adapter === 'deepseek' && !key) throw new Error('deepseek: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
|
|
115
|
-
|
|
129
|
+
// claude adapter: the vendor key is not given to the child, but redact it (and the launch token) from anything written anyway.
|
|
130
|
+
const secrets = adapter === 'claude' ? [deepseekKey(env, home), env.ANTHROPIC_AUTH_TOKEN].filter(Boolean) : key ? [key] : [];
|
|
131
|
+
const redact = (s) => secrets.reduce((acc, k) => acc.split(k).join('***'), String(s));
|
|
116
132
|
const base = (git(worktree, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
117
133
|
const lastFile = resultFile + '.last.txt';
|
|
118
134
|
const gitDir = (git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout || '').trim();
|
|
@@ -148,7 +164,7 @@ async function main(argv) {
|
|
|
148
164
|
if (m) a[m[1]] = m[2] !== undefined ? m[2] : argv[++i];
|
|
149
165
|
}
|
|
150
166
|
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>');
|
|
167
|
+
console.error('usage: exec.js --adapter=codex|deepseek|claude --model=<id> --worktree <dir> --prompt-file <f> --result-file <f>');
|
|
152
168
|
return 2;
|
|
153
169
|
}
|
|
154
170
|
try {
|
|
@@ -159,4 +175,4 @@ async function main(argv) {
|
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
|
|
162
|
-
module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
|
|
178
|
+
module.exports = { otherVendorSession, parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
|
|
@@ -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,13 +148,74 @@ 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; delete base.ANTHROPIC_BASE_URL;
|
|
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, extra = {}) => {
|
|
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, ...extra };
|
|
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
|
+
// a session launched before KENAZ_PROVIDER existed, or with the env set by hand, is recognised by its base URL
|
|
206
|
+
const byUrl = disp('claude-opus-5', null, { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic' });
|
|
207
|
+
assert.equal(byUrl.j.executor.adapter, 'claude'); assert.equal(byUrl.j.agentParams, undefined);
|
|
208
|
+
const official = disp('claude-opus-5', null, { ANTHROPIC_BASE_URL: 'https://api.anthropic.com' }); // control
|
|
209
|
+
assert.equal(official.j.executor, undefined); assert.equal(official.j.agentParams.model, 'opus');
|
|
210
|
+
});
|
|
211
|
+
|
|
151
212
|
test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
|
|
152
213
|
const sb = tmp(t, 'exec-dispatch-');
|
|
153
214
|
for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
|
|
154
215
|
fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
|
|
155
216
|
fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
|
|
156
217
|
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;
|
|
218
|
+
const env = { ...process.env, DEEPSEEK_API_KEY: KEY }; delete env.ANTHROPIC_BASE_URL; delete env.KENAZ_PROVIDER; delete env.CLAUDE_PROJECT_DIR; delete env.CODEX_SESSION_ID; delete env.CODEX_THREAD_ID;
|
|
158
219
|
const sh = (script, args) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
|
|
159
220
|
const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2422', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
|
|
160
221
|
'--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force']);
|
|
@@ -1063,6 +1063,14 @@ 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
|
+
// A session started before KENAZ_PROVIDER existed, or with the env set by hand, is recognised by its base URL.
|
|
1069
|
+
const otherVendor = require('../bifrost/exec').otherVendorSession(process.env);
|
|
1070
|
+
if (!isCodex && !exec && otherVendor) {
|
|
1071
|
+
const fam = translateModelForAgentTool(rawModel).model;
|
|
1072
|
+
if (fam) exec = { adapter: 'claude', id: fam };
|
|
1073
|
+
}
|
|
1066
1074
|
// TASK_2315: Codex inherits the session model — nothing to translate or pass.
|
|
1067
1075
|
const translated = isCodex || exec ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
|
|
1068
1076
|
if (!isCodex && !exec && !translated.model) {
|
|
@@ -70,9 +70,15 @@ the real defect BEFORE carding or dispatching.
|
|
|
70
70
|
opus). dispatch.js moves the card to active.
|
|
71
71
|
- The dispatch model is the project's setting: `kenaz subagent` (or /kenaz subagent) shows it per rigor, `kenaz subagent set <id> [--rigor r]`
|
|
72
72
|
changes it (writes .kenaz/dashboard-models.json). Your own model is the user's session model (/model).
|
|
73
|
+
- When the user names a model in conversation, act on it yourself (user decision 2026-10-12), never on your own
|
|
74
|
+
judgment (2026-09-22): for one card ("use opus for this", "let deepseek do the frontend") pass
|
|
75
|
+
`--dispatch-model <id>` to dispatch.js (claude-opus-5-5, opus, codex:<id>, deepseek:<model>) and leave the setting
|
|
76
|
+
alone; for "from now on / always" run `kenaz subagent set <id> [--rigor r]` (core repo:
|
|
77
|
+
`node .formation/tools/dispatch-model.js set <id> [--rigor r]`) and say what changed. No model named = the setting.
|
|
73
78
|
- Executor model (`kenaz subagent set codex:<id>` or `deepseek:<model>`): dispatch.js prints an `executor` block instead of
|
|
74
79
|
agentParams. Run `executor.command` with Bash run_in_background, wait for the completion notice, read `executor.resultFile`
|
|
75
80
|
(ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
|
|
81
|
+
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
82
|
- Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
|
|
77
83
|
via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
|
|
78
84
|
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.17
|
|
@@ -14,6 +14,7 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
|
|
|
14
14
|
## 工作方式
|
|
15
15
|
- **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
|
|
16
16
|
- **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
|
|
17
|
+
- 用戶指定 model 就照做:單張卡加 `--dispatch-model <id>`(不改設定);「之後都用」才 `kenaz subagent set <id> [--rigor r]`。沒指定就照設定,不自己換。
|
|
17
18
|
- 開卡前確認 scope 與驗收條件對準真正的問題。
|
|
18
19
|
|
|
19
20
|
## 驗證(合併前,強度跟風險相稱)
|
|
@@ -1,28 +1,152 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"SessionStart": [
|
|
4
|
-
{
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-start"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "command",
|
|
12
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" guidance"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"UserPromptSubmit": [
|
|
18
|
+
{
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" prompt-runner"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
8
27
|
"PreToolUse": [
|
|
9
|
-
{
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
{
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"matcher": "Bash",
|
|
38
|
+
"hooks": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" synapse-first"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"matcher": "mcp__kenaz-dashboard__.*|Read|Edit|Write",
|
|
47
|
+
"hooks": [
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" memory-guard"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"matcher": "Agent|Task",
|
|
56
|
+
"hooks": [
|
|
57
|
+
{
|
|
58
|
+
"type": "command",
|
|
59
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" vendor-agent-guard"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
12
63
|
],
|
|
13
64
|
"PostToolUse": [
|
|
14
|
-
{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
65
|
+
{
|
|
66
|
+
"hooks": [
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"matcher": "TaskCreate|TaskUpdate",
|
|
75
|
+
"hooks": [
|
|
76
|
+
{
|
|
77
|
+
"type": "command",
|
|
78
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" task-bridge"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"PostToolUseFailure": [
|
|
84
|
+
{
|
|
85
|
+
"hooks": [
|
|
86
|
+
{
|
|
87
|
+
"type": "command",
|
|
88
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"Notification": [
|
|
94
|
+
{
|
|
95
|
+
"hooks": [
|
|
96
|
+
{
|
|
97
|
+
"type": "command",
|
|
98
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
"SubagentStart": [
|
|
104
|
+
{
|
|
105
|
+
"hooks": [
|
|
106
|
+
{
|
|
107
|
+
"type": "command",
|
|
108
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"SubagentStop": [
|
|
114
|
+
{
|
|
115
|
+
"hooks": [
|
|
116
|
+
{
|
|
117
|
+
"type": "command",
|
|
118
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"SessionEnd": [
|
|
124
|
+
{
|
|
125
|
+
"hooks": [
|
|
126
|
+
{
|
|
127
|
+
"type": "command",
|
|
128
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
"Stop": [
|
|
134
|
+
{
|
|
135
|
+
"hooks": [
|
|
136
|
+
{
|
|
137
|
+
"type": "command",
|
|
138
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "command",
|
|
142
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-stop"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"type": "command",
|
|
146
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" autonomy-stop"
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
]
|
|
27
151
|
}
|
|
28
152
|
}
|
|
@@ -31,6 +31,7 @@ const HOOKS = {
|
|
|
31
31
|
'autonomy-stop': { kind: 'script', file: 'autonomy-stop.js', call: 'main', minMode: 'full' },
|
|
32
32
|
'synapse-first': { kind: 'script', file: 'synapse-first-investigation.js', minMode: 'full' },
|
|
33
33
|
'memory-guard': { kind: 'script', file: 'memory-guard.js', call: 'main', minMode: 'full' },
|
|
34
|
+
'vendor-agent-guard': { kind: 'script', file: 'vendor-agent-guard.js', call: 'main', minMode: 'assist' },
|
|
34
35
|
'task-bridge': { kind: 'script', file: 'task-bridge.js', minMode: 'assist' },
|
|
35
36
|
};
|
|
36
37
|
|
package/lib/cli.js
CHANGED
|
@@ -35,7 +35,9 @@ 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
|
-
|
|
38
|
+
// installed core, then the repo checkout (dev: live source), then the core bundled in this npm package (npx
|
|
39
|
+
// before `kenaz install`). Bundled before repo ran a stale prepack copy from a dev checkout (2026-10-12).
|
|
40
|
+
for (const f of [path.join(p.core, ...rel), path.join(__dirname, '..', '..', '..', ...rel), path.join(PKG_ROOT, 'core', ...rel)]) {
|
|
39
41
|
if (fs.existsSync(f)) return require(f);
|
|
40
42
|
}
|
|
41
43
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
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.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.17",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.17",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.17",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.17",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.17"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|