kenaz 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/.claude/commands/Ansuz.md +11 -1
- package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
- package/core/.claude/hooks/_run-user-prompt.js +2 -0
- package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
- package/core/.claude/hooks/correction-detect.js +99 -0
- package/core/.formation/bifrost/exec.js +147 -0
- package/core/.formation/bifrost/provider-launch.js +57 -0
- package/core/.formation/bifrost/provider-launch.test.js +62 -0
- package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
- package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
- package/core/.formation/tools/dispatch-model.js +15 -10
- package/core/.formation/tools/dispatch.js +46 -10
- package/core/.formation/tools/kenaz-stats.js +57 -16
- package/core/.formation/tools/kenaz-stats.test.js +2 -2
- package/core/.specialist/Ansuz/RULES.md +7 -2
- package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
- package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
- package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
- package/core/.specialist/tools/handoff-doc.js +95 -0
- package/core/.specialist/tools/lessons-harvest.js +243 -0
- package/core/.specialist/tools/lessons-lifecycle.js +103 -0
- package/core/.specialist/tools/lessons.js +149 -24
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +1 -1
- package/core/dev/scripts/correction-detect-eval.js +70 -0
- package/core/plugins/kenaz/commands/Ansuz.md +11 -1
- package/core/plugins/kenaz/commands/kenaz.md +5 -3
- package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
- package/core/plugins/kenaz/scripts/statusline.js +60 -0
- package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
- package/lib/cli.js +11 -4
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +27 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2422: codex:/deepseek: executors. Stub `codex` and `claude` on PATH, temp dirs only
|
|
3
|
+
// (never the real ~/.codex, ~/.claude, ~/.kenaz; no real codex/claude/wscript is ever started).
|
|
4
|
+
const { test } = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
12
|
+
const EXEC = path.join(ROOT, '.formation', 'bifrost', 'exec.js');
|
|
13
|
+
const DISPATCH = path.join(ROOT, '.formation', 'tools', 'dispatch.js');
|
|
14
|
+
const TASKAPI = path.join(ROOT, '.collaboration', 'core', 'task-api.js');
|
|
15
|
+
const dm = require('../dispatch-model');
|
|
16
|
+
const KEY = 'sk-test-DEEPSEEK-SECRET-1234567890';
|
|
17
|
+
|
|
18
|
+
const tmp = (t, p) => { const d = fs.mkdtempSync(path.join(os.tmpdir(), p)); t.after(() => fs.rmSync(d, { recursive: true, force: true })); return d; };
|
|
19
|
+
const git = (cwd, args) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, encoding: 'utf8', windowsHide: true });
|
|
20
|
+
function repo(t) {
|
|
21
|
+
const d = tmp(t, 'exec-wt-');
|
|
22
|
+
fs.writeFileSync(path.join(d, 'README.md'), 'x'); git(d, ['init', '-q']); git(d, ['add', '-A']); git(d, ['commit', '-q', '--no-verify', '-m', 'init']);
|
|
23
|
+
return d;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One stub script serves both binaries: records argv/env/stdin to STUB_OUT/<name>.json, optionally commits.
|
|
27
|
+
const STUB_JS = `
|
|
28
|
+
const fs = require('fs'), path = require('path'), cp = require('child_process');
|
|
29
|
+
const name = path.basename(process.argv[1], '.js');
|
|
30
|
+
let stdin = ''; process.stdin.on('data', (d) => { stdin += d; });
|
|
31
|
+
process.stdin.on('end', () => {
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
fs.writeFileSync(path.join(process.env.STUB_OUT, name + '.json'), JSON.stringify({ argv, cwd: process.cwd(), stdin,
|
|
34
|
+
env: { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } }));
|
|
35
|
+
if (process.env.STUB_WRITE_ONLY) fs.writeFileSync(path.join(process.cwd(), 'left.txt'), 'x'); // a sandboxed agent: edits, cannot commit
|
|
36
|
+
if (process.env.STUB_COMMIT) {
|
|
37
|
+
fs.writeFileSync(path.join(process.cwd(), 'out.txt'), 'x');
|
|
38
|
+
cp.spawnSync('git', ['add', '-A'], { cwd: process.cwd() });
|
|
39
|
+
cp.spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--no-verify', '-m', 'stub work'], { cwd: process.cwd() });
|
|
40
|
+
}
|
|
41
|
+
const o = argv.indexOf('-o'); if (o >= 0) fs.writeFileSync(argv[o + 1], 'codex last message');
|
|
42
|
+
if (name === 'claude') console.log('claude says ' + (process.env.STUB_ECHO_KEY ? process.env.ANTHROPIC_AUTH_TOKEN : 'done'));
|
|
43
|
+
process.exit(Number(process.env.STUB_EXIT || 0));
|
|
44
|
+
});`;
|
|
45
|
+
function stubs(t) {
|
|
46
|
+
const bin = tmp(t, 'exec-bin-');
|
|
47
|
+
for (const n of ['codex', 'claude']) {
|
|
48
|
+
fs.writeFileSync(path.join(bin, n + '.js'), STUB_JS);
|
|
49
|
+
if (process.platform === 'win32') fs.writeFileSync(path.join(bin, n + '.cmd'), `@node "%~dp0${n}.js" %*\r\n`);
|
|
50
|
+
else { fs.writeFileSync(path.join(bin, n), `#!/bin/sh\nexec node "$(dirname "$0")/${n}.js" "$@"\n`); fs.chmodSync(path.join(bin, n), 0o755); }
|
|
51
|
+
}
|
|
52
|
+
return bin;
|
|
53
|
+
}
|
|
54
|
+
function runExec(t, { adapter, model, extraEnv = {}, key = KEY }) {
|
|
55
|
+
const wt = repo(t); const bin = stubs(t); const out = tmp(t, 'exec-out-'); const home = tmp(t, 'exec-home-');
|
|
56
|
+
const prompt = path.join(out, 'p.md'); fs.writeFileSync(prompt, 'DO THE CARD');
|
|
57
|
+
const result = path.join(out, 'r.json');
|
|
58
|
+
const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH, STUB_OUT: out, HOME: home, USERPROFILE: home, STUB_COMMIT: '1', ...extraEnv };
|
|
59
|
+
delete env.DEEPSEEK_API_KEY; delete env.ANTHROPIC_API_KEY; if (key) env.DEEPSEEK_API_KEY = key;
|
|
60
|
+
const r = spawnSync(process.execPath, [EXEC, `--adapter=${adapter}`, `--model=${model}`, '--worktree', wt, '--prompt-file', prompt, '--result-file', result], { env, encoding: 'utf8', windowsHide: true });
|
|
61
|
+
const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(out, f), 'utf8')); } catch { return null; } };
|
|
62
|
+
return { r, wt, out, result: fs.existsSync(result) ? JSON.parse(fs.readFileSync(result, 'utf8')) : null, resultPath: result, codex: read('codex.json'), claude: read('claude.json'), home };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('codex executor: exact argv, prompt on stdin, result file with commits', (t) => {
|
|
66
|
+
const x = runExec(t, { adapter: 'codex', model: 'gpt-5.6-sol-high' });
|
|
67
|
+
assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
|
|
68
|
+
const gitDir = require('child_process').execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: x.wt, encoding: 'utf8' }).trim();
|
|
69
|
+
assert.deepEqual(x.codex.argv, ['exec', '-C', x.wt, '-m', 'gpt-5.6-sol', '-c', 'model_reasoning_effort=high', '--sandbox', 'workspace-write', '-o', x.resultPath + '.last.txt', '--add-dir', gitDir]);
|
|
70
|
+
assert.match(x.codex.stdin, /^DO THE CARD\n\n## Executor note\n/);
|
|
71
|
+
assert.equal(fs.realpathSync(x.codex.cwd), fs.realpathSync(x.wt));
|
|
72
|
+
assert.equal(x.result.ok, true); assert.equal(x.result.exitCode, 0);
|
|
73
|
+
assert.equal(x.result.lastMessage, 'codex last message');
|
|
74
|
+
assert.match(x.result.commits[0], /stub work/);
|
|
75
|
+
assert.ok(fs.existsSync(x.wt), 'worktree is never removed');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('codex default (empty id): no -m, project default effort', (t) => {
|
|
79
|
+
const x = runExec(t, { adapter: 'codex', model: '' });
|
|
80
|
+
assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
|
|
81
|
+
assert.equal(x.codex.argv.includes('-m'), false);
|
|
82
|
+
assert.ok(x.codex.argv.includes('model_reasoning_effort=medium'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('codex without commits is reported as failure (no commits)', (t) => {
|
|
86
|
+
const x = runExec(t, { adapter: 'codex', model: '', extraEnv: { STUB_COMMIT: '' } });
|
|
87
|
+
assert.equal(x.r.status, 1);
|
|
88
|
+
assert.equal(x.result.ok, false); assert.equal(x.result.error, 'no commits');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('deepseek executor: env only in the child, key absent from argv, stdout, result file', (t) => {
|
|
92
|
+
const x = runExec(t, { adapter: 'deepseek', model: 'deepseek-chat', extraEnv: { ANTHROPIC_API_KEY: 'real-anthropic-key', STUB_ECHO_KEY: '1' } });
|
|
93
|
+
assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
|
|
94
|
+
assert.deepEqual(x.claude.argv, ['-p', '--allowedTools', 'Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep']);
|
|
95
|
+
assert.deepEqual(x.claude.env, { ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', ANTHROPIC_AUTH_TOKEN: KEY, ANTHROPIC_MODEL: 'deepseek-chat' });
|
|
96
|
+
assert.match(x.claude.stdin, /^DO THE CARD\n\n## Executor note\n/);
|
|
97
|
+
assert.equal(JSON.stringify(x.claude.argv).includes(KEY), false);
|
|
98
|
+
assert.equal((x.r.stdout + x.r.stderr).includes(KEY), false);
|
|
99
|
+
const raw = fs.readFileSync(x.resultPath, 'utf8');
|
|
100
|
+
assert.equal(raw.includes(KEY), false);
|
|
101
|
+
assert.match(x.result.lastMessage, /claude says \*\*\*/); // the stub echoed the key: it was redacted
|
|
102
|
+
assert.equal(JSON.stringify(x.result).includes('real-anthropic-key'), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('deepseek key from ~/.kenaz/credentials.yaml; missing key fails before anything starts', (t) => {
|
|
106
|
+
const none = runExec(t, { adapter: 'deepseek', model: 'deepseek-chat', key: null });
|
|
107
|
+
assert.equal(none.r.status, 2); assert.match(none.r.stderr, /no API key/);
|
|
108
|
+
assert.equal(none.claude, null); assert.equal(none.result, null);
|
|
109
|
+
// credentials file route (home is a temp dir)
|
|
110
|
+
const wt = repo(t); const bin = stubs(t); const out = tmp(t, 'exec-out-'); const home = tmp(t, 'exec-home-');
|
|
111
|
+
fs.mkdirSync(path.join(home, '.kenaz')); fs.writeFileSync(path.join(home, '.kenaz', 'credentials.yaml'), `other: 1\ndeepseek_api_key: "${KEY}"\n`);
|
|
112
|
+
const prompt = path.join(out, 'p.md'); fs.writeFileSync(prompt, 'P');
|
|
113
|
+
const env = { ...process.env, PATH: bin + path.delimiter + process.env.PATH, STUB_OUT: out, HOME: home, USERPROFILE: home, STUB_COMMIT: '1' };
|
|
114
|
+
delete env.DEEPSEEK_API_KEY;
|
|
115
|
+
const r = spawnSync(process.execPath, [EXEC, '--adapter=deepseek', '--model=deepseek-reasoner', '--worktree', wt, '--prompt-file', prompt, '--result-file', path.join(out, 'r.json')], { env, encoding: 'utf8', windowsHide: true });
|
|
116
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
117
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'claude.json'), 'utf8')).env.ANTHROPIC_AUTH_TOKEN, KEY);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---- kenaz subagent set/unset/show ----
|
|
121
|
+
const dmRun = (argv, cwd) => { const out = []; const code = dm.main(argv, (l) => out.push(l), cwd); return { code, out: out.join('\n') }; };
|
|
122
|
+
const modelsOf = (p) => JSON.parse(fs.readFileSync(path.join(p, '.kenaz', 'dashboard-models.json'), 'utf8'));
|
|
123
|
+
|
|
124
|
+
test('subagent set/unset/show: codex:, deepseek:, codex: alone', (t) => {
|
|
125
|
+
const p = tmp(t, 'dm-');
|
|
126
|
+
assert.equal(dmRun(['set', 'codex:gpt-5.6-sol'], p).code, 0);
|
|
127
|
+
assert.equal(dmRun(['set', 'deepseek:deepseek-chat', '--rigor', 'low'], p).code, 0);
|
|
128
|
+
assert.equal(dmRun(['set', 'codex:', '--rigor', 'high'], p).code, 0);
|
|
129
|
+
assert.deepEqual(modelsOf(p), { dispatchModel: 'codex:gpt-5.6-sol', dispatchModelByRigor: { low: 'deepseek:deepseek-chat', high: 'codex:' } });
|
|
130
|
+
const shown = dmRun([], p).out;
|
|
131
|
+
assert.match(shown, /low\s+deepseek:deepseek-chat -> executor deepseek/);
|
|
132
|
+
assert.match(shown, /medium\s+codex:gpt-5.6-sol -> executor codex/);
|
|
133
|
+
assert.match(shown, /high\s+codex: -> executor codex/);
|
|
134
|
+
dmRun(['unset', '--rigor', 'low'], p); dmRun(['unset'], p);
|
|
135
|
+
assert.deepEqual(modelsOf(p), { dispatchModelByRigor: { high: 'codex:' } });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('subagent set: unknown codex id and unknown prefix are refused with the valid list; nothing written', (t) => {
|
|
139
|
+
const p = tmp(t, 'dm-');
|
|
140
|
+
const a = dmRun(['set', 'codex:nope'], p);
|
|
141
|
+
assert.equal(a.code, 1); assert.match(a.out, /unknown codex model 'nope'.*codex:gpt-5\.6-sol/);
|
|
142
|
+
const b = dmRun(['set', 'deepseek:gpt'], p);
|
|
143
|
+
assert.equal(b.code, 1); assert.match(b.out, /deepseek:deepseek-chat, deepseek:deepseek-reasoner/);
|
|
144
|
+
const c = dmRun(['set', 'gemini:pro'], p);
|
|
145
|
+
assert.equal(c.code, 1); assert.match(c.out, /unknown executor prefix 'gemini:'.*codex:<id>, deepseek:<model>/);
|
|
146
|
+
assert.equal(fs.existsSync(path.join(p, '.kenaz', 'dashboard-models.json')), false);
|
|
147
|
+
assert.equal(dmRun(['set', 'opus'], p).code, 0, 'plain Claude ids keep working');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---- dispatch.js: executor block vs the unchanged Claude control ----
|
|
151
|
+
test('dispatch: executor block for codex:/deepseek:, byte-for-byte agentParams for a Claude id', (t) => {
|
|
152
|
+
const sb = tmp(t, 'exec-dispatch-');
|
|
153
|
+
for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(sb, '.kenaz', 'tasks', d), { recursive: true });
|
|
154
|
+
fs.mkdirSync(path.join(sb, '.kenaz', 'locks'), { recursive: true });
|
|
155
|
+
fs.writeFileSync(path.join(sb, 'README.md'), 'x'); fs.writeFileSync(path.join(sb, '.gitignore'), '.formation/worktrees/\n.kenaz/\n');
|
|
156
|
+
git(sb, ['init', '-q']); git(sb, ['add', '-A']); git(sb, ['commit', '-q', '--no-verify', '-m', 'init']);
|
|
157
|
+
const env = { ...process.env, DEEPSEEK_API_KEY: KEY }; delete env.CLAUDE_PROJECT_DIR; delete env.CODEX_SESSION_ID; delete env.CODEX_THREAD_ID;
|
|
158
|
+
const sh = (script, args) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', windowsHide: true, cwd: ROOT });
|
|
159
|
+
const mk = sh(TASKAPI, ['--project-path', sb, 'create', '--title', 't2422', '--description', 'd', '--scope-paths', 'zzz/a.js', '--requirements', 'r1',
|
|
160
|
+
'--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force']);
|
|
161
|
+
const id = ((mk.stdout || '').match(/TASK_\d+/) || [])[0]; assert.ok(id, mk.stdout + mk.stderr);
|
|
162
|
+
const disp = (model) => {
|
|
163
|
+
fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: model }));
|
|
164
|
+
const r = sh(DISPATCH, ['--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force']);
|
|
165
|
+
return { r, j: (() => { try { return JSON.parse(r.stdout); } catch { return null; } })() };
|
|
166
|
+
};
|
|
167
|
+
const cx = disp('codex:gpt-5.6-sol');
|
|
168
|
+
assert.ok(cx.j, cx.r.stdout + cx.r.stderr);
|
|
169
|
+
assert.equal(cx.j.agentParams, undefined);
|
|
170
|
+
assert.equal(cx.j.executor.adapter, 'codex'); assert.equal(cx.j.executor.branchName, `task/${id.toLowerCase()}`);
|
|
171
|
+
assert.ok(fs.existsSync(cx.j.executor.worktreePath), 'worktree created');
|
|
172
|
+
assert.equal(fs.readFileSync(cx.j.executor.promptFile, 'utf8'), cx.j.promptText);
|
|
173
|
+
assert.match(cx.j.executor.command, /exec\.js" --adapter=codex --model="gpt-5\.6-sol" --worktree/);
|
|
174
|
+
const ds = disp('deepseek:deepseek-chat');
|
|
175
|
+
assert.equal(ds.j.executor.adapter, 'deepseek');
|
|
176
|
+
assert.equal(ds.r.stdout.includes(KEY) || ds.r.stderr.includes(KEY), false, 'key never in the dispatch JSON or logs');
|
|
177
|
+
assert.equal(fs.readFileSync(ds.j.executor.promptFile, 'utf8').includes(KEY), false);
|
|
178
|
+
// control: Claude id -> the existing agentParams output, no executor block
|
|
179
|
+
const cl = disp('claude-sonnet-5-5');
|
|
180
|
+
assert.equal(cl.j.executor, undefined);
|
|
181
|
+
assert.equal(cl.j.agentParams.model, 'sonnet'); // cross-project sandbox: Agent isolation omitted, as before
|
|
182
|
+
assert.deepEqual(Object.keys(cl.j.agentParams), ['description', 'model']);
|
|
183
|
+
// a missing DeepSeek key is refused before any worktree/side effect
|
|
184
|
+
const noKey = { ...env }; delete noKey.DEEPSEEK_API_KEY; noKey.USERPROFILE = noKey.HOME = tmp(t, 'nokey-');
|
|
185
|
+
fs.writeFileSync(path.join(sb, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchModel: 'deepseek:deepseek-chat' }));
|
|
186
|
+
const nk = spawnSync(process.execPath, [DISPATCH, '--skill=do', `--task=${id}`, '--dispatch-mode=subagent', '--project-path', sb, '--force'], { env: noKey, encoding: 'utf8', windowsHide: true, cwd: ROOT });
|
|
187
|
+
assert.notEqual(nk.status, 0); assert.match(nk.stderr, /no API key/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('codex that edits but cannot commit (sandbox): exec.js commits the leftovers, telemetry excluded', (t) => {
|
|
191
|
+
const id = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
|
|
192
|
+
const x = runExec(t, { adapter: 'codex', model: '', extraEnv: { STUB_COMMIT: '', STUB_WRITE_ONLY: '1', ...id } });
|
|
193
|
+
assert.equal(x.r.status, 0, x.r.stdout + x.r.stderr);
|
|
194
|
+
assert.equal(x.result.ok, true); assert.ok(x.result.executorCommit);
|
|
195
|
+
assert.match(x.result.commits[0], /codex executor: agent's changes/);
|
|
196
|
+
const files = require('child_process').execFileSync('git', ['show', '--name-only', '--format=', 'HEAD'], { cwd: x.wt, encoding: 'utf8' }).trim();
|
|
197
|
+
assert.strictEqual(files, 'left.txt');
|
|
198
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
/**
|
|
4
|
-
* Dispatch-agent model for a project, without the Dashboard (`kenaz
|
|
4
|
+
* Dispatch-agent model for a project, without the Dashboard (`kenaz subagent`).
|
|
5
5
|
* Reads and writes <project>/.kenaz/dashboard-models.json (dispatchModel, dispatchModelByRigor),
|
|
6
6
|
* the same file and resolution dispatch.js uses; the core's copy is only a read fallback.
|
|
7
7
|
* The specialist (Ansuz) runs in the user's own Claude Code / Codex session: its model is that
|
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { ROOT, readDashboardModels } = require('./utils');
|
|
17
17
|
const { resolveDispatchModelForRigor, translateModelForAgentTool } = require('./dispatch');
|
|
18
|
+
const { parseExecutorModel } = require('../bifrost/exec');
|
|
18
19
|
|
|
19
20
|
const RIGORS = ['low', 'medium', 'high'];
|
|
20
21
|
const fileOf = (dir) => path.join(dir, '.kenaz', 'dashboard-models.json');
|
|
@@ -32,8 +33,10 @@ function describe(projectPath) {
|
|
|
32
33
|
const family = (models && models.dispatchFamily) || 'claude';
|
|
33
34
|
const rows = RIGORS.map((rigor) => {
|
|
34
35
|
const r = resolveDispatchModelForRigor({ rigor, models, cliDispatchModel: null });
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
let executor = null;
|
|
37
|
+
try { executor = parseExecutorModel(r.rawModel); } catch { /* shown as-is; dispatch.js refuses it */ }
|
|
38
|
+
const agent = family === 'bifrost' || executor || /^\w+:/.test(r.rawModel) ? null : translateModelForAgentTool(r.rawModel).model;
|
|
39
|
+
return { rigor, model: r.rawModel, source: r.label, agentModel: agent, executor: executor ? executor.adapter : null };
|
|
37
40
|
});
|
|
38
41
|
return { file, family, rows };
|
|
39
42
|
}
|
|
@@ -45,9 +48,11 @@ function setModel(projectPath, modelId, rigor = null) {
|
|
|
45
48
|
const id = modelId == null ? null : String(modelId).trim();
|
|
46
49
|
if (id !== null) {
|
|
47
50
|
if (!id) throw new Error('model id is empty');
|
|
51
|
+
// codex:<id> / deepseek:<model> run through .formation/bifrost/exec.js (throws, listing the valid ids, if invalid).
|
|
52
|
+
const executor = parseExecutorModel(id);
|
|
48
53
|
// Same rule dispatch.js enforces at dispatch time: a Claude id must map onto the Agent tool enum.
|
|
49
|
-
if ((cur.dispatchFamily || 'claude') !== 'bifrost' && !translateModelForAgentTool(id).model) {
|
|
50
|
-
throw new Error(`'${id}' is not a Claude model id the Agent tool can run (expected claude-<opus|sonnet|haiku|fable>-..., or opus|sonnet|haiku|fable)`);
|
|
54
|
+
if (!executor && (cur.dispatchFamily || 'claude') !== 'bifrost' && !translateModelForAgentTool(id).model) {
|
|
55
|
+
throw new Error(`'${id}' is not a Claude model id the Agent tool can run (expected claude-<opus|sonnet|haiku|fable>-..., or opus|sonnet|haiku|fable; or codex:<id> / deepseek:<model>)`);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
const next = { ...cur };
|
|
@@ -66,25 +71,25 @@ function setModel(projectPath, modelId, rigor = null) {
|
|
|
66
71
|
|
|
67
72
|
function render(d) {
|
|
68
73
|
const lines = [`dispatch model (${d.file ? d.file : 'no dashboard-models.json; built-in default'})`];
|
|
69
|
-
for (const r of d.rows) lines.push(` ${r.rigor.padEnd(6)} ${r.model}${r.agentModel ? ` -> Agent ${r.agentModel}` : ''} [${r.source}]`);
|
|
74
|
+
for (const r of d.rows) lines.push(` ${r.rigor.padEnd(6)} ${r.model}${r.agentModel ? ` -> Agent ${r.agentModel}` : ''}${r.executor ? ` -> executor ${r.executor} (bifrost/exec.js)` : ''} [${r.source}]`);
|
|
70
75
|
lines.push('specialist (Ansuz): the model of your Claude Code / Codex session (/model)');
|
|
71
76
|
return lines.join('\n');
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
/** CLI entry shared with `kenaz
|
|
79
|
+
/** CLI entry shared with `kenaz subagent`. Returns an exit code. */
|
|
75
80
|
function main(argv, out = console.log, cwd = process.cwd()) {
|
|
76
81
|
const val = (k) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : undefined; };
|
|
77
82
|
const projectPath = path.resolve(val('project-path') || cwd);
|
|
78
83
|
const pos = argv.filter((a, i) => !a.startsWith('--') && !['--rigor', '--project-path'].includes(argv[i - 1]));
|
|
79
84
|
try {
|
|
80
85
|
if (pos[0] === 'set') {
|
|
81
|
-
if (!pos[1]) { out('usage: kenaz
|
|
86
|
+
if (!pos[1]) { out('usage: kenaz subagent set <model-id> [--rigor low|medium|high]'); return 2; }
|
|
82
87
|
out(`wrote ${setModel(projectPath, pos[1], val('rigor') || null)}`);
|
|
83
88
|
} else if (pos[0] === 'unset') out(`wrote ${setModel(projectPath, null, val('rigor') || null)}`);
|
|
84
|
-
else if (pos[0]) { out('usage: kenaz
|
|
89
|
+
else if (pos[0]) { out('usage: kenaz subagent [set <model-id> | unset] [--rigor low|medium|high] [--project-path <dir>]'); return 2; }
|
|
85
90
|
out(render(describe(projectPath)));
|
|
86
91
|
return 0;
|
|
87
|
-
} catch (e) { out('kenaz
|
|
92
|
+
} catch (e) { out('kenaz subagent: ' + e.message); return 1; }
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
@@ -39,7 +39,7 @@ const {
|
|
|
39
39
|
// getSubagentDispatchParams during evaluation, so a later `const` would be in
|
|
40
40
|
// its temporal dead zone at call time.)
|
|
41
41
|
const AGENT_TOOL_MODEL_ENUM = ['sonnet', 'opus', 'haiku', 'fable'];
|
|
42
|
-
// Used when no dashboard-models.json sets dispatchModel (set one with `kenaz
|
|
42
|
+
// Used when no dashboard-models.json sets dispatchModel (set one with `kenaz subagent set <id>`).
|
|
43
43
|
const DEFAULT_DISPATCH_MODEL = 'claude-sonnet-5-5';
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -1016,22 +1016,34 @@ async function getSubagentDispatchParams(options) {
|
|
|
1016
1016
|
// 2b: a rejected dispatch must never acquire locks it won't use).
|
|
1017
1017
|
const resolution = resolveDispatchModelForRigor({ rigor, models, cliDispatchModel: dispatchModelOverride });
|
|
1018
1018
|
const rawModel = resolution.rawModel;
|
|
1019
|
+
// TASK_2422: codex:<id> / deepseek:<model> run via .formation/bifrost/exec.js, not the Agent tool.
|
|
1020
|
+
let exec = null;
|
|
1021
|
+
if (!isCodex) {
|
|
1022
|
+
try { exec = require('../bifrost/exec').parseExecutorModel(rawModel); } catch (e) {
|
|
1023
|
+
console.error(`❌ ${resolution.label} '${rawModel}': ${e.message}`);
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
if (exec && exec.adapter === 'deepseek' && !dryRun && !require('../bifrost/exec').deepseekKey()) {
|
|
1027
|
+
console.error('❌ deepseek executor: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1019
1031
|
// TASK_2315: Codex inherits the session model — nothing to translate or pass.
|
|
1020
|
-
const translated = isCodex ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
|
|
1021
|
-
if (!isCodex && !translated.model) {
|
|
1032
|
+
const translated = isCodex || exec ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
|
|
1033
|
+
if (!isCodex && !exec && !translated.model) {
|
|
1022
1034
|
console.error(`❌ ${resolution.label} '${rawModel}' cannot be mapped to an Agent tool model family (${AGENT_TOOL_MODEL_ENUM.join('|')}) — refusing to silently guess a substitute model. Fix the model selection (Quick Actions → Default Models).`);
|
|
1023
1035
|
return null;
|
|
1024
1036
|
}
|
|
1025
1037
|
const dispatchModel = translated.model;
|
|
1026
1038
|
const modelTranslation = isCodex
|
|
1027
1039
|
? { requested: rawModel, inherited: true }
|
|
1028
|
-
: { requested: rawModel, agentToolModel: dispatchModel, granularityLoss: !translated.lossless };
|
|
1040
|
+
: exec ? { requested: rawModel, executor: exec.adapter } : { requested: rawModel, agentToolModel: dispatchModel, granularityLoss: !translated.lossless };
|
|
1029
1041
|
if (!translated.lossless) {
|
|
1030
1042
|
process.stderr.write(`[dispatch] model '${rawModel}' → Agent tool family '${dispatchModel}' — the Agent tool only expresses family names (${AGENT_TOOL_MODEL_ENUM.join('|')}); version/speed selections (e.g. 5 vs 4.x, [1m]) are NOT expressible on the subagent path. If the exact version matters, choose a model whose family the Agent tool can express.\n`);
|
|
1031
1043
|
}
|
|
1032
1044
|
// TASK_1998 R3 (+ two-axis policy): the resolved choice, always printed
|
|
1033
1045
|
// (unlike the granularity block above, which only fires on lossy translation).
|
|
1034
|
-
process.stderr.write(`[dispatch] rigor=${resolution.rigorKey} complexity=${complexity} model=${rawModel} → ${isCodex ? 'inherited by Codex session' : "family '" + dispatchModel + "'"} (source: ${resolution.source})\n`);
|
|
1046
|
+
process.stderr.write(`[dispatch] rigor=${resolution.rigorKey} complexity=${complexity} model=${rawModel} → ${isCodex ? 'inherited by Codex session' : exec ? 'executor ' + exec.adapter + ' (bifrost/exec.js)' : "family '" + dispatchModel + "'"} (source: ${resolution.source})\n`);
|
|
1035
1047
|
|
|
1036
1048
|
// TASK_2362: subagent is the only route. dispatchMode is a legacy config echo:
|
|
1037
1049
|
// missing -> 'subagent'; 'subagent'|'auto' accepted; 'formation' (retired) or
|
|
@@ -1101,8 +1113,9 @@ async function getSubagentDispatchParams(options) {
|
|
|
1101
1113
|
// TASK_2315: Codex has no harness-managed worktree isolation — always pre-create
|
|
1102
1114
|
// one (same-project included) and hand its path to the agent as cwd.
|
|
1103
1115
|
const wtRepo2315 = projectPath || ROOT;
|
|
1104
|
-
|
|
1105
|
-
|
|
1116
|
+
// TASK_2422: executors (codex:/deepseek:) have no harness worktree either.
|
|
1117
|
+
if ((isCodex || exec) && iso.mode === 'agent_tool_worktree') { iso.mode = 'target_repo_worktree'; iso.source = isCodex ? 'codex-harness' : 'executor'; }
|
|
1118
|
+
const isCrossProject = iso.isCrossProject || ((isCodex || exec) && iso.mode === 'target_repo_worktree');
|
|
1106
1119
|
let crossWorktree = null; // { worktreePath, branchName } when mode=target_repo_worktree
|
|
1107
1120
|
let serialLock = null; // { lockFile } when mode=serial_lock
|
|
1108
1121
|
let worktreeFallbackReason = null; // TASK_2067: non-null only on a genuine target_repo_worktree creation failure that degraded to serial_lock -- rides in the isolation payload, not just stderr
|
|
@@ -1139,6 +1152,13 @@ async function getSubagentDispatchParams(options) {
|
|
|
1139
1152
|
}
|
|
1140
1153
|
}
|
|
1141
1154
|
|
|
1155
|
+
if (exec && iso.mode !== 'target_repo_worktree') {
|
|
1156
|
+
console.error(`❌ executor ${exec.adapter} needs a git worktree for the card (isolation mode '${iso.mode}'${worktreeFallbackReason ? ': ' + worktreeFallbackReason : ''}) — refusing to run a non-Claude agent in a shared tree`);
|
|
1157
|
+
releaseResourceLocks(_saAcquiredResources, _saRoot);
|
|
1158
|
+
releaseAdoptedClaimMarker(_saClaim);
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1142
1162
|
if (iso.mode === 'serial_lock' && dryRun) {
|
|
1143
1163
|
console.error(' [dry-run] shared-tree dispatch lock NOT acquired: ' + dispatchLockPath(projectPath));
|
|
1144
1164
|
} else if (iso.mode === 'serial_lock') {
|
|
@@ -1254,7 +1274,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1254
1274
|
// Still true: the description field does not affect the worktree name, and per-TASK
|
|
1255
1275
|
// naming (task_<taskid>) requires the formation path via
|
|
1256
1276
|
// formations.setupUnitWorktree({ taskId }).
|
|
1257
|
-
const agentParams = isCodex ? undefined : {
|
|
1277
|
+
const agentParams = isCodex || exec ? undefined : {
|
|
1258
1278
|
// Include taskInput so the Agent tool description is unique per task (helps tracing)
|
|
1259
1279
|
description: `[${taskInput}] Kenaz subagent (${skill})`,
|
|
1260
1280
|
model: dispatchModel,
|
|
@@ -1315,7 +1335,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1315
1335
|
// by dashboard vs dashboard-by-rigor vs cli without re-deriving it.
|
|
1316
1336
|
// Two-axis policy: complexity rides along too, so dispatch-stats.js can
|
|
1317
1337
|
// group by (rigor, complexity) instead of rigor alone.
|
|
1318
|
-
if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel, modelSource: resolution.source, projectPath });
|
|
1338
|
+
if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath });
|
|
1319
1339
|
|
|
1320
1340
|
// TASK_2032: mirror headless.js's formation-path auto-activation (~L272-290)
|
|
1321
1341
|
// on the subagent path — every gate/guard above has passed and this function
|
|
@@ -1355,10 +1375,26 @@ async function getSubagentDispatchParams(options) {
|
|
|
1355
1375
|
// on agentParams itself). rigor is the NORMALIZED key (null/unrecognized →
|
|
1356
1376
|
// 'medium') that actually drove model resolution, not the raw card field.
|
|
1357
1377
|
// complexity is likewise the normalized CONTEXT-volume key.
|
|
1378
|
+
// TASK_2422: executor block = the card's worktree + one command for Ansuz to run with Bash run_in_background.
|
|
1379
|
+
// The prompt goes to exec.js as a file (never argv); no secret is ever part of this payload.
|
|
1380
|
+
let executor;
|
|
1381
|
+
if (exec) {
|
|
1382
|
+
const dir = path.join(require('os').tmpdir(), 'kenaz-exec');
|
|
1383
|
+
const stem = path.join(dir, `${(taskInput || 'adhoc').replace(/[^\w.-]/g, '_')}-${Date.now().toString(36)}`);
|
|
1384
|
+
const promptFile = stem + '.prompt.md'; const resultFile = stem + '.result.json';
|
|
1385
|
+
if (!dryRun) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(promptFile, promptText); }
|
|
1386
|
+
const q = (s) => '"' + String(s).replace(/"/g, '\\"') + '"';
|
|
1387
|
+
executor = {
|
|
1388
|
+
adapter: exec.adapter, model: exec.id, worktreePath: crossWorktree.worktreePath, branchName: crossWorktree.branchName,
|
|
1389
|
+
promptFile, resultFile,
|
|
1390
|
+
command: `node ${q(path.join(__dirname, '..', 'bifrost', 'exec.js'))} --adapter=${exec.adapter} --model=${q(exec.id)} --worktree ${q(crossWorktree.worktreePath)} --prompt-file ${q(promptFile)} --result-file ${q(resultFile)}`,
|
|
1391
|
+
note: 'Run command with Bash run_in_background, wait for the completion notice, read resultFile (ok, exitCode, lastMessage, commits), then verify and merge the branch as for an Agent subagent.',
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1358
1394
|
return {
|
|
1359
1395
|
promptText, taskId: taskInput, formation, skill, dispatchMode, effectiveMode: 'subagent',
|
|
1360
1396
|
...(isCodex ? { harness: 'codex', cwd: crossWorktree ? crossWorktree.worktreePath : null, worktreePath: crossWorktree ? crossWorktree.worktreePath : null } : {}),
|
|
1361
|
-
modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
|
|
1397
|
+
modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), ...(executor ? { executor } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
|
|
1362
1398
|
...(dryRun ? { dryRun: true } : {}),
|
|
1363
1399
|
};
|
|
1364
1400
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 2. dispatch git history of the project: `Merge TASK_n` commits vs `[TASK_n]` commits (first-pass = one merge, no later fix-up on the main line)
|
|
8
8
|
* 3. field reports ~/.kenaz/cortex/field_reports.db (card_id) -> the card's merge commit -> first release tag containing it
|
|
9
9
|
* 4. alaya ~/.kenaz/cortex/global_memory.db (alaya_bijas, alaya_exported, seed_imports)
|
|
10
|
+
* 5. lessons <project>/.kenaz/knowledge and ~/.kenaz/knowledge (active, fires, recurrences, graduated / merged / archived)
|
|
10
11
|
*/
|
|
11
12
|
const fs = require('fs');
|
|
12
13
|
const os = require('os');
|
|
@@ -76,36 +77,36 @@ function git(cwd, ...args) {
|
|
|
76
77
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 });
|
|
77
78
|
}
|
|
78
79
|
/** Commits of the repo (all refs): [{sha, parents, t, subject, firstParent, onHead}] newest first; null when not a git repo. */
|
|
79
|
-
function readCommits(repo, sinceMs) {
|
|
80
|
+
function readCommits(repo, sinceMs, untilMs) {
|
|
80
81
|
try {
|
|
81
82
|
const fmt = '%H%x09%P%x09%ct%x09%s';
|
|
82
83
|
const head = new Set(git(repo, 'rev-list', 'HEAD').split('\n').filter(Boolean));
|
|
83
84
|
const first = new Set(git(repo, 'log', '--first-parent', '--format=%H').split('\n').filter(Boolean));
|
|
84
|
-
return git(repo, 'log', '--all', `--since=${new Date(sinceMs).toISOString()}`, `--format=${fmt}`).split('\n').filter(Boolean).map((l) => {
|
|
85
|
+
return git(repo, 'log', '--all', `--since=${new Date(sinceMs).toISOString()}`, ...(untilMs ? [`--until=${new Date(untilMs).toISOString()}`] : []), `--format=${fmt}`).split('\n').filter(Boolean).map((l) => {
|
|
85
86
|
const [sha, parents, ct, ...s] = l.split('\t');
|
|
86
87
|
return { sha, parents: parents.split(' ').filter(Boolean), t: Number(ct) * 1000, subject: s.join('\t'), firstParent: first.has(sha), onHead: head.has(sha) };
|
|
87
88
|
});
|
|
88
89
|
} catch (_) { return null; }
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/**
|
|
92
|
+
/** Shas of commits the merged branch gained after the agent's first `task completed` commit (fixes before the merge). */
|
|
92
93
|
function branchFixups(repo, merges) {
|
|
93
|
-
|
|
94
|
+
const shas = [];
|
|
94
95
|
for (const m of merges) {
|
|
95
96
|
if (m.parents.length < 2) continue;
|
|
96
97
|
try {
|
|
97
|
-
const
|
|
98
|
-
const first =
|
|
98
|
+
const rows = git(repo, 'log', '--reverse', '--format=%H\t%s', `${m.parents[0]}..${m.parents[1]}`).split('\n').filter(Boolean).map((l) => l.split('\t'));
|
|
99
|
+
const first = rows.findIndex(([, s]) => /task completed\s*$/i.test(s));
|
|
99
100
|
// the agent's own follow-ups reuse the `task completed` subject; anything else was added after delivery
|
|
100
|
-
if (first >= 0)
|
|
101
|
+
if (first >= 0) shas.push(...rows.slice(first + 1).filter(([, s]) => !/task completed\s*$/i.test(s)).map(([h]) => h));
|
|
101
102
|
} catch (_) { /* unreadable merge: not judged */ }
|
|
102
103
|
}
|
|
103
|
-
return
|
|
104
|
+
return shas;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function dispatchStats({ repo, now, days }) {
|
|
107
|
+
function dispatchStats({ repo, now, days, until }) {
|
|
107
108
|
const src = 'git log of ' + repo + ' (`Merge TASK_n` commits, `[TASK_n]` commits)';
|
|
108
|
-
const commits = readCommits(repo, now - days * DAY);
|
|
109
|
+
const commits = readCommits(repo, now - days * DAY, until);
|
|
109
110
|
if (!commits) return { source: src, note: `${NO_DATA} (not a git repository)` };
|
|
110
111
|
const cards = new Map(); // n -> {merges:[commit], work:number, fixups:[]}
|
|
111
112
|
const card = (n) => { if (!cards.has(n)) cards.set(n, { merges: [], work: [], fixups: [] }); return cards.get(n); };
|
|
@@ -126,14 +127,15 @@ function dispatchStats({ repo, now, days }) {
|
|
|
126
127
|
const after = c.fixups.filter((x) => x.t >= firstMerge);
|
|
127
128
|
const abandoned = c.work.filter((x) => /task completed\s*$/i.test(x.subject) && !x.onHead).length;
|
|
128
129
|
const fixedOnBranch = branchFixups(repo, c.merges);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
else if (
|
|
132
|
-
else if (
|
|
130
|
+
// `shas`: the extra commits made after delivery (TASK_2427 harvest reads the files they touched)
|
|
131
|
+
if (c.merges.length > 1) rework.push({ card: `TASK_${n}`, why: `re-dispatched/merged ${c.merges.length} times`, shas: [] });
|
|
132
|
+
else if (abandoned) rework.push({ card: `TASK_${n}`, why: `${abandoned} agent delivery commit(s) never merged (re-dispatch or a losing A/B arm)`, shas: c.work.filter((x) => !x.onHead).map((x) => x.sha) });
|
|
133
|
+
else if (fixedOnBranch.length) rework.push({ card: `TASK_${n}`, why: `${fixedOnBranch.length} commit(s) on the branch after the agent's delivery, before the merge`, shas: fixedOnBranch });
|
|
134
|
+
else if (after.length) rework.push({ card: `TASK_${n}`, why: `${after.length} fix-up commit(s) on the main line after the merge`, shas: after.map((x) => x.sha) });
|
|
133
135
|
}
|
|
134
136
|
return {
|
|
135
137
|
source: src, dispatched: cards.size, merged: merged.length, landedWithoutMergeCommit: noMergeCommit.length, notMerged: unmerged.length, firstPass: merged.length - rework.length,
|
|
136
|
-
firstPassRate: merged.length ? (merged.length - rework.length) / merged.length : null, rework, notMergedCards: unmerged.slice(0, 20),
|
|
138
|
+
firstPassRate: merged.length ? (merged.length - rework.length) / merged.length : null, rework, mergedCards: merged, notMergedCards: unmerged.slice(0, 20),
|
|
137
139
|
note: 'dispatched = cards with any [TASK_n] or Merge TASK_n commit in the window; first-pass rate = cards with a Merge TASK_n commit on HEAD, merged once, no main-line fix-up; cards landed without a merge commit (fast-forward) are not judged; not merged = commits not reachable from HEAD (first 20 listed)',
|
|
138
140
|
};
|
|
139
141
|
}
|
|
@@ -200,6 +202,24 @@ function alayaStats({ home, now, days }) {
|
|
|
200
202
|
};
|
|
201
203
|
}
|
|
202
204
|
|
|
205
|
+
function lessonStats({ home, cwd, now, days }) {
|
|
206
|
+
const src = '<project>/.kenaz/knowledge + ~/.kenaz/knowledge (+ archive/, .usage.jsonl, memory_usage.jsonl)';
|
|
207
|
+
try {
|
|
208
|
+
return { source: src, ...require('../../.specialist/tools/lessons.js').stats({ projectPath: cwd, home, now, days }) };
|
|
209
|
+
} catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function selfImprovementStats({ home, cwd, now, days }) {
|
|
213
|
+
const src = '<project>/.kenaz/specialist/correction-nudges.jsonl + lessons + .kenaz/knowledge/.harvested.json + git (this window vs the previous one)';
|
|
214
|
+
try {
|
|
215
|
+
const cur = dispatchStats({ repo: cwd, now, days });
|
|
216
|
+
const prev = dispatchStats({ repo: cwd, now: now - days * DAY, days, until: now - days * DAY });
|
|
217
|
+
const pick = (d) => (d.firstPassRate == null ? null : { rate: d.firstPassRate, merged: d.merged });
|
|
218
|
+
return { source: src, ...require('../../.specialist/tools/lessons-harvest.js').selfStats({ projectPath: cwd, home, now, days }),
|
|
219
|
+
firstPass: { window: pick(cur), previous: pick(prev) } };
|
|
220
|
+
} catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
|
|
221
|
+
}
|
|
222
|
+
|
|
203
223
|
function collect({ home = os.homedir(), cwd = process.cwd(), days = 14, now = Date.now() } = {}) {
|
|
204
224
|
return {
|
|
205
225
|
days,
|
|
@@ -207,6 +227,8 @@ function collect({ home = os.homedir(), cwd = process.cwd(), days = 14, now = Da
|
|
|
207
227
|
dispatch: dispatchStats({ repo: cwd, now, days }),
|
|
208
228
|
fieldReports: fieldStats({ home, repo: cwd, now, days }),
|
|
209
229
|
alaya: alayaStats({ home, now, days }),
|
|
230
|
+
lessons: lessonStats({ home, cwd, now, days }),
|
|
231
|
+
selfImprovement: selfImprovementStats({ home, cwd, now, days }),
|
|
210
232
|
};
|
|
211
233
|
}
|
|
212
234
|
|
|
@@ -243,6 +265,25 @@ function render(s) {
|
|
|
243
265
|
` activated: ${a.bijasWithActivation || 0} bijas with >=1 activation, ${a.activationsTotal} activations all-time`,
|
|
244
266
|
` seeds exported: ${a.seedsExportedTotal} all-time, ${a.seedsExportedInWindow || 0} in window; seed imports in window ${a.seedImportsInWindow}`, ` note: ${a.note}`);
|
|
245
267
|
}
|
|
268
|
+
const le = s.lessons || { source: '-', note: 'no data' };
|
|
269
|
+
L.push('', 'Lessons', ` source: ${le.source}`);
|
|
270
|
+
if (!le.project) L.push(` ${le.note}`);
|
|
271
|
+
else for (const k of ['project', 'user']) {
|
|
272
|
+
const x = le[k];
|
|
273
|
+
L.push(` ${k}: active ${x.active}, fires in window ${x.fires}, recurrences ${x.recurrences}; graduated ${x.graduated}, merged ${x.merged}, archived ${x.archived}`);
|
|
274
|
+
}
|
|
275
|
+
const si = s.selfImprovement;
|
|
276
|
+
if (si) {
|
|
277
|
+
L.push('', 'Self-improvement', ` source: ${si.source}`);
|
|
278
|
+
if (si.nudges == null) L.push(` ${si.note}`);
|
|
279
|
+
else {
|
|
280
|
+
const r = (x) => (x ? `${(x.rate * 100).toFixed(0)}% of ${x.merged}` : NO_DATA);
|
|
281
|
+
L.push(` correction nudges ${si.nudges}: lesson recorded within 15 min after ${si.converted}${si.nudges ? ` (${(100 * si.converted / si.nudges).toFixed(0)}%)` : ''}`,
|
|
282
|
+
` lessons by source: ${Object.entries(si.bySource).map(([k, v]) => `${k}=${v.lessons} (fired ${v.fired}, recurred before first fire ${v.recurBefore}, after ${v.recurAfter})`).join('; ') || 'none'}`,
|
|
283
|
+
` graduated ${si.graduated}; harvest items open ${si.harvestOpen}, recorded ${si.harvestRecorded}`,
|
|
284
|
+
` first-pass merge rate: this window ${r(si.firstPass.window)}, previous window ${r(si.firstPass.previous)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
246
287
|
return L.join('\n');
|
|
247
288
|
}
|
|
248
289
|
|
|
@@ -256,4 +297,4 @@ function main(argv, out = console.log, opts = {}) {
|
|
|
256
297
|
}
|
|
257
298
|
|
|
258
299
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
259
|
-
module.exports = { main, collect, peerStats, dispatchStats, fieldStats, alayaStats, render };
|
|
300
|
+
module.exports = { main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
|
|
@@ -164,10 +164,10 @@ test('alaya: bija counts, activations, exported and seed imports in window; miss
|
|
|
164
164
|
test('main: --json parses and all four sections print; bad --days exits 2', () => {
|
|
165
165
|
const lines = [];
|
|
166
166
|
assert.strictEqual(S.main(['--json'], (m) => lines.push(m), { home: path.join(tmp, 'home2'), cwd: path.join(tmp, 'repo'), now: NOW }), 0);
|
|
167
|
-
assert.deepStrictEqual(Object.keys(JSON.parse(lines[0])), ['days', 'peer', 'dispatch', 'fieldReports', 'alaya']);
|
|
167
|
+
assert.deepStrictEqual(Object.keys(JSON.parse(lines[0])), ['days', 'peer', 'dispatch', 'fieldReports', 'alaya', 'lessons', 'selfImprovement']);
|
|
168
168
|
const text = [];
|
|
169
169
|
S.main([], (m) => text.push(m), { home: path.join(tmp, 'nohome'), cwd: path.join(tmp, 'nogit'), now: NOW });
|
|
170
|
-
for (const h of ['Peer delivery', 'Dispatch first-pass merge rate', 'Field report -> release latency', 'Alaya']) assert.ok(text[0].includes(h), h);
|
|
170
|
+
for (const h of ['Peer delivery', 'Dispatch first-pass merge rate', 'Field report -> release latency', 'Alaya', 'Lessons', 'Self-improvement']) assert.ok(text[0].includes(h), h);
|
|
171
171
|
assert.ok(!/\b0%/.test(text[0]) && text[0].includes('no data'));
|
|
172
172
|
assert.strictEqual(S.main(['--days', 'x'], () => {}), 2);
|
|
173
173
|
});
|
|
@@ -68,8 +68,11 @@ the real defect BEFORE carding or dispatching.
|
|
|
68
68
|
(plus `--project-path <pin>`). Pass top-level promptText as the Agent prompt and agentParams
|
|
69
69
|
verbatim; agentParams.model is already the Agent enum - never substitute a model (never inherit
|
|
70
70
|
opus). dispatch.js moves the card to active.
|
|
71
|
-
- The dispatch model is the project's setting: `kenaz
|
|
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
|
+
- Executor model (`kenaz subagent set codex:<id>` or `deepseek:<model>`): dispatch.js prints an `executor` block instead of
|
|
74
|
+
agentParams. Run `executor.command` with Bash run_in_background, wait for the completion notice, read `executor.resultFile`
|
|
75
|
+
(ok, exitCode, lastMessage, commits; `no commits` = failure), then verify and merge `executor.branchName` as usual.
|
|
73
76
|
- Worktree subagents must commit; cleanup deletes uncommitted edits. Remove Kenaz worktrees only
|
|
74
77
|
via .formation/tools/worktree-reclaim.js (it unlinks node_modules junctions first).
|
|
75
78
|
|
|
@@ -80,7 +83,7 @@ the real defect BEFORE carding or dispatching.
|
|
|
80
83
|
through dev/scripts/rust-verify-worktree.js.
|
|
81
84
|
|
|
82
85
|
## Handoff
|
|
83
|
-
|
|
86
|
+
One ISBAR markdown file per handoff in <project>/.kenaz/handoffs/, created by .specialist/tools/handoff-doc.js new --topic ... (Identification filled in; you fill Situation, Background, Assessment, Recommendation). Write one when the user asks for a handoff or a pause, when context is nearly full, or when a session ends with unfinished work. Boot lists the newest and Ansuz reads it in full before proposing the agenda. The old JSON snapshots (ansuz_handoff_snapshot, handoffs/*.json) stay frozen.
|
|
84
87
|
|
|
85
88
|
## Wishing Well
|
|
86
89
|
When the Kenaz app itself blocks or degrades the work, file kenaz_wish_create (bug|feature|question,
|
|
@@ -91,6 +94,8 @@ Write a lesson automatically (no asking, one line in the report naming it) when
|
|
|
91
94
|
- Layer: user-wide preference -> `--scope user` (~/.kenaz/knowledge, local, never synced); project fact -> `--scope project` (<project>/.kenaz/knowledge).
|
|
92
95
|
- Type decides where it acts (a stored note alone changes nothing): `pitfall` needs `--trigger '<tool regex> | <input regex>'`, memory-guard fires it right before the matching tool call (add `--cause` = the command that caused it; the write is refused if the trigger misses it or fires on >20% of recent tool calls; `--symptom` records how the mistake shows up); `fact` (`--paths` limits it to cards touching those paths) and `check` go into the dispatch prompt (max 5 lines / 600 bytes); `pref` shows in the boot banner.
|
|
93
96
|
- Lessons idle for 30 days move to `<layer>/archive/` (pref exempt); `kenaz learn list` shows fire counts.
|
|
97
|
+
- Lifecycle (lessons must not just pile up): when the boot banner shows `[lessons] N to graduate`, run `kenaz learn review` and propose turning the top candidate into code (a test, a hook check, or a rule in the project's CLAUDE.md/AGENTS.md; never edit ~/.kenaz/core), then `kenaz learn graduate <id> --to <commit or file>`. That is the preferred outcome. A correction that matches an existing lesson is a recurrence, not a new lesson: `add` bumps `recurrences` instead of writing (max 20 active per layer; `kenaz learn merge <id1> <id2> --text "<general rule>"` to make room). A lesson about Kenaz itself gets `--about kenaz`; `review` lists it with the hint to report it via `kenaz_wish_create scope=kenaz` (nothing is sent automatically).
|
|
98
|
+
- History harvest (TASK_2427): when the boot banner shows `[learn] N history items to review` (or `[lessons] ... N history items`), run `kenaz learn review`. Each item (rework, failed executor run, blocked card, handoff risk line) has a ready `kenaz learn add ... --source harvest:<id>`: fill in the why, run it for items that reveal a reusable lesson, ignore the rest. The metric suggestions (first-pass rate per rigor, rework-heavy files) are suggestions: show them to the user, never apply `kenaz subagent set` yourself. Lessons from a correction nudge use `--source correction-nudge`. Benefit is measured in `kenaz stats` (Self-improvement); the keep/remove rule is in dev/research/self-improvement/DESIGN.md.
|
|
94
99
|
|
|
95
100
|
## REA (reverse-engineering reference products)
|
|
96
101
|
REA (github.com/morluto/rea, MCP server `rea`) is optional and user-installed; the boot banner's `[rea]` line says whether it is on.
|