kenaz 0.3.16 → 0.3.18

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.
@@ -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
+ });
@@ -201,9 +201,24 @@ function isDeduped(normalized) {
201
201
  }
202
202
  }
203
203
 
204
+ // Every Bash call appends a line; uncapped it reached 11.7 MB (2026-10-12). Past MAX_LOG_BYTES keep the newest
205
+ // KEEP_LOG_BYTES (cut at a line boundary), so the readers still see recent history.
206
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
207
+ const KEEP_LOG_BYTES = 1024 * 1024;
208
+ function capLog(file) {
209
+ let size = 0;
210
+ try { size = fs.statSync(file).size; } catch { return; }
211
+ if (size <= MAX_LOG_BYTES) return;
212
+ const buf = fs.readFileSync(file);
213
+ const tail = buf.subarray(buf.length - KEEP_LOG_BYTES);
214
+ const nl = tail.indexOf(10);
215
+ fs.writeFileSync(file, nl >= 0 ? tail.subarray(nl + 1) : tail);
216
+ }
217
+
204
218
  function appendTelemetry(record) {
205
219
  try {
206
220
  fs.mkdirSync(path.dirname(NUDGES_PATH), { recursive: true });
221
+ capLog(NUDGES_PATH);
207
222
  fs.appendFileSync(NUDGES_PATH, JSON.stringify(record) + '\n', 'utf8');
208
223
  } catch {
209
224
  // best-effort
@@ -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": [
@@ -19,7 +19,7 @@ const { spawn, spawnSync } = require('child_process');
19
19
 
20
20
  // First = default. From GET https://api.deepseek.com/models on 2026-10-11 (V4 ids, 1M context); the V3 ids still
21
21
  // answer but are no longer listed. `kenaz launch` re-reads the context window from that endpoint at start.
22
- const DEEPSEEK_MODELS = ['deepseek-v4-pro', 'deepseek-flash', 'deepseek-chat', 'deepseek-reasoner'];
22
+ const DEEPSEEK_MODELS = ['deepseek-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner']; // flash = default (user decision 2026-10-12)
23
23
  const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/anthropic';
24
24
  const DEEPSEEK_CONTEXT_TOKENS = { 'deepseek-v4-pro': 1048576, 'deepseek-flash': 1048576, 'deepseek-chat': 128000, 'deepseek-reasoner': 128000 };
25
25
  const DEEPSEEK_FAST = 'deepseek-flash'; // Claude Code's small/fast slot (titles, summaries): the cheap model
@@ -38,12 +38,37 @@ function parseExecutorModel(raw) {
38
38
  return { adapter, id };
39
39
  }
40
40
  if (adapter === 'deepseek') {
41
- if (!DEEPSEEK_MODELS.includes(id)) throw new Error(`unknown deepseek model '${id}'; valid: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
42
- return { adapter, id };
41
+ // Any id the vendor may add later is accepted (no release needed); `deepseek:` alone = the user's saved default.
42
+ const model = id || providerDefault('deepseek') || DEEPSEEK_MODELS[0];
43
+ if (!MODEL_ID.test(model)) throw new Error(`invalid deepseek model id '${id}' (letters, digits, . _ - only); known: ${DEEPSEEK_MODELS.map((i) => 'deepseek:' + i).join(', ')}`);
44
+ return { adapter, id: model };
43
45
  }
44
46
  throw new Error(`unknown executor prefix '${m[1]}:'; valid prefixes: codex:<id>, deepseek:<model>`);
45
47
  }
46
48
 
49
+ const MODEL_ID = /^[A-Za-z0-9][\w.-]{0,63}$/;
50
+ const configFile = (home) => path.join(home, '.kenaz', 'config.json');
51
+
52
+ /** The user's own default model for a provider: KENAZ_<PROVIDER>_MODEL, else ~/.kenaz/config.json
53
+ * providers.<provider>.model (written by `kenaz launch --provider p --model m --save-default`); null when unset. */
54
+ function providerDefault(provider, env = process.env, home = os.homedir()) {
55
+ const e = env['KENAZ_' + provider.toUpperCase() + '_MODEL'];
56
+ if (e && e.trim()) return e.trim();
57
+ try { const m = JSON.parse(fs.readFileSync(configFile(home), 'utf8')).providers[provider].model; return typeof m === 'string' && m.trim() ? m.trim() : null; } catch { return null; }
58
+ }
59
+
60
+ /** Save the default (other keys in config.json are kept; atomic write). */
61
+ function saveProviderDefault(provider, model, home = os.homedir()) {
62
+ let cfg = {};
63
+ try { cfg = JSON.parse(fs.readFileSync(configFile(home), 'utf8')) || {}; } catch { /* new file */ }
64
+ cfg.providers = { ...(cfg.providers || {}), [provider]: { ...((cfg.providers || {})[provider] || {}), model } };
65
+ fs.mkdirSync(path.dirname(configFile(home)), { recursive: true });
66
+ const tmp = configFile(home) + '.tmp';
67
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
68
+ fs.renameSync(tmp, configFile(home));
69
+ return configFile(home);
70
+ }
71
+
47
72
  /** DEEPSEEK_API_KEY, else deepseek_api_key in ~/.kenaz/credentials.yaml; null when neither. */
48
73
  function deepseekKey(env = process.env, home = os.homedir()) {
49
74
  if (env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY.trim()) return env.DEEPSEEK_API_KEY.trim();
@@ -92,6 +117,13 @@ async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000)
92
117
  return Object.keys(out).length ? out : null;
93
118
  } catch { return null; }
94
119
  }
120
+ /** True in a Claude Code session whose model requests go to another vendor (kenaz launch, or the env set by
121
+ * hand): KENAZ_PROVIDER, or ANTHROPIC_BASE_URL not on anthropic.com. The Agent tool there reaches only that vendor. */
122
+ function otherVendorSession(env = process.env) {
123
+ if (env.KENAZ_PROVIDER && env.KENAZ_PROVIDER !== 'anthropic') return true;
124
+ const u = String(env.ANTHROPIC_BASE_URL || '').replace(/^https?:\/\//i, '');
125
+ return !!u && !/(^|\.)anthropic\.com(\/|:|$)/i.test(u);
126
+ }
95
127
  const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
96
128
  // The claude adapter must reach Claude, not the vendor `kenaz launch` pointed this session at: drop every override
97
129
  // launch sets (the vendor key lives in ANTHROPIC_AUTH_TOKEN; DEEPSEEK_API_KEY is dropped too, the child does not need it).
@@ -168,4 +200,4 @@ async function main(argv) {
168
200
  }
169
201
 
170
202
  if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
171
- module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
203
+ module.exports = { otherVendorSession, providerDefault, saveProviderDefault, MODEL_ID, parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  /**
4
- * `kenaz launch --provider deepseek [--model deepseek-v4-pro|deepseek-flash|...] [claude args...]`
4
+ * `kenaz launch --provider deepseek [--model deepseek-flash (default)|deepseek-v4-pro|...] [claude args...]`
5
5
  * Starts Claude Code with every model request sent to another vendor's Anthropic-compatible endpoint, so
6
6
  * Ansuz (/kenaz:Ansuz), its subagents and background requests run on that vendor and use no Claude quota.
7
7
  * The key is read from the env or ~/.kenaz/credentials.yaml and set only in the claude child's env: never in
@@ -9,7 +9,7 @@
9
9
  */
10
10
  const os = require('os');
11
11
  const { spawn } = require('child_process');
12
- const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
12
+ const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote, providerDefault, saveProviderDefault, MODEL_ID } = require('./exec');
13
13
 
14
14
  const PROVIDERS = {
15
15
  deepseek: {
@@ -17,29 +17,31 @@ const PROVIDERS = {
17
17
  keyHelp: 'set DEEPSEEK_API_KEY, or add `deepseek_api_key: <key>` to ~/.kenaz/credentials.yaml',
18
18
  },
19
19
  };
20
- const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id>] [claude args...]';
20
+ const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id> [--save-default]] [claude args...]';
21
21
 
22
22
  function parse(argv) {
23
- const rest = []; let provider = null; let model = null;
23
+ const rest = []; let provider = null; let model = null; let save = false;
24
24
  for (let i = 0; i < argv.length; i++) {
25
+ if (argv[i] === '--save-default') { save = true; continue; }
25
26
  const m = /^--(provider|model)(?:=(.*))?$/.exec(argv[i]);
26
27
  if (!m) { rest.push(argv[i]); continue; }
27
28
  const v = m[2] !== undefined ? m[2] : argv[++i];
28
29
  if (m[1] === 'provider') provider = v; else model = v;
29
30
  }
30
- return { rest, provider, model };
31
+ return { rest, provider, model, save };
31
32
  }
32
33
 
33
- /** Child process plan: { bin, args, env }. live = {id: context_window} from the vendor's model list (or null):
34
- * a listed id is valid even if it is newer than the built-in list, and its window drives auto-compact.
35
- * Throws with a user-facing message. */
34
+ /** Child process plan: { bin, args, env }. Model = --model, else the user's own default (KENAZ_<P>_MODEL or
35
+ * ~/.kenaz/config.json, see providerDefault), else the built-in default. live = {id: context_window} from the
36
+ * vendor's model list (or null): when known it is the only validation (new vendor models work without a Kenaz
37
+ * release); when unreachable any well-formed id is accepted. Throws with a user-facing message. */
36
38
  function plan(argv, env = process.env, home = os.homedir(), live = null) {
37
39
  const { rest, provider, model } = parse(argv);
38
40
  const p = PROVIDERS[provider];
39
41
  if (!p) throw new Error((provider ? `unknown provider '${provider}'. ` : '') + USAGE);
40
- const id = model || p.models[0];
41
- const valid = [...new Set([...p.models, ...Object.keys(live || {})])];
42
- if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
42
+ const id = model || providerDefault(provider, env, home) || p.models[0];
43
+ if (!MODEL_ID.test(id)) throw new Error(`invalid ${provider} model id '${id}'`);
44
+ if (live && !(id in live)) throw new Error(`unknown ${provider} model '${id}'; the vendor currently lists: ${Object.keys(live).join(', ')}`);
43
45
  const key = p.key(env, home);
44
46
  if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
45
47
  const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
@@ -56,6 +58,11 @@ async function main(argv, out = console.log, deps = {}) {
56
58
  const live = key ? await (deps.live || p.live)(key) : null;
57
59
  let pl;
58
60
  try { pl = plan(argv, env, home, live); } catch (e) { out(e.message); return 2; }
61
+ const { model: asked, save } = parse(argv);
62
+ if (save) {
63
+ if (!asked) { out('--save-default needs --model <id>'); return 2; }
64
+ out(`kenaz launch: ${pl.provider} default model saved: ${pl.model} (${saveProviderDefault(pl.provider, pl.model, home)})`);
65
+ }
59
66
  out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}, context ${pl.context} tokens); no Claude quota is used in this session`);
60
67
  const win = (deps.platform || process.platform) === 'win32'; // claude is a .cmd shim on npm installs
61
68
  const child = (deps.spawn || spawn)(win ? [pl.bin, ...pl.args].map(winQuote).join(' ') : pl.bin, win ? [] : pl.args,
@@ -25,7 +25,7 @@ test('deepseek: every model slot points at DeepSeek, key only in the child env,
25
25
  assert.strictEqual(e.ANTHROPIC_BASE_URL, 'https://api.deepseek.com/anthropic');
26
26
  assert.strictEqual(e.ANTHROPIC_AUTH_TOKEN, KEY);
27
27
  for (const k of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
28
- assert.strictEqual(e[k], 'deepseek-v4-pro', k);
28
+ assert.strictEqual(e[k], 'deepseek-flash', k);
29
29
  }
30
30
  assert.strictEqual(e.ANTHROPIC_SMALL_FAST_MODEL, 'deepseek-flash'); assert.strictEqual(e.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'deepseek-flash');
31
31
  assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
@@ -54,7 +54,7 @@ test('control: no key, unknown provider or model -> exit 2 and claude is never s
54
54
  [['--provider', 'deepseek'], {}, /no API key/],
55
55
  [['--provider', 'nope'], { DEEPSEEK_API_KEY: KEY }, /unknown provider 'nope'/],
56
56
  [[], { DEEPSEEK_API_KEY: KEY }, /usage: kenaz launch --provider <deepseek>/],
57
- [['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-v4-pro, deepseek-flash, deepseek-chat, deepseek-reasoner/],
57
+ [['--provider', 'deepseek', '--model', 'bad id'], { DEEPSEEK_API_KEY: KEY }, /invalid deepseek model id/],
58
58
  ]) {
59
59
  const calls = [], lines = [];
60
60
  const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env, home: home(), platform: 'linux' });
@@ -74,3 +74,27 @@ test('live model list: a newer listed id is accepted and its context window driv
74
74
  assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '2097152');
75
75
  assert.match(lines[0], /context 2097152 tokens/);
76
76
  });
77
+
78
+ test('model not in the vendor list is refused only when the list is known; unknown list = accepted (no release needed)', async () => {
79
+ const calls = [], lines = [];
80
+ const rc = await main(['--provider', 'deepseek', '--model', 'gpt-x'], (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => ({ 'deepseek-flash': 1048576 }), env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' });
81
+ assert.strictEqual(rc, 2); assert.strictEqual(calls.length, 0); assert.match(lines.join(String.fromCharCode(10)), /the vendor currently lists: deepseek-flash/);
82
+ const calls2 = [];
83
+ assert.strictEqual(await main(['--provider', 'deepseek', '--model', 'deepseek-v9'], () => {}, { spawn: fakeSpawn(calls2), live: async () => null, env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' }), 0);
84
+ assert.strictEqual(calls2[0].opts.env.ANTHROPIC_MODEL, 'deepseek-v9');
85
+ });
86
+
87
+ test('the user picks the default: --save-default writes ~/.kenaz/config.json (other keys kept), env overrides it, --model overrides both', async () => {
88
+ const h = home(); fs.mkdirSync(path.join(h, '.kenaz'));
89
+ fs.writeFileSync(path.join(h, '.kenaz', 'config.json'), JSON.stringify({ setupCompleted: true }));
90
+ const run = async (argv, env = {}) => { const calls = [], lines = []; const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env: { DEEPSEEK_API_KEY: KEY, ...env }, home: h, platform: 'linux' }); return { rc, model: calls[0] && calls[0].opts.env.ANTHROPIC_MODEL, lines }; };
91
+ assert.strictEqual((await run(['--provider', 'deepseek'])).model, 'deepseek-flash', 'built-in default');
92
+ const saved = await run(['--provider', 'deepseek', '--model', 'deepseek-v4-pro', '--save-default']);
93
+ assert.strictEqual(saved.model, 'deepseek-v4-pro'); assert.match(saved.lines.join(String.fromCharCode(10)), /default model saved: deepseek-v4-pro/);
94
+ const cfg = JSON.parse(fs.readFileSync(path.join(h, '.kenaz', 'config.json'), 'utf8'));
95
+ assert.strictEqual(cfg.setupCompleted, true); assert.strictEqual(cfg.providers.deepseek.model, 'deepseek-v4-pro');
96
+ assert.strictEqual((await run(['--provider', 'deepseek'])).model, 'deepseek-v4-pro', 'saved default used');
97
+ assert.strictEqual((await run(['--provider', 'deepseek'], { KENAZ_DEEPSEEK_MODEL: 'deepseek-chat' })).model, 'deepseek-chat', 'env overrides saved');
98
+ assert.strictEqual((await run(['--provider', 'deepseek', '--model', 'deepseek-flash'])).model, 'deepseek-flash', '--model overrides');
99
+ assert.strictEqual((await run(['--provider', 'deepseek', '--save-default'])).rc, 2, '--save-default without --model');
100
+ });
@@ -0,0 +1,42 @@
1
+ // dispatch.js without --project-path, run from a user's project through the plugin (cwd = that project, ROOT = core):
2
+ // the card and the worktree belong to the cwd project, not the core (repro 2026-10-12: '任務不存在' and a worktree
3
+ // created inside the core repo, leaving Ansuz waiting on a subagent that had no card).
4
+ const test = require('node:test');
5
+ const assert = require('node:assert');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+
11
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
12
+ const DISPATCH = path.join(ROOT, '.formation', 'tools', 'dispatch.js');
13
+ const git = (cwd, args) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, encoding: 'utf8' });
14
+
15
+ function project() {
16
+ const p = fs.mkdtempSync(path.join(os.tmpdir(), 'dcp-'));
17
+ git(p, ['init', '-q']); fs.writeFileSync(path.join(p, 'README.md'), 'x'); git(p, ['add', '-A']); git(p, ['commit', '-q', '-m', 'init']);
18
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(p, '.kenaz', 'tasks', d), { recursive: true });
19
+ fs.writeFileSync(path.join(p, '.kenaz', 'project.yaml'), 'name: dcp\n');
20
+ fs.writeFileSync(path.join(p, '.kenaz', 'tasks', 'backlog', 'TASK_7201_x.yaml'),
21
+ 'id: TASK_7201\ntitle: "Unique card title 7201"\nstatus: backlog\npriority: low\nrigor: low\nrequirements: ["r"]\nacceptance_criteria: ["a"]\nscope_paths: [x.txt]\n');
22
+ return p;
23
+ }
24
+
25
+ const env = () => { const e = { ...process.env }; for (const k of ['CLAUDE_PROJECT_DIR', 'KENAZ_PROVIDER', 'ANTHROPIC_BASE_URL', 'CODEX_SESSION_ID', 'CODEX_THREAD_ID']) delete e[k]; return e; };
26
+
27
+ test('from a user project without --project-path: the card is found and isolation targets that project', () => {
28
+ const p = project();
29
+ const r = spawnSync(process.execPath, [DISPATCH, '--skill=do', '--task=TASK_7201', '--dispatch-mode=subagent', '--dry-run'], { cwd: p, env: env(), encoding: 'utf8' });
30
+ assert.strictEqual(r.status, 0, r.stderr);
31
+ assert.doesNotMatch(r.stderr, /任務不存在/);
32
+ const j = JSON.parse(r.stdout);
33
+ assert.match(j.promptText, /Unique card title 7201/);
34
+ assert.match(r.stderr, new RegExp('cross-project detected .*' + path.basename(p)));
35
+ });
36
+
37
+ test('control: in the core repo itself nothing changes (same-project Agent worktree)', () => {
38
+ const r = spawnSync(process.execPath, [DISPATCH, '--skill=do', '--task=TASK_2376', '--dispatch-mode=subagent', '--dry-run'], { cwd: ROOT, env: env(), encoding: 'utf8' });
39
+ assert.strictEqual(r.status, 0, r.stderr);
40
+ const j = JSON.parse(r.stdout);
41
+ assert.strictEqual(j.isolation.mode, 'agent_tool_worktree');
42
+ });
@@ -139,8 +139,9 @@ test('subagent set: unknown codex id and unknown prefix are refused with the val
139
139
  const p = tmp(t, 'dm-');
140
140
  const a = dmRun(['set', 'codex:nope'], p);
141
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/);
142
+ // any well-formed deepseek id is accepted (a new vendor model needs no Kenaz release); a malformed one is refused
143
+ const b = dmRun(['set', 'deepseek:bad id'], p);
144
+ assert.equal(b.code, 1); assert.match(b.out, /invalid deepseek model id/);
144
145
  const c = dmRun(['set', 'gemini:pro'], p);
145
146
  assert.equal(c.code, 1); assert.match(c.out, /unknown executor prefix 'gemini:'.*codex:<id>, deepseek:<model>/);
146
147
  assert.equal(fs.existsSync(path.join(p, '.kenaz', 'dashboard-models.json')), false);
@@ -178,14 +179,14 @@ test('dispatch under KENAZ_PROVIDER: Claude id -> executor adapter claude, no ag
178
179
  fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
179
180
  fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
180
181
  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 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
183
  const sh = (script, args, env) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
183
184
  const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2430', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
184
185
  '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force'], base);
185
186
  const id = ((mk.stdout || '').match(/TASK_\d+/) || [])[0]; assert.ok(id, mk.stdout + mk.stderr);
186
- const disp = (model, provider) => {
187
+ const disp = (model, provider, extra = {}) => {
187
188
  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 env = { ...base, ...(provider ? { KENAZ_PROVIDER: provider } : {}), DEEPSEEK_API_KEY: KEY, ...extra };
189
190
  const r = sh(DISPATCH, ['--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force'], env);
190
191
  return { r, j: (() => { try { return JSON.parse(r.stdout); } catch { return null; } })() };
191
192
  };
@@ -202,6 +203,11 @@ test('dispatch under KENAZ_PROVIDER: Claude id -> executor adapter claude, no ag
202
203
  const none = disp('claude-sonnet-5-5', null); // control: no KENAZ_PROVIDER = byte-for-byte as before
203
204
  assert.equal(none.j.executor, undefined);
204
205
  assert.deepEqual(Object.keys(none.j.agentParams), ['description', 'model']); assert.equal(none.j.agentParams.model, 'sonnet');
206
+ // a session launched before KENAZ_PROVIDER existed, or with the env set by hand, is recognised by its base URL
207
+ const byUrl = disp('claude-opus-5', null, { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic' });
208
+ assert.equal(byUrl.j.executor.adapter, 'claude'); assert.equal(byUrl.j.agentParams, undefined);
209
+ const official = disp('claude-opus-5', null, { ANTHROPIC_BASE_URL: 'https://api.anthropic.com' }); // control
210
+ assert.equal(official.j.executor, undefined); assert.equal(official.j.agentParams.model, 'opus');
205
211
  });
206
212
 
207
213
  test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
@@ -210,7 +216,7 @@ test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams f
210
216
  fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
211
217
  fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
212
218
  git(sb, ['init', '-q']); git(sb, ['add', '-A']); git(sb, ['commit', '-q', '--no-verify', '-m', 'init']);
213
- const env = { ...process.env, DEEPSEEK_API_KEY: KEY }; delete env.CLAUDE_PROJECT_DIR; delete env.CODEX_SESSION_ID; delete env.CODEX_THREAD_ID;
219
+ 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;
214
220
  const sh = (script, args) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
215
221
  const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2422', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
216
222
  '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force']);
@@ -487,6 +487,15 @@ function samePath(a, b) {
487
487
  * @param {boolean|null} [opts.useWorktreeOverride] - dispatch-time toggle (CLI --use-worktree)
488
488
  * @returns {{ isCrossProject: boolean, mode: string, useWorktree: boolean, source: string, verifyOffline: string|null }}
489
489
  */
490
+ /** The Kenaz project around the cwd (CLAUDE_PROJECT_DIR first), or null when it is the core itself / none. */
491
+ function cwdProject(start = process.env.CLAUDE_PROJECT_DIR || process.cwd()) {
492
+ let root = null;
493
+ try { root = require('../../.claude/hooks/_project-guard').findProjectRoot(start); } catch { return null; }
494
+ if (!root) return null;
495
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
496
+ return norm(root) === norm(ROOT) ? null : root;
497
+ }
498
+
490
499
  function resolveDispatchIsolation({ projectPath = null, useWorktreeOverride = null } = {}) {
491
500
  // TASK_1710 semantics: samePath, not raw string compare (separator/case).
492
501
  const isCrossProject = !!(projectPath && !samePath(projectPath, ROOT));
@@ -829,7 +838,11 @@ Sub-agent dispatch (Ansuz billing-aware path):
829
838
  const formation = getArg('--formation') || 'unit';
830
839
  const skill = getArg('--skill');
831
840
  const task = getArg('--task');
832
- const projectPath = getArg('--project-path');
841
+ // No --project-path: the Kenaz project around the cwd, when that is not the core itself. Plugin users run
842
+ // dispatch.js from their own project through core-resolve, so ROOT is the core: before this, the card was looked
843
+ // up in the core ("任務不存在") and the worktree was created in the core repo, leaving Ansuz waiting on a
844
+ // subagent with no card in the wrong repo (repro 2026-10-12).
845
+ const projectPath = getArg('--project-path') || cwdProject();
833
846
  const dryRun = args.includes('--dry-run');
834
847
  const dispatchModeCli = getArg('--dispatch-mode');
835
848
  // TASK_1998 R3: subagent-path model override — wins over dashboard
@@ -1065,7 +1078,9 @@ async function getSubagentDispatchParams(options) {
1065
1078
  }
1066
1079
  // TASK_2430: in a `kenaz launch` session the Agent tool only reaches the other vendor, so a Claude model
1067
1080
  // 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') {
1081
+ // A session started before KENAZ_PROVIDER existed, or with the env set by hand, is recognised by its base URL.
1082
+ const otherVendor = require('../bifrost/exec').otherVendorSession(process.env);
1083
+ if (!isCodex && !exec && otherVendor) {
1069
1084
  const fam = translateModelForAgentTool(rawModel).model;
1070
1085
  if (fam) exec = { adapter: 'claude', id: fam };
1071
1086
  }
@@ -70,6 +70,11 @@ 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.
@@ -1 +1 @@
1
- 0.3.16
1
+ 0.3.18
@@ -115,6 +115,24 @@ function renderCompilerErrors(stdoutText, maxErrors = 20) {
115
115
  return out;
116
116
  }
117
117
 
118
+ // The shared verify cache only grows (incremental builds are never pruned): it reached 551 GB by 2026-10-12.
119
+ // Every KENAZ_VERIFY_CLEAN_DAYS (default 7) days run `cargo clean` on it first. A stamp file, not a size walk:
120
+ // walking hundreds of GB takes minutes. cargo clean takes cargo's own lock, so a running build is waited for.
121
+ const CLEAN_STAMP = '.kenaz-last-clean';
122
+ function periodicClean(targetDir, env, { now = Date.now(), run = spawnSync, log = (m) => process.stderr.write(m) } = {}) {
123
+ const days = Number(env.KENAZ_VERIFY_CLEAN_DAYS || 7);
124
+ const stamp = path.join(targetDir, CLEAN_STAMP);
125
+ let last = 0;
126
+ try { last = fs.statSync(stamp).mtimeMs; } catch (_) { /* never stamped */ }
127
+ const touch = () => { try { fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(stamp, new Date(now).toISOString() + '\n'); } catch (_) { /* best effort */ } };
128
+ if (!last) { touch(); return 'stamped'; } // first run after this change: start the clock, do not wipe
129
+ if (!(days > 0) || now - last < days * 86400000) return 'fresh';
130
+ log(`[run-rust-tests] verify cache last cleaned ${Math.round((now - last) / 86400000)} days ago: cargo clean (one full rebuild follows)\n`);
131
+ const r = run('cargo', ['clean'], { cwd: crateDir, env, encoding: 'utf8', shell: false });
132
+ touch();
133
+ return r && r.status === 0 ? 'cleaned' : 'clean-failed';
134
+ }
135
+
118
136
  function main() {
119
137
  const exeArgs = buildExeArgs(process.argv.slice(2));
120
138
 
@@ -131,6 +149,8 @@ function main() {
131
149
  );
132
150
  }
133
151
 
152
+ if (env.CARGO_TARGET_DIR === verifyTargetDir()) periodicClean(env.CARGO_TARGET_DIR, env);
153
+
134
154
  // 1. Build the lib test binary without running it.
135
155
  const build = spawnSync(
136
156
  'cargo',
@@ -204,4 +224,4 @@ if (require.main === module) {
204
224
  main();
205
225
  }
206
226
 
207
- module.exports = { parseArgs, buildExeArgs, renderCompilerErrors };
227
+ module.exports = { parseArgs, buildExeArgs, renderCompilerErrors, periodicClean, CLEAN_STAMP };
@@ -152,3 +152,20 @@ test('renderCompilerErrors: cap applied -> first N kept + omitted-count note', (
152
152
  'error[E0001]: synthetic error 1\n... 2 more errors omitted\n'
153
153
  );
154
154
  });
155
+
156
+ test('periodicClean: first run only stamps; fresh = nothing; older than N days = cargo clean then restamp', () => {
157
+ const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
158
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rrt-clean-'));
159
+ const calls = [];
160
+ const run = (cmd, args) => { calls.push([cmd, ...args].join(' ')); return { status: 0 }; };
161
+ const t0 = Date.parse('2026-10-01T00:00:00Z');
162
+ assert.equal(mod.periodicClean(dir, {}, { now: t0, run, log: () => {} }), 'stamped');
163
+ assert.deepEqual(calls, [], 'never wipes on the first run');
164
+ const stamp = path.join(dir, mod.CLEAN_STAMP);
165
+ fs.utimesSync(stamp, t0 / 1000, t0 / 1000);
166
+ assert.equal(mod.periodicClean(dir, {}, { now: t0 + 6 * 86400000, run, log: () => {} }), 'fresh');
167
+ assert.deepEqual(calls, []);
168
+ assert.equal(mod.periodicClean(dir, {}, { now: t0 + 8 * 86400000, run, log: () => {} }), 'cleaned');
169
+ assert.deepEqual(calls, ['cargo clean']);
170
+ assert.equal(mod.periodicClean(dir, { KENAZ_VERIFY_CLEAN_DAYS: '30' }, { now: t0 + 10 * 86400000, run, log: () => {} }), 'fresh', 'restamped, and the window is configurable');
171
+ });
@@ -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": [{ "hooks": [
4
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-start" },
5
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" guidance" }
6
- ] }],
7
- "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" prompt-runner" }] }],
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
- { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] },
10
- { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" synapse-first" }] },
11
- { "matcher": "mcp__kenaz-dashboard__.*|Read|Edit|Write", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" memory-guard" }] }
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
- { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] },
15
- { "matcher": "TaskCreate|TaskUpdate", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" task-bridge" }] }
16
- ],
17
- "PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
18
- "Notification": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
19
- "SubagentStart": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
20
- "SubagentStop": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
21
- "SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" }] }],
22
- "Stop": [{ "hooks": [
23
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" flow-post" },
24
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" session-stop" },
25
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kenaz-hook.js\" autonomy-stop" }
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
 
