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.
Files changed (34) hide show
  1. package/core/.claude/commands/Ansuz.md +11 -1
  2. package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
  3. package/core/.claude/hooks/_run-user-prompt.js +2 -0
  4. package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
  5. package/core/.claude/hooks/correction-detect.js +99 -0
  6. package/core/.formation/bifrost/exec.js +147 -0
  7. package/core/.formation/bifrost/provider-launch.js +57 -0
  8. package/core/.formation/bifrost/provider-launch.test.js +62 -0
  9. package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
  10. package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
  11. package/core/.formation/tools/dispatch-model.js +15 -10
  12. package/core/.formation/tools/dispatch.js +46 -10
  13. package/core/.formation/tools/kenaz-stats.js +57 -16
  14. package/core/.formation/tools/kenaz-stats.test.js +2 -2
  15. package/core/.specialist/Ansuz/RULES.md +7 -2
  16. package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
  17. package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
  18. package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
  19. package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
  20. package/core/.specialist/tools/handoff-doc.js +95 -0
  21. package/core/.specialist/tools/lessons-harvest.js +243 -0
  22. package/core/.specialist/tools/lessons-lifecycle.js +103 -0
  23. package/core/.specialist/tools/lessons.js +149 -24
  24. package/core/KENAZ_CORE_VERSION +1 -1
  25. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +1 -1
  26. package/core/dev/scripts/correction-detect-eval.js +70 -0
  27. package/core/plugins/kenaz/commands/Ansuz.md +11 -1
  28. package/core/plugins/kenaz/commands/kenaz.md +5 -3
  29. package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
  30. package/core/plugins/kenaz/scripts/statusline.js +60 -0
  31. package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
  32. package/lib/cli.js +11 -4
  33. package/package.json +6 -6
  34. 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 model`).
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
- const agent = family === 'bifrost' ? null : translateModelForAgentTool(r.rawModel).model;
36
- return { rigor, model: r.rawModel, source: r.label, agentModel: agent };
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 model`. Returns an exit code. */
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 model set <model-id> [--rigor low|medium|high]'); return 2; }
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 model [set <model-id> | unset] [--rigor low|medium|high] [--project-path <dir>]'); return 2; }
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 model: ' + e.message); return 1; }
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 model set <id>`).
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
- if (isCodex && iso.mode === 'agent_tool_worktree') { iso.mode = 'target_repo_worktree'; iso.source = 'codex-harness'; }
1105
- const isCrossProject = iso.isCrossProject || (isCodex && iso.mode === 'target_repo_worktree');
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
- /** Commits the merged branch gained after the agent's first `task completed` commit (fixes before the merge). */
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
- let n = 0;
94
+ const shas = [];
94
95
  for (const m of merges) {
95
96
  if (m.parents.length < 2) continue;
96
97
  try {
97
- const subj = git(repo, 'log', '--reverse', '--format=%s', `${m.parents[0]}..${m.parents[1]}`).split('\n').filter(Boolean);
98
- const first = subj.findIndex((s) => /task completed\s*$/i.test(s));
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) n += subj.slice(first + 1).filter((s) => !/task completed\s*$/i.test(s)).length;
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 n;
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
- if (c.merges.length > 1) rework.push({ card: `TASK_${n}`, why: `re-dispatched/merged ${c.merges.length} times` });
130
- else if (abandoned) rework.push({ card: `TASK_${n}`, why: `${abandoned} agent delivery commit(s) never merged (re-dispatch or a losing A/B arm)` });
131
- else if (fixedOnBranch) rework.push({ card: `TASK_${n}`, why: `${fixedOnBranch} commit(s) on the branch after the agent's delivery, before the merge` });
132
- else if (after.length) rework.push({ card: `TASK_${n}`, why: `${after.length} fix-up commit(s) on the main line after the merge` });
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 model` shows it per rigor, `kenaz model set <id> [--rigor r]`
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
- Frozen since 2026-10-10 (ANSUZ_SLIM_PLAN): no handoff snapshots. The task cards and git history are the record.
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.