kenaz 0.3.12 → 0.3.14
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 +162 -0
- package/core/.formation/bifrost/provider-launch.js +69 -0
- package/core/.formation/bifrost/provider-launch.test.js +75 -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,46 @@
|
|
|
1
|
+
// Project handoff documents: handoff-doc.js writes ISBAR files under .kenaz/handoffs/, and
|
|
2
|
+
// ansuz-boot-fast.js lists them (plus legacy handoff*.md) newest first. Temp project dirs only.
|
|
3
|
+
// Usage: node --test .specialist/tools/__tests__/handoff-doc.test.js
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const assert = require('node:assert');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const doc = require('../handoff-doc.js');
|
|
10
|
+
const boot = require('../ansuz-boot-fast.js');
|
|
11
|
+
|
|
12
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-handoff-'));
|
|
13
|
+
|
|
14
|
+
test('new writes an ISBAR file under .kenaz/handoffs with Identification filled in', () => {
|
|
15
|
+
const p = tmp();
|
|
16
|
+
const f = doc.create(p, '中繼 部署/收尾', 'sess-1', new Date(2026, 9, 11, 22, 5));
|
|
17
|
+
assert.strictEqual(path.dirname(f), path.join(p, '.kenaz', 'handoffs'));
|
|
18
|
+
assert.strictEqual(path.basename(f), '2026-10-11-2205-中繼-部署-收尾.md');
|
|
19
|
+
const s = fs.readFileSync(f, 'utf8');
|
|
20
|
+
for (const h of ['# Handoff: 中繼 部署/收尾', '## I · Identification', '## S · Situation', '## B · Background', '## A · Assessment', '## R · Recommendation']) assert.ok(s.includes(h), h);
|
|
21
|
+
assert.ok(s.includes('session sess-1'));
|
|
22
|
+
const again = doc.create(p, '中繼 部署/收尾', 'sess-1', new Date(2026, 9, 11, 22, 5));
|
|
23
|
+
assert.notStrictEqual(again, f, 'never overwrites an earlier handoff');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('boot lists handoffs newest first, standard dir and legacy names, and ignores other markdown', () => {
|
|
27
|
+
const p = tmp();
|
|
28
|
+
const old = doc.create(p, 'older', null);
|
|
29
|
+
const newer = doc.create(p, 'newer', null);
|
|
30
|
+
fs.writeFileSync(path.join(p, '.kenaz', 'HANDOFF.md'), '# Legacy lane handoff\n');
|
|
31
|
+
fs.mkdirSync(path.join(p, 'docs'));
|
|
32
|
+
fs.writeFileSync(path.join(p, 'docs', 'README.md'), '# not a handoff\n');
|
|
33
|
+
const t = Date.now() / 1000;
|
|
34
|
+
fs.utimesSync(old, t - 3 * 86400, t - 3 * 86400);
|
|
35
|
+
fs.utimesSync(path.join(p, '.kenaz', 'HANDOFF.md'), t - 86400, t - 86400);
|
|
36
|
+
const docs = boot.findHandoffDocs(p);
|
|
37
|
+
assert.deepStrictEqual(docs.map((d) => path.basename(d.rel)), [path.basename(newer), 'HANDOFF.md', path.basename(old)]);
|
|
38
|
+
const lines = boot.handoffDocLines(p);
|
|
39
|
+
assert.match(lines[0], /Project handoff/);
|
|
40
|
+
assert.match(lines[1], /→ \.kenaz\/handoffs\/.*newer\.md \(today\) Handoff: newer/);
|
|
41
|
+
assert.match(lines[2], /\.kenaz\/HANDOFF\.md \(1d ago\) Legacy lane handoff/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('control: a project without handoffs prints nothing', () => {
|
|
45
|
+
assert.deepStrictEqual(boot.handoffDocLines(tmp()), []);
|
|
46
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2425: lesson lifecycle. Temp homes and temp projects only, never the real ~/.kenaz.
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const lessons = require('../lessons');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
11
|
+
const mk = () => fs.mkdtempSync(path.join(os.tmpdir(), 'k2425-'));
|
|
12
|
+
const project = () => { const p = mk(); fs.mkdirSync(path.join(p, '.kenaz'), { recursive: true }); return p; };
|
|
13
|
+
const files = (dir) => { try { return fs.readdirSync(dir).filter((n) => n.endsWith('.md')); } catch (_) { return []; } };
|
|
14
|
+
const projKnowledge = (p) => path.join(p, '.kenaz', 'knowledge');
|
|
15
|
+
function fire(p, id, n, daysAgo = 1) {
|
|
16
|
+
const log = path.join(p, '.kenaz', 'specialist', 'memory_usage.jsonl');
|
|
17
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
fs.appendFileSync(log, JSON.stringify({ ts: new Date(Date.now() - daysAgo * 86400000).toISOString(), kind: 'guard', file: `${id}.md` }) + '\n');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const addP = (p, home, text, extra = {}) => lessons.add({ scope: 'project', text, projectPath: p, home, ...extra });
|
|
23
|
+
|
|
24
|
+
test('dedup: a similar text bumps recurrences and writes no file; a dissimilar text writes one', () => {
|
|
25
|
+
const home = mk(); const p = project();
|
|
26
|
+
const a = addP(p, home, 'never run the deploy script on the main branch');
|
|
27
|
+
const r = addP(p, home, 'never run deploy script on main branch');
|
|
28
|
+
assert.equal(r.recurred, true); assert.equal(r.id, a.id); assert.equal(r.recurrences, 1);
|
|
29
|
+
assert.equal(files(projKnowledge(p)).length, 1);
|
|
30
|
+
assert.equal(addP(p, home, 'never run deploy script on main branch').recurrences, 2);
|
|
31
|
+
assert.match(fs.readFileSync(path.join(projKnowledge(p), `${a.id}.md`), 'utf8'), /recurrences: 2\r?\nlast_recurrence: /);
|
|
32
|
+
const ctl = addP(p, home, 'use pnpm for installing packages here');
|
|
33
|
+
assert.ok(!ctl.recurred); assert.equal(files(projKnowledge(p)).length, 2);
|
|
34
|
+
assert.ok(!addP(p, home, 'never run deploy script on main branch', { force: true }).recurred);
|
|
35
|
+
assert.equal(files(projKnowledge(p)).length, 3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('dedup: Chinese text dedups by character', () => {
|
|
39
|
+
const home = mk(); const p = project();
|
|
40
|
+
const a = addP(p, home, '部署前必須先跑測試');
|
|
41
|
+
assert.equal(addP(p, home, '部署前必須先執行測試').id, a.id);
|
|
42
|
+
assert.equal(files(projKnowledge(p)).length, 1);
|
|
43
|
+
assert.ok(!addP(p, home, '回覆一律使用繁體中文').recurred);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('cap: the 21st active lesson is refused with merge suggestions and nothing is written', () => {
|
|
47
|
+
const home = mk(); const p = project();
|
|
48
|
+
for (let i = 0; i < lessons.MAX_ACTIVE_PER_LAYER; i++) addP(p, home, `lesson${i}x unique${i}y words${i}z`);
|
|
49
|
+
assert.equal(files(projKnowledge(p)).length, 20);
|
|
50
|
+
assert.throws(() => addP(p, home, 'completely other thing about databases'), (e) => /cap of 20/.test(e.message) && (e.message.match(/kenaz learn merge/g) || []).length === 3);
|
|
51
|
+
assert.equal(files(projKnowledge(p)).length, 20);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('merge: one new lesson, sources archived with merged_into, fires and recurrences summed', () => {
|
|
55
|
+
const home = mk(); const p = project();
|
|
56
|
+
const a = addP(p, home, 'alpha rule one', { paths: ['src/a'] }); const b = addP(p, home, 'beta other rule two', { paths: ['src/b'] });
|
|
57
|
+
fire(p, a.id, 2); fire(p, b.id, 1);
|
|
58
|
+
const r = lessons.merge([a.id, b.id], { text: 'general rule for both', projectPath: p, home });
|
|
59
|
+
const rows = lessons.listAll({ projectPath: p, home });
|
|
60
|
+
assert.equal(rows.length, 1); assert.equal(rows[0].id, r.id);
|
|
61
|
+
assert.equal(rows[0].fires, 3); assert.deepEqual(rows[0].paths.sort(), ['src/a', 'src/b']);
|
|
62
|
+
const arch = path.join(projKnowledge(p), 'archive', 'merged');
|
|
63
|
+
assert.deepEqual(files(arch).sort(), [`${a.id}.md`, `${b.id}.md`].sort());
|
|
64
|
+
assert.match(fs.readFileSync(path.join(arch, `${a.id}.md`), 'utf8'), new RegExp(`merged_into: "${r.id}"`));
|
|
65
|
+
assert.throws(() => lessons.merge([a.id, b.id], { text: 'x', projectPath: p, home }), /no active lesson/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('graduate: archived under graduated/ with graduated_to', () => {
|
|
69
|
+
const home = mk(); const p = project();
|
|
70
|
+
const a = addP(p, home, 'something that became a test');
|
|
71
|
+
lessons.graduate(a.id, 'abc1234', { projectPath: p, home });
|
|
72
|
+
assert.equal(lessons.listAll({ projectPath: p, home }).length, 0);
|
|
73
|
+
assert.match(fs.readFileSync(path.join(projKnowledge(p), 'archive', 'graduated', `${a.id}.md`), 'utf8'), /graduated_to: "abc1234"/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('review: 3 fires and 2 recurrences are candidates, 1 fire is not; similar pairs and old prefs listed', () => {
|
|
77
|
+
const home = mk(); const p = project();
|
|
78
|
+
const three = addP(p, home, 'first topic zebra alpha'); const rec = addP(p, home, 'second matter quartz beta');
|
|
79
|
+
const one = addP(p, home, 'third item walnut gamma');
|
|
80
|
+
fire(p, three.id, 3); fire(p, one.id, 1);
|
|
81
|
+
addP(p, home, 'second matter quartz beta'); addP(p, home, 'second matter quartz beta');
|
|
82
|
+
addP(p, home, 'third item walnut gamma delta', { force: true });
|
|
83
|
+
const old = lessons.add({ scope: 'user', text: 'old preference about tabs', projectPath: p, home });
|
|
84
|
+
const f = path.join(home, '.kenaz', 'knowledge', `${old.id}.md`);
|
|
85
|
+
fs.writeFileSync(f, fs.readFileSync(f, 'utf8').replace(/created: .*/, `created: ${new Date(Date.now() - 100 * 86400000).toISOString()}`));
|
|
86
|
+
const r = lessons.reviewAll({ projectPath: p, home });
|
|
87
|
+
assert.deepEqual(r.candidates.map((l) => l.id).sort(), [three.id, rec.id].sort());
|
|
88
|
+
assert.ok(!r.candidates.some((l) => l.id === one.id));
|
|
89
|
+
assert.deepEqual(r.oldPrefs.map((l) => l.id), [old.id]);
|
|
90
|
+
assert.ok(r.pairs.some((x) => [x.a.id, x.b.id].includes(one.id)));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('banner: nudge only when there is something to act on; prefs capped at 10 plus +K more', () => {
|
|
94
|
+
const home = mk(); const p = project();
|
|
95
|
+
addP(p, home, 'plain fact about widgets');
|
|
96
|
+
assert.ok(!lessons.bannerLines(p, { home }).some((l) => l.startsWith('[lessons]')));
|
|
97
|
+
const a = addP(p, home, 'fact two about gadgets'); fire(p, a.id, 3);
|
|
98
|
+
assert.ok(lessons.bannerLines(p, { home }).includes('[lessons] 1 to graduate, 0 near cap: kenaz learn review'));
|
|
99
|
+
const home2 = mk(); const p2 = project();
|
|
100
|
+
for (let i = 0; i < 18; i++) addP(p2, home2, `lesson${i}x unique${i}y words${i}z`);
|
|
101
|
+
assert.ok(lessons.bannerLines(p2, { home: home2 }).includes('[lessons] 0 to graduate, 1 near cap: kenaz learn review'));
|
|
102
|
+
const home3 = mk(); const p3 = project();
|
|
103
|
+
for (let i = 0; i < 12; i++) lessons.add({ scope: 'user', text: `pref${i}x unique${i}y words${i}z`, projectPath: p3, home: home3 });
|
|
104
|
+
const lines = lessons.bannerLines(p3, { home: home3 });
|
|
105
|
+
assert.equal(lines.filter((l) => l.startsWith('[learn] pref:')).length, 10);
|
|
106
|
+
assert.ok(lines.includes('[learn] +2 more'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('stats: per-layer lifecycle counts, read-only', () => {
|
|
110
|
+
const home = mk(); const p = project();
|
|
111
|
+
const a = addP(p, home, 'stat lesson one'); const b = addP(p, home, 'other thing two here');
|
|
112
|
+
fire(p, a.id, 2); addP(p, home, 'stat lesson one');
|
|
113
|
+
lessons.graduate(b.id, 'x', { projectPath: p, home });
|
|
114
|
+
const s = lessons.stats({ projectPath: p, home });
|
|
115
|
+
assert.deepEqual([s.project.active, s.project.fires, s.project.recurrences, s.project.graduated, s.project.merged], [1, 2, 1, 1, 0]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('npm path: kenaz learn review|merge|graduate through cli.js against a staged ~/.kenaz/core', () => {
|
|
119
|
+
const home = mk(); const p = project();
|
|
120
|
+
const core = path.join(home, '.kenaz', 'core');
|
|
121
|
+
fs.mkdirSync(path.join(core, '.specialist', 'tools'), { recursive: true });
|
|
122
|
+
fs.mkdirSync(path.join(core, '.vendor'), { recursive: true });
|
|
123
|
+
for (const n of ['lessons.js', 'lessons-lifecycle.js', 'lessons-harvest.js']) fs.copyFileSync(path.join(ROOT, '.specialist', 'tools', n), path.join(core, '.specialist', 'tools', n));
|
|
124
|
+
for (const n of ['js-yaml.cjs', 'require-yaml.cjs']) fs.copyFileSync(path.join(ROOT, '.vendor', n), path.join(core, '.vendor', n));
|
|
125
|
+
const { run } = require(path.join(ROOT, 'packages', 'kenaz', 'lib', 'cli.js'));
|
|
126
|
+
const go = (...argv) => { const out = []; const code = run(['learn', ...argv, '--home', home, '--project-path', p], (l) => out.push(l)); return { code, text: out.join('\n') }; };
|
|
127
|
+
const first = go('add', '--scope', 'project', '--about', 'kenaz', '--text', 'kenaz banner hides the wish line');
|
|
128
|
+
assert.equal(first.code, 0);
|
|
129
|
+
const id = /learned (lesson-\w+)/.exec(first.text)[1];
|
|
130
|
+
assert.match(go('add', '--scope', 'project', '--text', 'kenaz banner hides the wish line').text, /recurred lesson-\w+ \(1 times\)/);
|
|
131
|
+
go('add', '--scope', 'project', '--text', 'kenaz banner hides the wish line');
|
|
132
|
+
const rev = go('review').text;
|
|
133
|
+
assert.match(rev, /make it code in this project/); assert.match(rev, new RegExp(id));
|
|
134
|
+
assert.match(rev, /kenaz_wish_create scope=kenaz/);
|
|
135
|
+
const other = /learned (lesson-\w+)/.exec(go('add', '--scope', 'project', '--text', 'use pnpm for installs here').text)[1];
|
|
136
|
+
const m = go('merge', id, other, '--text', 'general rule');
|
|
137
|
+
assert.equal(m.code, 0); assert.match(m.text, /merged/);
|
|
138
|
+
const newId = /into (lesson-\w+)/.exec(m.text)[1];
|
|
139
|
+
assert.match(go('graduate', newId, '--to', 'deadbeef').text, /graduated/);
|
|
140
|
+
assert.equal(lessons.listAll({ projectPath: p, home: mk() }).length, 0);
|
|
141
|
+
assert.equal(files(path.join(projKnowledge(p), 'archive', 'graduated')).length, 1);
|
|
142
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2427: harvest from the project's own history, review suggestions, nudge log, self-improvement stats.
|
|
3
|
+
// Temp git repos and temp homes only, never the real ~/.kenaz or ~/.claude.
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
10
|
+
const lessons = require('../lessons');
|
|
11
|
+
const hv = require('../lessons-harvest');
|
|
12
|
+
const stats = require('../../../.formation/tools/kenaz-stats');
|
|
13
|
+
|
|
14
|
+
const mk = () => fs.mkdtempSync(path.join(os.tmpdir(), 'k2427-'));
|
|
15
|
+
const put = (file, body) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, body); };
|
|
16
|
+
|
|
17
|
+
/** Repo with: TASK_1 clean, TASK_2 reviewer fix on the branch after delivery, TASK_3 fix-up on main after the merge. */
|
|
18
|
+
function repo({ rework = true } = {}) {
|
|
19
|
+
const dir = mk();
|
|
20
|
+
const g = (...a) => execFileSync('git', a, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
g('init', '-q', '-b', 'main'); g('config', 'user.email', 't@t'); g('config', 'user.name', 't'); g('config', 'commit.gpgsign', 'false');
|
|
22
|
+
const commit = (msg, file) => { put(path.join(dir, file), msg + Math.random()); g('add', '-A'); g('commit', '-q', '-m', msg); };
|
|
23
|
+
const land = (card, br, steps) => {
|
|
24
|
+
g('checkout', '-q', '-b', br);
|
|
25
|
+
for (const [m, f] of steps) commit(m, f);
|
|
26
|
+
g('checkout', '-q', 'main'); g('merge', '--no-ff', '-q', '-m', `Merge ${card}: done`, br);
|
|
27
|
+
};
|
|
28
|
+
commit('base', 'README.md');
|
|
29
|
+
land('TASK_1', 'b1', [['[TASK_1] task completed', 'src/one.js']]);
|
|
30
|
+
if (rework) {
|
|
31
|
+
land('TASK_2', 'b2', [['[TASK_2] task completed', 'src/two.js'], ['[TASK_2] reviewer fix', 'src/fix.js']]);
|
|
32
|
+
land('TASK_3', 'b3', [['[TASK_3] task completed', 'src/three.js']]);
|
|
33
|
+
commit('[TASK_3] fix-up after merge', 'src/late.js');
|
|
34
|
+
}
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('rework is harvested once with the files of the extra commits, and disappears after add --source harvest:<id>', () => {
|
|
39
|
+
const dir = repo(); const home = mk();
|
|
40
|
+
const items = hv.harvest({ projectPath: dir });
|
|
41
|
+
const ids = items.map((i) => i.id).sort();
|
|
42
|
+
assert.deepEqual(ids, ['rework:TASK_2', 'rework:TASK_3']);
|
|
43
|
+
const t2 = items.find((i) => i.id === 'rework:TASK_2');
|
|
44
|
+
assert.deepEqual(t2.paths, ['src/fix.js']);
|
|
45
|
+
assert.match(t2.cmd, /^kenaz learn add --scope project --type check --paths src\/fix\.js --text "<why: .*" --source harvest:rework:TASK_2$/);
|
|
46
|
+
assert.deepEqual(items.find((i) => i.id === 'rework:TASK_3').paths, ['src/late.js']);
|
|
47
|
+
const r = lessons.add({ scope: 'project', text: 'run the reviewer checklist before delivering', paths: ['src/fix.js'], source: 'harvest:rework:TASK_2', projectPath: dir, home });
|
|
48
|
+
assert.ok(r.id);
|
|
49
|
+
assert.match(fs.readFileSync(path.join(dir, '.kenaz', 'knowledge', `${r.id}.md`), 'utf8'), /source: "harvest:rework:TASK_2"/);
|
|
50
|
+
assert.deepEqual(hv.harvest({ projectPath: dir }).map((i) => i.id), ['rework:TASK_3']);
|
|
51
|
+
assert.throws(() => lessons.add({ scope: 'project', text: 'x y', source: 'bogus', projectPath: dir, home }), /--source/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('control: a clean history harvests nothing and the banner prints no line', () => {
|
|
55
|
+
const dir = repo({ rework: false }); const home = mk();
|
|
56
|
+
assert.deepEqual(hv.harvest({ projectPath: dir }), []);
|
|
57
|
+
assert.deepEqual(lessons.bannerLines(dir, { home, syncHarvest: true }), []);
|
|
58
|
+
const out = []; lessons.main(['harvest', '--project-path', dir, '--home', home], (l) => out.push(l));
|
|
59
|
+
assert.deepEqual(out, ['no history items to harvest']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('failed executor result, blocked card with a reason and a handoff A-section risk line are harvested', () => {
|
|
63
|
+
const dir = repo({ rework: false }); const execDir = mk(); const now = Date.now();
|
|
64
|
+
put(path.join(execDir, 'TASK_9-abc.result.json'), JSON.stringify({ adapter: 'deepseek', model: 'v3', ok: false, exitCode: 1, error: 'no commits', worktree: path.join(dir, '.wt', 'x') }));
|
|
65
|
+
put(path.join(execDir, 'TASK_8-def.result.json'), JSON.stringify({ adapter: 'codex', ok: true, worktree: path.join(dir, '.wt', 'y') }));
|
|
66
|
+
put(path.join(execDir, 'TASK_7-zzz.result.json'), JSON.stringify({ adapter: 'codex', ok: false, error: 'other project', worktree: path.join(mk(), 'wt') }));
|
|
67
|
+
put(path.join(dir, '.kenaz', 'tasks', 'blocked', 'TASK_5_thing.yaml'), 'id: TASK_5\nblocked_reason: needs the vendor key\nscope_paths:\n - src/api\n');
|
|
68
|
+
put(path.join(dir, '.kenaz', 'tasks', 'blocked', 'TASK_6_thing.yaml'), 'id: TASK_6\n');
|
|
69
|
+
put(path.join(dir, '.kenaz', 'handoffs', '2026-10-11-1200-x.md'), [
|
|
70
|
+
'# Handoff: x', '', '## A · Assessment(評估)', '- 已驗證(怎麼驗證的):unit tests', '- 未驗證 / 風險 / 阻塞:', ' - Windows path handling is untested', '- unverified: migration on a big db', '## R · Recommendation', '- risk: not an A-section line', ''].join('\n'));
|
|
71
|
+
const items = hv.harvest({ projectPath: dir, now, execDir });
|
|
72
|
+
assert.deepEqual(items.map((i) => i.id.split(':')[0]).sort(), ['blocked', 'exec', 'handoff', 'handoff']);
|
|
73
|
+
assert.match(items.find((i) => i.id.startsWith('exec:')).why, /no commits/);
|
|
74
|
+
assert.deepEqual(items.find((i) => i.id === 'blocked:TASK_5').paths, ['src/api']);
|
|
75
|
+
const hand = items.filter((i) => i.id.startsWith('handoff:')).map((i) => i.why).join('|');
|
|
76
|
+
assert.match(hand, /Windows path handling/); assert.match(hand, /migration on a big db/); assert.ok(!/not an A-section/.test(hand));
|
|
77
|
+
assert.deepEqual(hv.harvest({ projectPath: dir, now: now + 40 * 86400000, execDir }), [], 'items age out after the harvest window (7 days)');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('banner: one line at most, merged with the lessons line when both apply', () => {
|
|
81
|
+
const dir = repo(); const home = mk();
|
|
82
|
+
assert.deepEqual(lessons.bannerLines(dir, { home, syncHarvest: true }), ['[learn] 2 history items to review: kenaz learn review']);
|
|
83
|
+
const a = lessons.add({ scope: 'project', text: 'alpha rule', projectPath: dir, home, force: true });
|
|
84
|
+
for (let i = 0; i < 3; i++) {
|
|
85
|
+
const log = path.join(dir, '.kenaz', 'specialist', 'memory_usage.jsonl');
|
|
86
|
+
fs.appendFileSync(log, JSON.stringify({ ts: new Date().toISOString(), kind: 'guard', file: `${a.id}.md` }) + '\n');
|
|
87
|
+
}
|
|
88
|
+
const lines = lessons.bannerLines(dir, { home, syncHarvest: true });
|
|
89
|
+
assert.equal(lines.filter((l) => /review/.test(l)).length, 1);
|
|
90
|
+
assert.match(lines.join('\n'), /\[lessons\] 1 to graduate, 0 near cap, 2 history items: kenaz learn review/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('review prints the per-rigor suggestion and the top rework files from a fake dashboard-models.json', () => {
|
|
94
|
+
const dir = repo(); const home = mk();
|
|
95
|
+
for (const [id, r] of [['TASK_1', 'high'], ['TASK_2', 'high'], ['TASK_3', 'high']]) put(path.join(dir, '.kenaz', 'tasks', 'done', `${id}_x.yaml`), `id: ${id}\nrigor: ${r}\n`);
|
|
96
|
+
put(path.join(dir, '.kenaz', 'dashboard-models.json'), JSON.stringify({ dispatchFamily: 'claude', dispatchModelByRigor: { high: 'claude-sonnet-4-5' } }));
|
|
97
|
+
const out = []; lessons.main(['review', '--project-path', dir, '--home', home], (l) => out.push(l));
|
|
98
|
+
const text = out.join('\n');
|
|
99
|
+
assert.match(text, /high: 1\/3 first-pass with .*sonnet.*consider kenaz subagent set .* --rigor high/);
|
|
100
|
+
assert.match(text, /most rework commits: .*src\/(fix|late)\.js \(1\)/);
|
|
101
|
+
assert.match(text, /rework:TASK_2/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('correction-detect appends a nudge line without the prompt text', () => {
|
|
105
|
+
const dir = mk(); const home = mk();
|
|
106
|
+
put(path.join(dir, '.kenaz', 'project.yaml'), 'name: t\n');
|
|
107
|
+
const hook = path.join(__dirname, '..', '..', '..', '.claude', 'hooks', 'correction-detect.js');
|
|
108
|
+
const secret = '不是這樣 SECRET-PROMPT-TEXT';
|
|
109
|
+
const r = spawnSync(process.execPath, [hook], { input: JSON.stringify({ session_id: 's1', prompt: secret }), encoding: 'utf8',
|
|
110
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: dir, USERPROFILE: home, HOME: home, KENAZ_MODE: '' } });
|
|
111
|
+
assert.match(r.stdout, /--source correction-nudge/);
|
|
112
|
+
const log = fs.readFileSync(path.join(dir, '.kenaz', 'specialist', 'correction-nudges.jsonl'), 'utf8');
|
|
113
|
+
const row = JSON.parse(log.trim());
|
|
114
|
+
assert.equal(row.session_id, 's1'); assert.ok(Date.parse(row.ts));
|
|
115
|
+
assert.ok(!/SECRET|不是/.test(log));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('stats: nudge conversion, lessons by source, recurrence after a fire, harvest open vs recorded, graduated', () => {
|
|
119
|
+
const dir = repo(); const home = mk(); const now = Date.now();
|
|
120
|
+
const nudge = (msAgo) => fs.appendFileSync(path.join(dir, '.kenaz', 'specialist', 'correction-nudges.jsonl'), JSON.stringify({ ts: new Date(now - msAgo).toISOString(), session_id: 's' }) + '\n');
|
|
121
|
+
put(path.join(dir, '.kenaz', 'specialist', 'correction-nudges.jsonl'), '');
|
|
122
|
+
nudge(3 * 3600000); // lesson added 5 min later: converted
|
|
123
|
+
nudge(2 * 3600000); // nothing follows: not converted
|
|
124
|
+
const a = lessons.add({ scope: 'project', text: 'always run the linter first', source: 'correction-nudge', projectPath: dir, home });
|
|
125
|
+
// move the lesson into the window right after the first nudge
|
|
126
|
+
const f = path.join(dir, '.kenaz', 'knowledge', `${a.id}.md`);
|
|
127
|
+
fs.writeFileSync(f, fs.readFileSync(f, 'utf8').replace(/created: .*/, `created: ${new Date(now - 3 * 3600000 + 5 * 60000).toISOString()}`));
|
|
128
|
+
const logUse = path.join(dir, '.kenaz', 'specialist', 'memory_usage.jsonl');
|
|
129
|
+
const line = (kind, msAgo) => JSON.stringify({ ts: new Date(now - msAgo).toISOString(), kind, file: `${a.id}.md` }) + '\n';
|
|
130
|
+
fs.appendFileSync(logUse, line('recurrence', 5 * 3600000) + line('guard', 4 * 3600000) + line('recurrence', 3600000));
|
|
131
|
+
lessons.add({ scope: 'project', text: 'harvested lesson about fix', source: 'harvest:rework:TASK_2', projectPath: dir, home });
|
|
132
|
+
const g = lessons.add({ scope: 'project', text: 'graduate me please', projectPath: dir, home });
|
|
133
|
+
lessons.graduate(g.id, 'abc123', { projectPath: dir, home });
|
|
134
|
+
const s = hv.selfStats({ projectPath: dir, home, now, days: 14 });
|
|
135
|
+
assert.equal(s.nudges, 2); assert.equal(s.converted, 1);
|
|
136
|
+
assert.equal(s.bySource['correction-nudge'].lessons, 1);
|
|
137
|
+
assert.deepEqual([s.bySource['correction-nudge'].recurBefore, s.bySource['correction-nudge'].recurAfter, s.bySource['correction-nudge'].fired], [1, 1, 1]);
|
|
138
|
+
assert.equal(s.bySource.harvest.lessons, 1); assert.equal(s.bySource.manual.lessons, 1);
|
|
139
|
+
assert.equal(s.graduated, 1); assert.equal(s.harvestOpen, 1); assert.equal(s.harvestRecorded, 1);
|
|
140
|
+
const all = stats.collect({ home, cwd: dir, days: 14, now });
|
|
141
|
+
assert.equal(all.selfImprovement.nudges, 2);
|
|
142
|
+
assert.match(stats.render(all), /Self-improvement[\s\S]*correction nudges 2: lesson recorded within 15 min after 1 \(50%\)[\s\S]*first-pass merge rate/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('boot never waits for the harvest scan: no cache -> no line now, a detached refresh is started', () => {
|
|
146
|
+
const dir = repo(); const home = mk();
|
|
147
|
+
const started = [];
|
|
148
|
+
assert.deepEqual(lessons.bannerLines(dir, { home, refreshHarvest: (p) => started.push(p) }), []);
|
|
149
|
+
assert.deepEqual(started, [dir]);
|
|
150
|
+
});
|
|
@@ -1021,6 +1021,7 @@ async function main() {
|
|
|
1021
1021
|
// line above) so the query is scoped to the bound project when pinned.
|
|
1022
1022
|
const wishesLine = formatWishesLine(await fetchOpenWishes(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : null));
|
|
1023
1023
|
if (wishesLine) lines.push(wishesLine);
|
|
1024
|
+
for (const l of handoffDocLines(BASE)) lines.push(l);
|
|
1024
1025
|
for (const l of openCardLines()) lines.push(l);
|
|
1025
1026
|
const landedLine = landedActiveCardsLine();
|
|
1026
1027
|
if (landedLine) lines.push(landedLine);
|
|
@@ -1028,7 +1029,7 @@ async function main() {
|
|
|
1028
1029
|
try { for (const l of require('./lessons').bannerLines(BASE)) lines.push(l); } catch { /* optional */ }
|
|
1029
1030
|
const peerLines = await fetchPendingPeerLines(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
1030
1031
|
for (const l of peerLines) lines.push(l);
|
|
1031
|
-
lines.push('(boot
|
|
1032
|
+
lines.push('(boot 完成:' + (findHandoffDocs(BASE).length ? '先讀 → 標記的 handoff 全文,再' : '') + '依 Open cards 提議程;小修正直接做,中型工作開卡派工)');
|
|
1032
1033
|
process.stdout.write(lines.join(String.fromCharCode(10)) + String.fromCharCode(10));
|
|
1033
1034
|
}
|
|
1034
1035
|
|
|
@@ -1043,6 +1044,39 @@ if (require.main === module) {
|
|
|
1043
1044
|
});
|
|
1044
1045
|
}
|
|
1045
1046
|
|
|
1047
|
+
// Project handoff documents: markdown a previous session wrote for the next one. Standard place:
|
|
1048
|
+
// .kenaz/handoffs/*.md (ISBAR, created by handoff-doc.js); older hand-written ones are still found
|
|
1049
|
+
// as handoff*.md in .kenaz/, the project root or docs/. Read-only: nothing is archived or moved.
|
|
1050
|
+
// Newest first; the banner tells Ansuz to read the newest in full.
|
|
1051
|
+
const HANDOFF_DOC_DIRS = [['.kenaz/handoffs', /\.md$/i], ['.kenaz', /^handoff.*\.md$/i], ['', /^handoff.*\.md$/i], ['docs', /^handoff.*\.md$/i]];
|
|
1052
|
+
const HANDOFF_DOC_MAX = 3;
|
|
1053
|
+
function findHandoffDocs(base, now = Date.now()) {
|
|
1054
|
+
const docs = [];
|
|
1055
|
+
for (const [d, re] of HANDOFF_DOC_DIRS) {
|
|
1056
|
+
let names = [];
|
|
1057
|
+
try { names = fs.readdirSync(path.join(base, d)); } catch { continue; }
|
|
1058
|
+
for (const n of names) {
|
|
1059
|
+
if (!re.test(n)) continue;
|
|
1060
|
+
const fp = path.join(base, d, n);
|
|
1061
|
+
let st; try { st = fs.statSync(fp); } catch { continue; }
|
|
1062
|
+
if (!st.isFile()) continue;
|
|
1063
|
+
let title = '';
|
|
1064
|
+
try { const m = fs.readFileSync(fp, 'utf8').slice(0, 4096).match(/^#\s+(.+)$/m); if (m) title = m[1].trim().slice(0, 100); } catch { /* unreadable */ }
|
|
1065
|
+
docs.push({ rel: (d ? d + '/' : '') + n, mtimeMs: st.mtimeMs, ageDays: Math.floor((now - st.mtimeMs) / 86400000), title });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return docs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1069
|
+
}
|
|
1070
|
+
function handoffDocLines(base, now) {
|
|
1071
|
+
const docs = findHandoffDocs(base, now);
|
|
1072
|
+
if (!docs.length) return [];
|
|
1073
|
+
const age = (n) => (n <= 0 ? 'today' : n + 'd ago');
|
|
1074
|
+
const lines = ['📎 Project handoff (read the newest in full with Read BEFORE proposing the agenda; it overrides the card list where they disagree):'];
|
|
1075
|
+
docs.slice(0, HANDOFF_DOC_MAX).forEach((h, i) => lines.push(' ' + (i === 0 ? '→ ' : ' ') + h.rel + ' (' + age(h.ageDays) + ')' + (h.title ? ' ' + h.title : '')));
|
|
1076
|
+
if (docs.length > HANDOFF_DOC_MAX) lines.push(' … ' + (docs.length - HANDOFF_DOC_MAX) + ' older');
|
|
1077
|
+
return lines;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1046
1080
|
// TASK_2404: active cards whose task branch is already an ancestor of origin/main (local remote-tracking
|
|
1047
1081
|
// refs only -- no fetch at boot). Silent when none or when anything fails.
|
|
1048
1082
|
function landedActiveCardsLine() {
|
|
@@ -1062,6 +1096,8 @@ function landedActiveCardsLine() {
|
|
|
1062
1096
|
}
|
|
1063
1097
|
|
|
1064
1098
|
module.exports = {
|
|
1099
|
+
findHandoffDocs,
|
|
1100
|
+
handoffDocLines,
|
|
1065
1101
|
isOutsideAnyProject,
|
|
1066
1102
|
harnessSessionIdFromEnv,
|
|
1067
1103
|
hintNamesThisSession,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Project handoff document (ISBAR) for the next session.
|
|
4
|
+
// node handoff-doc.js new --topic "<topic>" [--session <id>] [--project-path <dir>]
|
|
5
|
+
// creates <project>/.kenaz/handoffs/<YYYY-MM-DD-HHmm>-<slug>.md from the ISBAR template with the
|
|
6
|
+
// Identification section filled in, and prints its path; the writer then fills S/B/A/R.
|
|
7
|
+
// node handoff-doc.js latest [--project-path <dir>] prints the newest handoff's path
|
|
8
|
+
// ansuz-boot-fast.js lists .kenaz/handoffs/ newest first and tells Ansuz to read the newest in full.
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const REL_DIR = path.join('.kenaz', 'handoffs');
|
|
15
|
+
|
|
16
|
+
function slugify(topic) {
|
|
17
|
+
const s = String(topic || '').trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
|
18
|
+
return s || 'handoff';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stamp(d) {
|
|
22
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
23
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function git(projectPath, args) {
|
|
27
|
+
try { return execFileSync('git', args, { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function template({ topic, session, projectPath, now }) {
|
|
31
|
+
const branch = git(projectPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
32
|
+
const head = git(projectPath, ['rev-parse', '--short', 'HEAD']);
|
|
33
|
+
return [
|
|
34
|
+
'# Handoff: ' + topic,
|
|
35
|
+
'',
|
|
36
|
+
'## I · Identification',
|
|
37
|
+
'- 時間:' + now.toISOString(),
|
|
38
|
+
'- 專案:' + projectPath,
|
|
39
|
+
'- 撰寫者:Ansuz' + (session ? '(session ' + session + ')' : '') + ',節點 ' + os.hostname(),
|
|
40
|
+
'- 分支 / HEAD:' + (branch || '—') + ' @ ' + (head || '—'),
|
|
41
|
+
'',
|
|
42
|
+
'## S · Situation(現況)',
|
|
43
|
+
'- 一句話:現在在做什麼、做到哪裡',
|
|
44
|
+
'- 進行中的卡 / 分支 / worktree:',
|
|
45
|
+
'',
|
|
46
|
+
'## B · Background(背景)',
|
|
47
|
+
'- 用戶的決定與理由(原話優先):',
|
|
48
|
+
'- 相關卡、commit、文件:',
|
|
49
|
+
'',
|
|
50
|
+
'## A · Assessment(評估)',
|
|
51
|
+
'- 已驗證(怎麼驗證的):',
|
|
52
|
+
'- 未驗證 / 風險 / 阻塞:',
|
|
53
|
+
'',
|
|
54
|
+
'## R · Recommendation(建議)',
|
|
55
|
+
'- 下一個 session 的第一步:',
|
|
56
|
+
'- 待用戶決定的事:',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function create(projectPath, topic, session, now = new Date()) {
|
|
62
|
+
const dir = path.join(projectPath, REL_DIR);
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
let file = path.join(dir, stamp(now) + '-' + slugify(topic) + '.md');
|
|
65
|
+
for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, stamp(now) + '-' + slugify(topic) + '-' + i + '.md');
|
|
66
|
+
fs.writeFileSync(file, template({ topic, session, projectPath, now }));
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function latest(projectPath) {
|
|
71
|
+
const dir = path.join(projectPath, REL_DIR);
|
|
72
|
+
let names = [];
|
|
73
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')); } catch { return null; }
|
|
74
|
+
const withTime = names.map((n) => ({ f: path.join(dir, n), t: fs.statSync(path.join(dir, n)).mtimeMs }));
|
|
75
|
+
withTime.sort((a, b) => b.t - a.t);
|
|
76
|
+
return withTime.length ? withTime[0].f : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function main(argv, out = console.log, cwd = process.cwd()) {
|
|
80
|
+
const val = (k) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : undefined; };
|
|
81
|
+
const projectPath = path.resolve(val('project-path') || process.env.CLAUDE_PROJECT_DIR || cwd);
|
|
82
|
+
if (argv[0] === 'new') {
|
|
83
|
+
const topic = val('topic');
|
|
84
|
+
if (!topic) { out('usage: handoff-doc.js new --topic "<topic>" [--session <id>] [--project-path <dir>]'); return 2; }
|
|
85
|
+
out(create(projectPath, topic, val('session')));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (argv[0] === 'latest') { const f = latest(projectPath); out(f || 'no handoff in ' + path.join(projectPath, REL_DIR)); return f ? 0 : 1; }
|
|
89
|
+
out('usage: handoff-doc.js <new --topic "<topic>" [--session <id>] | latest> [--project-path <dir>]');
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
94
|
+
|
|
95
|
+
module.exports = { create, latest, slugify, template, main, REL_DIR };
|