@@ -76,6 +76,10 @@ function buildStatus(cfg, relay, peer) {
76
76
  waiting_mail: peer && typeof peer.waiting_count === 'number' ? peer.waiting_count : null,
77
77
  undeliverable_mail: peer && typeof peer.undeliverable_count === 'number' ? peer.undeliverable_count : null,
78
78
  // TASK_2380: real delivery mode per live session (push | pull-on-prompt | none), never a bare 'enabled'.
79
+ // TASK_2436: when the poller last reached the relay, and the relay's same-name warning.
80
+ last_poll_at: peer && peer.last_poll_at ? peer.last_poll_at : null,
81
+ last_poll_ok: peer && typeof peer.last_poll_ok === 'boolean' ? peer.last_poll_ok : null,
82
+ same_name_warning: peer && peer.same_name_warning ? peer.same_name_warning : '',
79
83
  delivery_mode: peer && peer.delivery_mode ? peer.delivery_mode : 'unknown',
80
84
  delivery_note: peer && peer.delivery_note ? peer.delivery_note : '',
81
85
  sessions: peer && Array.isArray(peer.sessions) ? peer.sessions : [],
@@ -95,6 +99,7 @@ function renderStatus(s) {
95
99
  'Pending mail: ' + (s.pending_mail == null ? 'unknown' : s.pending_mail),
96
100
  'Waiting for a session: ' + (s.waiting_mail == null ? 'unknown' : s.waiting_mail) + ' (stored on this node, readable)',
97
101
  'Undeliverable: ' + (s.undeliverable_mail == null ? 'unknown' : s.undeliverable_mail),
102
+ 'Last poll: ' + (s.last_poll_at ? s.last_poll_at + (s.last_poll_ok === false ? ' (FAILED)' : ' (ok)') : 'none yet'),
98
103
  'Delivery: ' + s.delivery_mode + (s.sessions.length ? '' : ' (no live sessions)'),
99
104
  ];
100
105
  for (const x of s.sessions) L.push(' - ' + x.agent + ' ' + String(x.session_id).slice(0, 8) + ': ' + x.delivery_mode);
@@ -102,6 +107,7 @@ function renderStatus(s) {
102
107
  L.push('', 'New team key (shown once, save it): ' + s.created.key + (s.created.team_name ? ' [' + s.created.team_name + ']' : ''));
103
108
  L.push('share with teammates: kenaz ygg join ' + s.created.key);
104
109
  }
110
+ if (s.same_name_warning) L.push('WARNING: ' + s.same_name_warning);
105
111
  if (s.room_warning) L.push('WARNING: ' + s.room_warning);
106
112
  if (s.delivery_note) L.push('Note: ' + s.delivery_note);
107
113
  return L.join('\n');
package/lib/cli.js CHANGED
@@ -35,8 +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
- // 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)]) {
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)]) {
40
41
  if (fs.existsSync(f)) return require(f);
41
42
  }
42
43
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
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.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"
23
+ "@kenazai/server-darwin-arm64": "0.3.18",
24
+ "@kenazai/server-darwin-x64": "0.3.18",
25
+ "@kenazai/server-linux-arm64": "0.3.18",
26
+ "@kenazai/server-linux-x64": "0.3.18",
27
+ "@kenazai/server-win32-x64": "0.3.18"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {