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.
- package/core/.claude/commands/Ansuz.md +1 -0
- package/core/.claude/hooks/__tests__/vendor-agent-guard.test.js +44 -0
- package/core/.claude/hooks/synapse-first-investigation.js +15 -0
- package/core/.claude/hooks/vendor-agent-guard.js +40 -0
- package/core/.claude/settings.json +9 -0
- package/core/.formation/bifrost/exec.js +36 -4
- package/core/.formation/bifrost/provider-launch.js +18 -11
- package/core/.formation/bifrost/provider-launch.test.js +26 -2
- package/core/.formation/tools/__tests__/dispatch-cwd-project.test.js +42 -0
- package/core/.formation/tools/__tests__/subagent-executor.test.js +12 -6
- package/core/.formation/tools/dispatch.js +17 -2
- package/core/.specialist/Ansuz/RULES.md +5 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/scripts/run-rust-tests.js +21 -1
- package/core/dev/scripts/run-rust-tests.test.js +17 -0
- 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/core/plugins/kenaz/scripts/ygg.js +6 -0
- package/lib/cli.js +3 -2
- 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
|
+
});
|
|
@@ -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-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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 }.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
42
|
-
if (!
|
|
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-
|
|
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', '
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
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": [
|
|
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
|
|
|
@@ -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
|
|
39
|
-
|
|
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.
|
|
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.
|
|
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.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": {
|