kenaz 0.3.11 → 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/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -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/bifrost/setup-codex.js +84 -16
- 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/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/Ansuz/RULES.md +7 -2
- package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -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/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/scripts/correction-detect-eval.js +70 -0
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -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/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/statusline.js +60 -0
- package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +27 -10
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +193 -16
- package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Self-improvement from the project's own history (TASK_2427). Read-only suggestions: nothing here writes a lesson.
|
|
4
|
+
* harvest history items not yet recorded, each with a ready `kenaz learn add ... --source harvest:<id>`
|
|
5
|
+
* (a) rework: a card's branch got commits after the agent's delivery, or a fix-up after the merge
|
|
6
|
+
* (b) failed verification: executor result files with ok=false; cards moved to blocked with a reason
|
|
7
|
+
* (c) handoffs: bullets under the A (Assessment) section's unverified / risk lines
|
|
8
|
+
* metrics first-pass merge rate per rigor vs the current `kenaz subagent` model; the 3 files with most rework commits
|
|
9
|
+
* selfStats numbers for `kenaz stats` section Self-improvement (benefit measurement, DESIGN.md decision rule)
|
|
10
|
+
* Recorded items live in <project>/.kenaz/knowledge/.harvested.json {id: iso time}; items older than 30 days age out.
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const yaml = require('../../.vendor/require-yaml.cjs');
|
|
17
|
+
|
|
18
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
19
|
+
const WINDOW_DAYS = 30;
|
|
20
|
+
// Harvested items ask Ansuz for the why; weeks later nobody remembers it (measured 2026-10-11: a 30-day
|
|
21
|
+
// window listed 27 items in this repo, mostly cards nobody had context on any more). Metrics keep 30 days.
|
|
22
|
+
const HARVEST_DAYS = 7;
|
|
23
|
+
const LIST_MAX = 10;
|
|
24
|
+
const NUDGE_CONVERSION_MS = 15 * 60 * 1000;
|
|
25
|
+
const MAX_PATHS = 6;
|
|
26
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
27
|
+
const RIGORS = ['low', 'medium', 'high'];
|
|
28
|
+
const SUGGEST_BELOW = 0.7;
|
|
29
|
+
const MIN_CARDS = 3;
|
|
30
|
+
|
|
31
|
+
const root = (p) => path.resolve(p || process.cwd());
|
|
32
|
+
const harvestedFile = (p) => path.join(root(p), '.kenaz', 'knowledge', '.harvested.json');
|
|
33
|
+
const cacheFile = (p) => path.join(root(p), '.kenaz', 'specialist', 'harvest-cache.json');
|
|
34
|
+
const nudgeLog = (p) => path.join(root(p), '.kenaz', 'specialist', 'correction-nudges.jsonl');
|
|
35
|
+
const norm = (s) => String(s).replace(/\\/g, '/');
|
|
36
|
+
const one = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
37
|
+
|
|
38
|
+
function readHarvested(p) {
|
|
39
|
+
try { return JSON.parse(fs.readFileSync(harvestedFile(p), 'utf8')) || {}; } catch (_) { return {}; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Record an item as harvested (called by `learn add --source harvest:<id>`); drops the banner cache. */
|
|
43
|
+
function markHarvested(p, id, now = Date.now()) {
|
|
44
|
+
const state = readHarvested(p);
|
|
45
|
+
state[id] = new Date(now).toISOString();
|
|
46
|
+
fs.mkdirSync(path.dirname(harvestedFile(p)), { recursive: true });
|
|
47
|
+
fs.writeFileSync(harvestedFile(p), JSON.stringify(state, null, 2) + '\n');
|
|
48
|
+
try { fs.unlinkSync(cacheFile(p)); } catch (_) { /* none */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stats = () => require('../../.formation/tools/kenaz-stats.js');
|
|
52
|
+
|
|
53
|
+
/** Files a commit touched, minus Kenaz bookkeeping. */
|
|
54
|
+
function commitFiles(repo, sha) {
|
|
55
|
+
try {
|
|
56
|
+
return stats().git(repo, 'show', '--name-only', '--format=', sha).split('\n').map(norm).filter((f) => f && !f.startsWith('.kenaz/'));
|
|
57
|
+
} catch (_) { return []; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** (a) every rework card of the window with the files its extra commits touched (reuses kenaz-stats' detection). */
|
|
61
|
+
function reworkItems(repo, now, days) {
|
|
62
|
+
const d = stats().dispatchStats({ repo, now, days });
|
|
63
|
+
return (d.rework || []).map((r) => {
|
|
64
|
+
const perCommit = (r.shas || []).map((sha) => commitFiles(repo, sha));
|
|
65
|
+
return { card: r.card, why: r.why, perCommit, files: [...new Set(perCommit.flat())] };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cardFiles = (repo) => {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const base of [path.join(repo, '.kenaz', 'tasks'), path.join(repo, '.collaboration', 'tasks')]) {
|
|
72
|
+
if (!fs.existsSync(base)) continue;
|
|
73
|
+
for (const st of fs.readdirSync(base)) {
|
|
74
|
+
let names = []; try { names = fs.readdirSync(path.join(base, st)); } catch (_) { continue; }
|
|
75
|
+
for (const n of names) if (/^TASK_\d+.*\.ya?ml$/i.test(n)) out.push({ status: st, file: path.join(base, st, n), id: /^(TASK_\d+)/i.exec(n)[1].toUpperCase() });
|
|
76
|
+
}
|
|
77
|
+
return out; // .kenaz/tasks wins, as task-api does
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function readCard(file) { try { return yaml.load(fs.readFileSync(file, 'utf8')) || {}; } catch (_) { return {}; } }
|
|
83
|
+
|
|
84
|
+
/** (b) cards in blocked/ with a reason, moved in the window. */
|
|
85
|
+
function blockedItems(repo, now, days) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const c of cardFiles(repo).filter((x) => x.status === 'blocked')) {
|
|
88
|
+
if (now - fs.statSync(c.file).mtimeMs > days * DAY) continue;
|
|
89
|
+
const card = readCard(c.file);
|
|
90
|
+
const reason = one(card.blocked_reason || card.block_reason || card.blocker || card.blocked_by || card.reason);
|
|
91
|
+
if (!reason) continue;
|
|
92
|
+
out.push({ id: `blocked:${c.id}`, type: 'fact', paths: [].concat(card.scope_paths || []).map(norm), why: `${c.id} was blocked: ${reason.slice(0, 160)}` });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** (b) executor result files (bifrost/exec.js, written under <tmp>/kenaz-exec by dispatch.js) with ok=false for this project. */
|
|
98
|
+
function execItems(repo, now, days, execDir) {
|
|
99
|
+
const dir = execDir || path.join(os.tmpdir(), 'kenaz-exec');
|
|
100
|
+
let names = []; try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.result.json')); } catch (_) { return []; }
|
|
101
|
+
const me = norm(root(repo)).toLowerCase();
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const n of names) {
|
|
104
|
+
const file = path.join(dir, n);
|
|
105
|
+
let r; try { if (now - fs.statSync(file).mtimeMs > days * DAY) continue; r = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { continue; }
|
|
106
|
+
if (!r || r.ok !== false) continue;
|
|
107
|
+
const wt = norm(r.worktree || '').toLowerCase();
|
|
108
|
+
if (!(wt.startsWith(me + '/') || wt.includes('/' + path.basename(me) + '/'))) continue;
|
|
109
|
+
const stem = n.replace(/\.result\.json$/, '');
|
|
110
|
+
out.push({ id: `exec:${stem}`, type: 'check', paths: [], why: `${r.adapter || 'executor'} ${r.model || ''} run ${stem.split('-')[0]} failed: ${one(r.error || `exit ${r.exitCode}`)}`.replace(/\s+/g, ' ') });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** (c) lines under "## A" of recent handoffs that are unverified / risk bullets. */
|
|
116
|
+
function handoffItems(repo, now, days) {
|
|
117
|
+
const dir = path.join(root(repo), '.kenaz', 'handoffs');
|
|
118
|
+
let names = []; try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')); } catch (_) { return []; }
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const n of names) {
|
|
121
|
+
const file = path.join(dir, n);
|
|
122
|
+
if (now - fs.statSync(file).mtimeMs > days * DAY) continue;
|
|
123
|
+
let inA = false, inRisk = false;
|
|
124
|
+
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
125
|
+
if (/^##\s/.test(line)) { inA = /^##\s*A\b/.test(line); inRisk = false; continue; }
|
|
126
|
+
if (!inA) continue;
|
|
127
|
+
const top = /^[-*]\s+(.*)$/.exec(line);
|
|
128
|
+
const sub = /^\s+[-*]\s+(.*)$/.exec(line);
|
|
129
|
+
let text = null;
|
|
130
|
+
if (top) {
|
|
131
|
+
inRisk = /未驗證|風險|unverified|risk/i.test(top[1]);
|
|
132
|
+
if (inRisk) text = top[1].replace(/^[^::]*[::]\s*/, '');
|
|
133
|
+
} else if (sub && inRisk) text = sub[1];
|
|
134
|
+
text = one(text);
|
|
135
|
+
if (!text) continue;
|
|
136
|
+
const id = `handoff:${n.replace(/\.md$/, '')}:${crypto.createHash('sha1').update(text).digest('hex').slice(0, 6)}`;
|
|
137
|
+
out.push({ id, type: 'fact', paths: [], why: `handoff ${n.slice(0, 16)}: ${text.slice(0, 200)}` });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Items from the last 30 days not yet recorded, each with the ready command. Empty on any failure. */
|
|
144
|
+
function harvest({ projectPath, now = Date.now(), days = HARVEST_DAYS, execDir } = {}) {
|
|
145
|
+
const repo = root(projectPath);
|
|
146
|
+
const done = readHarvested(repo);
|
|
147
|
+
const safe = (fn) => { try { return fn(); } catch (_) { return []; } };
|
|
148
|
+
const items = [
|
|
149
|
+
...safe(() => reworkItems(repo, now, days)).map((r) => ({ id: `rework:${r.card}`, type: 'check', paths: r.files, why: `${r.card} needed rework: ${r.why}` })),
|
|
150
|
+
...safe(() => execItems(repo, now, days, execDir)),
|
|
151
|
+
...safe(() => blockedItems(repo, now, days)),
|
|
152
|
+
...safe(() => handoffItems(repo, now, days)),
|
|
153
|
+
].filter((i) => !done[i.id]);
|
|
154
|
+
return items.map((i) => ({ ...i, cmd: `kenaz learn add --scope project --type ${i.type}${i.paths.length ? ` --paths ${i.paths.slice(0, MAX_PATHS).join(',')}` : ''} --text "<why: ${i.why.replace(/"/g, "'").slice(0, 120)}>" --source harvest:${i.id}` }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeCount(projectPath, now = Date.now()) {
|
|
158
|
+
const n = harvest({ projectPath, now }).length;
|
|
159
|
+
try { fs.mkdirSync(path.dirname(cacheFile(projectPath)), { recursive: true }); fs.writeFileSync(cacheFile(projectPath), JSON.stringify({ t: now, n })); } catch (_) { /* optional */ }
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Open item count for the boot banner. Boot never waits for the git scan (7 s cold in this repo): a missing
|
|
164
|
+
* or stale cache starts a detached refresh and the banner uses what it has (nothing on the very first boot).
|
|
165
|
+
* o.sync computes in-process (tests, `kenaz learn review`); o.refresh replaces the detached spawn (tests). */
|
|
166
|
+
function openCount(projectPath, now = Date.now(), o = {}) {
|
|
167
|
+
let c = null;
|
|
168
|
+
try { c = JSON.parse(fs.readFileSync(cacheFile(projectPath), 'utf8')); } catch (_) { /* no cache */ }
|
|
169
|
+
if (c && now - c.t < CACHE_TTL_MS) return c.n;
|
|
170
|
+
if (o.sync) return writeCount(projectPath, now);
|
|
171
|
+
(o.refresh || ((p) => require('child_process').spawn(process.execPath, [__filename, '--refresh-cache', p], { detached: true, stdio: 'ignore', windowsHide: true }).unref()))(projectPath);
|
|
172
|
+
return c ? c.n : 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Suggestions only: per-rigor first-pass rate with the model in use, and the 3 files with most rework commits. */
|
|
176
|
+
function metrics({ projectPath, now = Date.now(), days = WINDOW_DAYS } = {}) {
|
|
177
|
+
const repo = root(projectPath);
|
|
178
|
+
const out = [];
|
|
179
|
+
try {
|
|
180
|
+
const d = stats().dispatchStats({ repo, now, days });
|
|
181
|
+
const rigorOf = {};
|
|
182
|
+
for (const c of cardFiles(repo)) rigorOf[c.id] = RIGORS.includes(readCard(c.file).rigor) ? readCard(c.file).rigor : 'medium';
|
|
183
|
+
const rework = new Set((d.rework || []).map((r) => r.card));
|
|
184
|
+
let models = {};
|
|
185
|
+
try { for (const r of require('../../.formation/tools/dispatch-model.js').describe(repo).rows) models[r.rigor] = r.model; } catch (_) { /* model unknown */ }
|
|
186
|
+
for (const rigor of RIGORS) {
|
|
187
|
+
const cards = (d.mergedCards || []).filter((c) => (rigorOf[c] || 'medium') === rigor);
|
|
188
|
+
const pass = cards.filter((c) => !rework.has(c)).length;
|
|
189
|
+
if (cards.length >= MIN_CARDS && pass / cards.length < SUGGEST_BELOW) {
|
|
190
|
+
out.push(`${rigor}: ${pass}/${cards.length} first-pass${models[rigor] ? ` with ${models[rigor]}` : ''}; consider kenaz subagent set <stronger model> --rigor ${rigor}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const perFile = {};
|
|
194
|
+
for (const r of reworkItems(repo, now, days)) for (const files of r.perCommit) for (const f of files) perFile[f] = (perFile[f] || 0) + 1;
|
|
195
|
+
const top = Object.entries(perFile).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3);
|
|
196
|
+
if (top.length) out.push(`most rework commits: ${top.map(([f, n]) => `${f} (${n})`).join(', ')}`);
|
|
197
|
+
} catch (_) { /* no git history: no suggestions */ }
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function print(items, suggestions, out) {
|
|
202
|
+
if (items.length) out(`History items to review (${items.length}); for each, fill in the why and run the command, or ignore it:`);
|
|
203
|
+
for (const i of items.slice(0, LIST_MAX)) { out(` ${i.id}: ${i.why}`); out(` ${i.cmd}`); }
|
|
204
|
+
if (items.length > LIST_MAX) out(` ... ${items.length - LIST_MAX} more (oldest); record the newest first, while the context is fresh`);
|
|
205
|
+
if (suggestions.length) out('Project metric suggestions (not applied):');
|
|
206
|
+
for (const s of suggestions) out(` ${s}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Numbers for `kenaz stats` section Self-improvement. lessons.js is required lazily (it requires this file). */
|
|
210
|
+
function selfStats({ projectPath, home, now = Date.now(), days = 14 }) {
|
|
211
|
+
const L = require('./lessons.js');
|
|
212
|
+
const since = now - days * DAY;
|
|
213
|
+
const jl = (f) => { try { return fs.readFileSync(f, 'utf8').split('\n').map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean); } catch (_) { return []; } };
|
|
214
|
+
const nudges = jl(nudgeLog(projectPath)).map((r) => Date.parse(r.ts)).filter((t) => t >= since);
|
|
215
|
+
const records = []; // lesson-recording moments: a new lesson or a recurrence
|
|
216
|
+
const bySource = {};
|
|
217
|
+
let graduated = 0;
|
|
218
|
+
for (const scope of ['project', 'user']) {
|
|
219
|
+
const rec = jl(L.usageLog(scope, projectPath, home)).filter((r) => r.kind === 'recurrence');
|
|
220
|
+
const uses = L.readUses(L.usageLog(scope, projectPath, home));
|
|
221
|
+
for (const r of rec) records.push(Date.parse(r.ts));
|
|
222
|
+
for (const l of L.everLessons(scope, { projectPath, home })) {
|
|
223
|
+
if (l.created) records.push(l.created);
|
|
224
|
+
if (l.graduated) graduated++;
|
|
225
|
+
const key = String(l.source || 'manual').split(':')[0];
|
|
226
|
+
const s = bySource[key] || (bySource[key] = { lessons: 0, fired: 0, recurBefore: 0, recurAfter: 0 });
|
|
227
|
+
const u = uses[`${l.id}.md`];
|
|
228
|
+
const firstFire = u && u.times.length ? Math.min(...u.times) : null;
|
|
229
|
+
s.lessons++; if (u && u.fires) s.fired++;
|
|
230
|
+
for (const r of rec.filter((x) => x.file === `${l.id}.md`)) {
|
|
231
|
+
if (firstFire != null && Date.parse(r.ts) > firstFire) s.recurAfter++; else s.recurBefore++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const converted = nudges.filter((t) => records.some((r) => r >= t && r - t <= NUDGE_CONVERSION_MS)).length;
|
|
236
|
+
const open = harvest({ projectPath, now }).length;
|
|
237
|
+
const recorded = Object.values(readHarvested(projectPath)).filter((t) => Date.parse(t) >= since).length;
|
|
238
|
+
return { nudges: nudges.length, converted, bySource, graduated, harvestOpen: open, harvestRecorded: recorded };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (require.main === module && process.argv[2] === '--refresh-cache') { try { writeCount(process.argv[3]); } catch (_) { /* best effort */ } }
|
|
242
|
+
|
|
243
|
+
module.exports = { HARVEST_DAYS, harvest, markHarvested, readHarvested, openCount, metrics, print, selfStats, reworkItems, WINDOW_DAYS };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Lesson lifecycle helpers (TASK_2425): active -> graduated into code | merged | archived.
|
|
4
|
+
* Pure similarity/candidate logic plus frontmatter edits and moves into <layer>/archive/<kind>/.
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
10
|
+
const MAX_ACTIVE_PER_LAYER = 20;
|
|
11
|
+
const NEAR_CAP = 18;
|
|
12
|
+
// Overlap coefficient, not Jaccard: a re-learned lesson is usually a shorter or longer paraphrase. Measured
|
|
13
|
+
// 2026-10-11 (8 paraphrase pairs vs 6328 pairs of distinct memory descriptions): Jaccard >= 0.6 caught 1/8,
|
|
14
|
+
// overlap >= 0.45 caught 5/8 with 0 false merges (the distinct pairs peak at 0.42).
|
|
15
|
+
const SIMILAR_MIN = 0.45;
|
|
16
|
+
const GRADUATE_FIRES = 3;
|
|
17
|
+
const GRADUATE_RECURRENCES = 2;
|
|
18
|
+
const PREF_REVIEW_AFTER_MS = 90 * DAY;
|
|
19
|
+
const WINDOW_MS = 30 * DAY;
|
|
20
|
+
|
|
21
|
+
/** Latin/digit words plus every CJK character as its own token. */
|
|
22
|
+
function tokens(text) {
|
|
23
|
+
return new Set(String(text).toLowerCase().match(/[a-z0-9_]+|[-ヿ㐀-鿿豈-가-]/g) || []);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Shared tokens over the smaller set: 1 when one text contains the other. */
|
|
27
|
+
function overlap(a, b) {
|
|
28
|
+
const x = tokens(a), y = tokens(b);
|
|
29
|
+
if (!x.size || !y.size) return 0;
|
|
30
|
+
let inter = 0;
|
|
31
|
+
for (const t of x) if (y.has(t)) inter++;
|
|
32
|
+
return inter / Math.min(x.size, y.size);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** All pairs of lessons, most similar first: [{a, b, score}]. */
|
|
36
|
+
function similarPairs(rows) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let i = 0; i < rows.length; i++) {
|
|
39
|
+
for (let j = i + 1; j < rows.length; j++) out.push({ a: rows[i], b: rows[j], score: overlap(rows[i].text, rows[j].text) });
|
|
40
|
+
}
|
|
41
|
+
return out.sort((p, q) => q.score - p.score);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findSimilar(rows, text) {
|
|
45
|
+
let best = null;
|
|
46
|
+
for (const l of rows) {
|
|
47
|
+
const score = overlap(l.text, text);
|
|
48
|
+
if (score >= SIMILAR_MIN && (!best || score > best.score)) best = { lesson: l, score };
|
|
49
|
+
}
|
|
50
|
+
return best && best.lesson;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Set or add top-level frontmatter keys in place (strings are JSON-quoted). */
|
|
54
|
+
function setFm(file, kv) {
|
|
55
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
56
|
+
const m = raw.match(/^---(\r?\n)([\s\S]*?)(\r?\n)---/);
|
|
57
|
+
if (!m) throw new Error(`${file} has no frontmatter`);
|
|
58
|
+
let body = m[2];
|
|
59
|
+
for (const [k, v] of Object.entries(kv)) {
|
|
60
|
+
const line = `${k}: ${typeof v === 'number' ? v : JSON.stringify(v)}`;
|
|
61
|
+
const re = new RegExp(`^${k}:.*$`, 'm');
|
|
62
|
+
body = re.test(body) ? body.replace(re, () => line) : `${body}${m[3]}${line}`;
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(file, `---${m[1]}${body}${m[3]}---${raw.slice(m[0].length)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function bumpRecurrence(l, now = Date.now()) {
|
|
68
|
+
const n = (l.recurrences || 0) + 1;
|
|
69
|
+
setFm(l.file, { recurrences: n, last_recurrence: new Date(now).toISOString() });
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Move a lesson to <layer>/archive/<kind>/ after stamping `extra` into its frontmatter. */
|
|
74
|
+
function archiveTo(l, kind, extra) {
|
|
75
|
+
setFm(l.file, extra);
|
|
76
|
+
const dir = path.join(path.dirname(l.file), 'archive', kind);
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
fs.renameSync(l.file, path.join(dir, path.basename(l.file)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isCandidate = (l) => (l.recent || 0) >= GRADUATE_FIRES || (l.recurrences || 0) >= GRADUATE_RECURRENCES;
|
|
82
|
+
|
|
83
|
+
/** What `kenaz learn review` shows: graduation candidates, prefs due for confirmation, merge pairs, Kenaz-product lessons. */
|
|
84
|
+
function review(layers, now = Date.now()) {
|
|
85
|
+
const all = layers.flat();
|
|
86
|
+
return {
|
|
87
|
+
candidates: all.filter(isCandidate),
|
|
88
|
+
oldPrefs: all.filter((l) => l.type === 'pref' && l.created && now - l.created > PREF_REVIEW_AFTER_MS),
|
|
89
|
+
pairs: layers.flatMap((rows) => similarPairs(rows)).filter((p) => p.score >= SIMILAR_MIN),
|
|
90
|
+
aboutKenaz: all.filter((l) => l.about === 'kenaz'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lines for the boot banner, empty when nothing needs action. */
|
|
95
|
+
function bannerNotice(layers, harvestOpen = 0) {
|
|
96
|
+
const toGraduate = layers.flat().filter(isCandidate).length;
|
|
97
|
+
const nearCap = layers.filter((rows) => rows.length >= NEAR_CAP).length;
|
|
98
|
+
if (toGraduate || nearCap) return [`[lessons] ${toGraduate} to graduate, ${nearCap} near cap${harvestOpen ? `, ${harvestOpen} history items` : ''}: kenaz learn review`];
|
|
99
|
+
return harvestOpen ? [`[learn] ${harvestOpen} history items to review: kenaz learn review`] : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { tokens, overlap, similarPairs, findSimilar, setFm, bumpRecurrence, archiveTo, isCandidate, review, bannerNotice,
|
|
103
|
+
MAX_ACTIVE_PER_LAYER, NEAR_CAP, SIMILAR_MIN, WINDOW_MS, DAY };
|
|
@@ -23,6 +23,8 @@ const os = require('os');
|
|
|
23
23
|
const path = require('path');
|
|
24
24
|
const crypto = require('crypto');
|
|
25
25
|
const yaml = require('../../.vendor/require-yaml.cjs');
|
|
26
|
+
const lc = require('./lessons-lifecycle');
|
|
27
|
+
const hv = require('./lessons-harvest');
|
|
26
28
|
|
|
27
29
|
const DAY = 24 * 60 * 60 * 1000;
|
|
28
30
|
const ARCHIVE_AFTER_MS = 30 * DAY;
|
|
@@ -31,6 +33,8 @@ const INJECT_MAX_LINES = 5;
|
|
|
31
33
|
const INJECT_MAX_BYTES = 600;
|
|
32
34
|
const MAX_UNRELATED_FIRE_RATE = 0.2;
|
|
33
35
|
const MIN_REPLAY_CALLS = 20;
|
|
36
|
+
const BANNER_PREFS = 10;
|
|
37
|
+
const DAY_MS = lc.DAY;
|
|
34
38
|
|
|
35
39
|
const homeDir = () => process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
36
40
|
const userDir = (home) => path.join(home || homeDir(), '.kenaz', 'knowledge');
|
|
@@ -48,7 +52,7 @@ function readLesson(file) {
|
|
|
48
52
|
if (!fm || typeof fm.name !== 'string' || !fm.name.startsWith('lesson-')) return null;
|
|
49
53
|
return { id: fm.name, file, type: fm.type, scope: fm.scope, created: Date.parse(fm.created) || 0,
|
|
50
54
|
triggers: Array.isArray(fm.triggers) ? fm.triggers : [], paths: Array.isArray(fm.paths) ? fm.paths : [],
|
|
51
|
-
symptom: fm.symptom || null, text: (m[2] || '').trim() };
|
|
55
|
+
symptom: fm.symptom || null, source: fm.source || null, graduated: !!fm.graduated_to, about: fm.about || null, recurrences: Number(fm.recurrences) || 0, carried: Number(fm.fires) || 0, text: (m[2] || '').trim() };
|
|
52
56
|
} catch (_) { return null; }
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -59,8 +63,9 @@ function readUses(log) {
|
|
|
59
63
|
for (const line of raw.split('\n')) {
|
|
60
64
|
let r; try { r = JSON.parse(line); } catch (_) { continue; }
|
|
61
65
|
if (!r || (r.kind !== 'guard' && r.kind !== 'inject') || !r.file) continue;
|
|
62
|
-
const u = uses[r.file] || (uses[r.file] = { fires: 0, last: 0 });
|
|
63
|
-
|
|
66
|
+
const u = uses[r.file] || (uses[r.file] = { fires: 0, last: 0, times: [] });
|
|
67
|
+
const t = Date.parse(r.ts) || 0;
|
|
68
|
+
u.fires++; u.last = Math.max(u.last, t); u.times.push(t);
|
|
64
69
|
}
|
|
65
70
|
return uses;
|
|
66
71
|
}
|
|
@@ -74,7 +79,7 @@ function logUse(scope, p, home, id, kind) {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/** Active lessons of one layer with fire stats; moves lessons idle > 30 days to archive/ first. */
|
|
77
|
-
function listLayer(scope, { projectPath, home, now = Date.now() } = {}) {
|
|
82
|
+
function listLayer(scope, { projectPath, home, now = Date.now(), readOnly = false } = {}) {
|
|
78
83
|
const dir = layerDir(scope, projectPath, home);
|
|
79
84
|
let names = [];
|
|
80
85
|
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')); } catch (_) { return []; }
|
|
@@ -83,9 +88,10 @@ function listLayer(scope, { projectPath, home, now = Date.now() } = {}) {
|
|
|
83
88
|
for (const n of names) {
|
|
84
89
|
const l = readLesson(path.join(dir, n));
|
|
85
90
|
if (!l) continue;
|
|
86
|
-
const u = uses[n] || { fires: 0, last: 0 };
|
|
87
|
-
l.scope = scope; l.fires = u.fires; l.lastFired = u.last || null;
|
|
88
|
-
|
|
91
|
+
const u = uses[n] || { fires: 0, last: 0, times: [] };
|
|
92
|
+
l.scope = scope; l.fires = u.fires + l.carried; l.lastFired = u.last || null;
|
|
93
|
+
l.recent = u.times.filter((t) => now - t <= lc.WINDOW_MS).length;
|
|
94
|
+
if (!readOnly && l.type !== 'pref' && now - Math.max(u.last, l.created) > ARCHIVE_AFTER_MS) {
|
|
89
95
|
fs.mkdirSync(path.join(dir, 'archive'), { recursive: true });
|
|
90
96
|
fs.renameSync(l.file, path.join(dir, 'archive', n));
|
|
91
97
|
continue;
|
|
@@ -95,6 +101,15 @@ function listLayer(scope, { projectPath, home, now = Date.now() } = {}) {
|
|
|
95
101
|
return rows.sort((a, b) => b.fires - a.fires || b.created - a.created);
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
/** Every lesson ever written in a layer, including archived / merged / graduated ones (read-only, for stats). */
|
|
105
|
+
function everLessons(scope, { projectPath, home } = {}) {
|
|
106
|
+
const out = [];
|
|
107
|
+
const scan = (d) => { let n = []; try { n = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
|
108
|
+
for (const e of n) { if (e.isDirectory()) scan(path.join(d, e.name)); else if (e.name.endsWith('.md')) { const l = readLesson(path.join(d, e.name)); if (l) out.push(l); } } };
|
|
109
|
+
scan(layerDir(scope, projectPath, home));
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
const listAll = (o = {}) => [...listLayer('project', o), ...listLayer('user', o)];
|
|
99
114
|
|
|
100
115
|
function triggerMatches(guard, c, name, input) {
|
|
@@ -136,18 +151,16 @@ function validateTrigger(raw, { cause, projectPath, home } = {}) {
|
|
|
136
151
|
return { total, fired };
|
|
137
152
|
}
|
|
138
153
|
|
|
139
|
-
function
|
|
140
|
-
const { scope, text, trigger, symptom,
|
|
141
|
-
if (scope !== 'user' && scope !== 'project') throw new Error('--scope must be user or project');
|
|
142
|
-
if (!text || !String(text).trim()) throw new Error('--text is required');
|
|
143
|
-
const type = o.type || (trigger ? 'pitfall' : (scope === 'user' ? 'pref' : 'fact'));
|
|
144
|
-
if (!TYPES.includes(type)) throw new Error(`--type must be one of ${TYPES.join('|')}`);
|
|
145
|
-
if (type === 'pitfall' && !trigger) throw new Error('a pitfall needs --trigger (it acts when the matching tool call is about to run)');
|
|
146
|
-
const replay = trigger ? validateTrigger(trigger, { cause, projectPath, home }) : null;
|
|
154
|
+
function writeLesson(f) {
|
|
155
|
+
const { scope, text, type, trigger, symptom, paths, projectPath, home, extra, about, source } = f;
|
|
147
156
|
const id = 'lesson-' + crypto.createHash('sha1').update(String(text) + Date.now()).digest('hex').slice(0, 8);
|
|
148
157
|
const j = JSON.stringify;
|
|
149
158
|
const fm = ['---', `name: ${id}`, `description: ${j(String(text).trim().split('\n')[0].slice(0, 160))}`, `type: ${type}`, `scope: ${scope}`,
|
|
150
159
|
`created: ${new Date().toISOString()}`];
|
|
160
|
+
if (about) fm.push(`about: ${j(about)}`);
|
|
161
|
+
fm.push(`source: ${j(source || 'manual')}`);
|
|
162
|
+
if (extra && extra.fires) fm.push(`fires: ${extra.fires}`);
|
|
163
|
+
if (extra && extra.recurrences) fm.push(`recurrences: ${extra.recurrences}`);
|
|
151
164
|
if (symptom) fm.push(`symptom: ${j(symptom)}`);
|
|
152
165
|
if (paths && paths.length) fm.push('paths:', ...paths.map((x) => ` - ${j(x)}`));
|
|
153
166
|
if (trigger) fm.push('triggers:', ` - ${j(trigger)}`);
|
|
@@ -155,9 +168,89 @@ function add(o) {
|
|
|
155
168
|
const dir = layerDir(scope, projectPath, home);
|
|
156
169
|
fs.mkdirSync(dir, { recursive: true });
|
|
157
170
|
fs.writeFileSync(path.join(dir, `${id}.md`), fm.join('\n'));
|
|
171
|
+
return id;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function capMessage(scope, rows) {
|
|
175
|
+
const top = lc.similarPairs(rows).slice(0, 3).map((p) => ` ${p.a.id} + ${p.b.id} (${p.score.toFixed(2)}): kenaz learn merge ${p.a.id} ${p.b.id} --text "<general rule>"`);
|
|
176
|
+
return `${scope} layer is at the cap of ${lc.MAX_ACTIVE_PER_LAYER} active lessons; merge or drop one first (kenaz learn review). Most similar pairs:\n${top.join('\n')}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function add(o) {
|
|
180
|
+
const { scope, text, trigger, symptom, cause, paths, projectPath, home } = o;
|
|
181
|
+
if (scope !== 'user' && scope !== 'project') throw new Error('--scope must be user or project');
|
|
182
|
+
if (!text || !String(text).trim()) throw new Error('--text is required');
|
|
183
|
+
if (o.about && o.about !== 'kenaz') throw new Error('--about only accepts kenaz (a lesson about Kenaz itself)');
|
|
184
|
+
const source = o.source || 'manual';
|
|
185
|
+
if (!/^(manual|correction-nudge|harvest:.+)$/.test(source)) throw new Error('--source must be correction-nudge, harvest:<item id> or manual');
|
|
186
|
+
const type = o.type || (trigger ? 'pitfall' : (scope === 'user' ? 'pref' : 'fact'));
|
|
187
|
+
if (!TYPES.includes(type)) throw new Error(`--type must be one of ${TYPES.join('|')}`);
|
|
188
|
+
if (type === 'pitfall' && !trigger) throw new Error('a pitfall needs --trigger (it acts when the matching tool call is about to run)');
|
|
189
|
+
const active = listLayer(scope, { projectPath, home });
|
|
190
|
+
if (!o.force) {
|
|
191
|
+
const dup = lc.findSimilar(active, text);
|
|
192
|
+
if (dup) {
|
|
193
|
+
logUse(scope, projectPath, home, dup.id, 'recurrence');
|
|
194
|
+
if (source.startsWith('harvest:')) hv.markHarvested(projectPath, source.slice(8));
|
|
195
|
+
return { id: dup.id, scope, type: dup.type, recurred: true, recurrences: lc.bumpRecurrence(dup) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (active.length >= lc.MAX_ACTIVE_PER_LAYER) throw new Error(capMessage(scope, active));
|
|
199
|
+
const replay = trigger ? validateTrigger(trigger, { cause, projectPath, home }) : null;
|
|
200
|
+
const id = writeLesson({ scope, text, type, trigger, symptom, paths, projectPath, home, about: o.about, source });
|
|
201
|
+
if (source.startsWith('harvest:')) hv.markHarvested(projectPath, source.slice(8));
|
|
158
202
|
return { id, type, scope, replay };
|
|
159
203
|
}
|
|
160
204
|
|
|
205
|
+
/** One general lesson replaces several: sources move to <layer>/archive/merged/ with merged_into. */
|
|
206
|
+
function merge(ids, o) {
|
|
207
|
+
if (!Array.isArray(ids) || ids.length < 2) throw new Error('merge needs at least two lesson ids');
|
|
208
|
+
if (!o.text || !String(o.text).trim()) throw new Error('--text "<general rule>" is required');
|
|
209
|
+
const all = listAll(o);
|
|
210
|
+
const src = ids.map((id) => {
|
|
211
|
+
const l = all.find((x) => x.id === id);
|
|
212
|
+
if (!l) throw new Error(`no active lesson '${id}'`);
|
|
213
|
+
return l;
|
|
214
|
+
});
|
|
215
|
+
if (new Set(src.map((l) => l.scope)).size > 1) throw new Error('lessons to merge must be in the same layer');
|
|
216
|
+
const scope = src[0].scope;
|
|
217
|
+
const type = o.type || src[0].type;
|
|
218
|
+
if (!TYPES.includes(type)) throw new Error(`--type must be one of ${TYPES.join('|')}`);
|
|
219
|
+
const trigger = o.trigger || src[0].triggers[0];
|
|
220
|
+
if (type === 'pitfall' && !trigger) throw new Error('a pitfall needs --trigger');
|
|
221
|
+
if (o.trigger) validateTrigger(o.trigger, o);
|
|
222
|
+
const id = writeLesson({ scope, text: o.text, type, trigger, paths: [...new Set(src.flatMap((l) => l.paths))],
|
|
223
|
+
projectPath: o.projectPath, home: o.home, about: src.find((l) => l.about) ? src.find((l) => l.about).about : null,
|
|
224
|
+
extra: { fires: src.reduce((n, l) => n + l.fires, 0), recurrences: src.reduce((n, l) => n + l.recurrences, 0) } });
|
|
225
|
+
for (const l of src) lc.archiveTo(l, 'merged', { merged_into: id });
|
|
226
|
+
return { id, scope, type, merged: src.map((l) => l.id) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function graduate(id, to, o = {}) {
|
|
230
|
+
if (!to || !String(to).trim()) throw new Error('--to "<commit sha or file path>" is required');
|
|
231
|
+
const l = listAll(o).find((x) => x.id === id);
|
|
232
|
+
if (!l) throw new Error(`no active lesson '${id}'`);
|
|
233
|
+
lc.archiveTo(l, 'graduated', { graduated_to: String(to).trim() });
|
|
234
|
+
return l;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const reviewAll = (o = {}) => lc.review([listLayer('project', o), listLayer('user', o)], o.now);
|
|
238
|
+
|
|
239
|
+
/** Read-only counts for `kenaz stats`: per layer active, fires in the window, recurrences, graduated / merged / archived. */
|
|
240
|
+
function stats({ projectPath, home, now = Date.now(), days = 14 } = {}) {
|
|
241
|
+
const count = (dir) => { try { return fs.readdirSync(dir).filter((n) => n.endsWith('.md')).length; } catch (_) { return 0; } };
|
|
242
|
+
const out = {};
|
|
243
|
+
for (const scope of ['project', 'user']) {
|
|
244
|
+
const rows = listLayer(scope, { projectPath, home, now, readOnly: true });
|
|
245
|
+
const dir = path.join(layerDir(scope, projectPath, home), 'archive');
|
|
246
|
+
let fires = 0;
|
|
247
|
+
for (const u of Object.values(readUses(usageLog(scope, projectPath, home)))) fires += u.times.filter((t) => now - t <= days * DAY_MS).length;
|
|
248
|
+
out[scope] = { active: rows.length, fires, recurrences: rows.reduce((n, l) => n + l.recurrences, 0),
|
|
249
|
+
graduated: count(path.join(dir, 'graduated')), merged: count(path.join(dir, 'merged')), archived: count(dir) };
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
161
254
|
function rm(id, o = {}) {
|
|
162
255
|
const l = listAll(o).find((x) => x.id === id);
|
|
163
256
|
if (!l) throw new Error(`no active lesson '${id}'`);
|
|
@@ -184,24 +277,42 @@ function dispatchLessons(projectPath, scopePaths = [], { home, now } = {}) {
|
|
|
184
277
|
return out;
|
|
185
278
|
}
|
|
186
279
|
|
|
187
|
-
/** Boot banner: counts
|
|
280
|
+
/** Boot banner: counts, a nudge when lessons need graduating or a layer nears the cap, and the newest prefs (max 10). */
|
|
188
281
|
function bannerLines(projectPath, o = {}) {
|
|
189
282
|
const proj = listLayer('project', { ...o, projectPath });
|
|
190
283
|
const usr = listLayer('user', o);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
284
|
+
let open = 0;
|
|
285
|
+
try { open = hv.openCount(projectPath, Date.now(), { sync: o.syncHarvest, refresh: o.refreshHarvest }); } catch (_) { /* optional */ }
|
|
286
|
+
if (!proj.length && !usr.length) return lc.bannerNotice([], open);
|
|
287
|
+
const lines = [`[learn] project ${proj.length}, user ${usr.length} (kenaz learn list)`, ...lc.bannerNotice([proj, usr], open)];
|
|
288
|
+
const prefs = usr.filter((x) => x.type === 'pref').sort((a, b) => b.created - a.created);
|
|
289
|
+
for (const l of prefs.slice(0, BANNER_PREFS)) lines.push(`[learn] pref: ${l.text.split('\n')[0].slice(0, 120)}`);
|
|
290
|
+
if (prefs.length > BANNER_PREFS) lines.push(`[learn] +${prefs.length - BANNER_PREFS} more`);
|
|
194
291
|
return lines;
|
|
195
292
|
}
|
|
196
293
|
|
|
197
294
|
function flags(argv) {
|
|
198
295
|
const f = { _: [] };
|
|
199
296
|
for (let i = 0; i < argv.length; i++) {
|
|
200
|
-
if (argv[i]
|
|
297
|
+
if (argv[i] === '--force') f.force = true;
|
|
298
|
+
else if (argv[i].startsWith('--')) f[argv[i].slice(2)] = argv[++i]; else f._.push(argv[i]);
|
|
201
299
|
}
|
|
202
300
|
return f;
|
|
203
301
|
}
|
|
204
302
|
|
|
303
|
+
function printReview(r, out, extras) {
|
|
304
|
+
const row = (l) => `${l.id} ${l.scope}/${l.type} fired ${l.recent}x in 30d, recurred ${l.recurrences}x ${l.text.split('\n')[0].slice(0, 80)}`;
|
|
305
|
+
if (!r.candidates.length && !r.oldPrefs.length && !r.pairs.length && !r.aboutKenaz.length && !extras) out('nothing to review');
|
|
306
|
+
if (r.candidates.length) out('Graduation candidates (make it code in this project: a test, a hook check, or a rule in the project\'s CLAUDE.md/AGENTS.md; then kenaz learn graduate <id> --to <commit or file>):');
|
|
307
|
+
for (const l of r.candidates) out(` ${row(l)}`);
|
|
308
|
+
if (r.oldPrefs.length) out('Prefs older than 90 days (ask the user: confirm, or drop with kenaz learn rm <id>):');
|
|
309
|
+
for (const l of r.oldPrefs) out(` ${l.id} ${l.text.split('\n')[0].slice(0, 100)}`);
|
|
310
|
+
if (r.pairs.length) out('Merge pairs (kenaz learn merge <id1> <id2> --text "<general rule>"):');
|
|
311
|
+
for (const p of r.pairs) out(` ${p.a.id} + ${p.b.id} similarity ${p.score.toFixed(2)}`);
|
|
312
|
+
if (r.aboutKenaz.length) out('Lessons about Kenaz itself (report it: kenaz_wish_create scope=kenaz):');
|
|
313
|
+
for (const l of r.aboutKenaz) out(` ${l.id} ${l.text.split('\n')[0].slice(0, 100)}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
205
316
|
function main(argv, out = console.log) {
|
|
206
317
|
const f = flags(argv);
|
|
207
318
|
const o = { projectPath: f['project-path'] || process.cwd(), home: f.home };
|
|
@@ -209,20 +320,34 @@ function main(argv, out = console.log) {
|
|
|
209
320
|
try {
|
|
210
321
|
if (sub === 'add') {
|
|
211
322
|
const r = add({ ...o, scope: f.scope, text: f.text, type: f.type, trigger: f.trigger, symptom: f.symptom, cause: f.cause,
|
|
212
|
-
paths: f.paths ? f.paths.split(',') : [] });
|
|
213
|
-
out(`
|
|
323
|
+
paths: f.paths ? f.paths.split(',') : [], force: f.force, about: f.about, source: f.source });
|
|
324
|
+
if (r.recurred) out(`recurred ${r.id} (${r.recurrences} times)`);
|
|
325
|
+
else out(`learned ${r.id} (${r.scope}/${r.type})${r.replay ? `; trigger fires on ${r.replay.fired}/${r.replay.total} recent tool calls` : ''}`);
|
|
214
326
|
} else if (sub === 'list') {
|
|
215
327
|
const rows = listAll(o);
|
|
216
328
|
if (!rows.length) out('no lessons yet (kenaz learn add --scope user|project --text ...)');
|
|
217
329
|
for (const l of rows) {
|
|
218
330
|
out(`${l.id} ${l.scope}/${l.type} fired ${l.fires}x last ${l.lastFired ? new Date(l.lastFired).toISOString().slice(0, 10) : '-'} ${l.text.split('\n')[0].slice(0, 100)}`);
|
|
219
331
|
}
|
|
332
|
+
} else if (sub === 'merge') {
|
|
333
|
+
const r = merge(f._.slice(1), { ...o, text: f.text, type: f.type, trigger: f.trigger });
|
|
334
|
+
out(`merged ${r.merged.join(', ')} into ${r.id} (${r.scope}/${r.type})`);
|
|
335
|
+
} else if (sub === 'graduate') {
|
|
336
|
+
out(`graduated ${graduate(f._[1], f.to, o).id} -> ${f.to}`);
|
|
337
|
+
} else if (sub === 'review') {
|
|
338
|
+
const items = hv.harvest(o), sugg = hv.metrics(o);
|
|
339
|
+
printReview(reviewAll(o), out, items.length + sugg.length);
|
|
340
|
+
hv.print(items, sugg, out);
|
|
341
|
+
} else if (sub === 'harvest') {
|
|
342
|
+
const items = hv.harvest(o);
|
|
343
|
+
if (!items.length) out('no history items to harvest');
|
|
344
|
+
hv.print(items, [], out);
|
|
220
345
|
} else if (sub === 'rm') {
|
|
221
346
|
out(`removed ${rm(f._[1], o).id}`);
|
|
222
347
|
} else if (sub === 'banner') {
|
|
223
348
|
for (const l of bannerLines(o.projectPath, o)) out(l);
|
|
224
349
|
} else {
|
|
225
|
-
out('usage: kenaz learn <add --scope user|project --text <lesson> [--type pref|fact|pitfall|check] [--trigger "<tool regex> | <input regex>"] [--symptom <text>] [--paths a,b] [--cause <command>] | list | rm <id>>');
|
|
350
|
+
out('usage: kenaz learn <add --scope user|project --text <lesson> [--type pref|fact|pitfall|check] [--trigger "<tool regex> | <input regex>"] [--symptom <text>] [--paths a,b] [--cause <command>] [--about kenaz] [--source correction-nudge|harvest:<id>|manual] [--force] | list | rm <id> | review | harvest | merge <id1> <id2> ... --text <general rule> [--type t] [--trigger r] | graduate <id> --to <commit or file>>');
|
|
226
351
|
return 2;
|
|
227
352
|
}
|
|
228
353
|
return 0;
|
|
@@ -231,4 +356,4 @@ function main(argv, out = console.log) {
|
|
|
231
356
|
|
|
232
357
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
233
358
|
|
|
234
|
-
module.exports = { add, rm, listLayer, listAll, dispatchLessons, bannerLines, validateTrigger, userDir, projectDir, main, ARCHIVE_AFTER_MS };
|
|
359
|
+
module.exports = { add, rm, merge, graduate, reviewAll, stats, MAX_ACTIVE_PER_LAYER: lc.MAX_ACTIVE_PER_LAYER, listLayer, listAll, dispatchLessons, bannerLines, validateTrigger, everLessons, readLesson, readUses, usageLog, userDir, projectDir, main, ARCHIVE_AFTER_MS };
|