mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* fix(continuous-learning): cluster instincts by keyword overlap in /evolve
`cmd_evolve` grouped instincts by exact string equality of the whole
normalized trigger sentence. Triggers are free-form sentences, so every
instinct landed in its own bucket and `skill_candidates` was always empty.
`agent_candidates` is derived from `skill_candidates`, so agents never
generated either — `/evolve --generate` could only ever emit commands.
Measured on a 42-instinct project: 42 instincts produced 42 unique cluster
keys, largest cluster size 1.
Group on keyword overlap instead. Jaccard is the wrong metric here — trigger
keyword sets average ~7 words, so even clearly related pairs top out around
0.33 — so this uses the overlap coefficient (shared / smaller set) at 0.5,
plus a floor of 2 shared keywords so one incidental word cannot pull
unrelated instincts together. The same 42 instincts now yield 4 clusters.
Also unify the command/agent slug used by the preview and the writer. The
preview called `.replace('a ', '')`, which strips "a " anywhere in the
string, mangling "extracting data from Reddit" into
`/extracting-datfrom-R` while `--generate` wrote `extracting-data-from.md`.
Both paths now share `_evolved_command_name()` / `_evolved_agent_name()`.
Adds tests/scripts/instinct-cli-evolve.test.js, which fails on the previous
implementation (0 clusters instead of 1; preview name `extracting-datfrom-R`)
and covers the negative cases so unrelated triggers still stay apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(continuous-learning): correct clustering metric name in docstring
The docstring said "Jaccard" while the implementation uses the overlap
coefficient, which is the point of the change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(continuous-learning): generate every evolve candidate and cut slugs on word boundaries
_generate_evolved() wrote only skill_candidates[:5], workflow_instincts[:5]
and agent_candidates[:3]. On a project with 36 command candidates that meant
5 files and no warning, so the output read as complete while 86% of the
candidates were dropped.
Generation is now unbounded by default and takes a --limit N flag for callers
that want a cap. A cap that truncates says so:
Note: writing 3 of 36 command candidates (--limit 3); 33 skipped.
The analysis preview keeps showing five per kind but now names the remainder
("... and 31 more command candidates not shown") instead of presenting a
sample as the whole set.
Slugs were also cut with a hard slice, which split words mid-token and
produced /investigating-comple, /learning-about-compl and
/researching-mechanis. _truncate_slug() retreats to the last separator that
fits, and keeps the full head when the cut already lands on one, so
"analyzing large text files" stays /analyzing-large-text rather than losing
a word. A first word longer than the limit still falls back to a hard cut
because no boundary is available.
Shorter slugs collide more easily, and a collision used to mean one file
silently overwriting another. _assign_unique_slugs() suffixes duplicates
(-2, -3) and is called by both the preview and the writer over the same
ordered list, so advertised names and written names cannot drift apart.
Skill directory naming moved to _evolved_skill_name(); it previously used its
own inline slug expression, so it was the one truncation the shared helper
did not cover.
Adds tests/scripts/instinct-cli-evolve-generate.test.js: 7 cases covering
word-boundary cuts, the separator-aligned cut, unbounded generation, --limit
reporting, collision dedup, preview remainder and preview/writer agreement.
Six of the seven fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
189 lines
5.6 KiB
JavaScript
189 lines
5.6 KiB
JavaScript
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
const cliPath = path.join(
|
|
repoRoot,
|
|
'skills',
|
|
'continuous-learning-v2',
|
|
'scripts',
|
|
'instinct-cli.py'
|
|
);
|
|
|
|
function detectPython3() {
|
|
for (const bin of ['python3', 'python']) {
|
|
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
|
if (r.status === 0 && /Python 3/.test(r.stdout + r.stderr)) return bin;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const PYTHON3 = detectPython3();
|
|
if (!PYTHON3) {
|
|
console.log('\n=== Testing instinct-cli.py evolve clustering ===\n');
|
|
console.log(' - skipped: Python 3 not found in PATH');
|
|
console.log('\nPassed: 0');
|
|
console.log('Failed: 0');
|
|
process.exit(0);
|
|
}
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
passed += 1;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
function createTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-instinct-cli-evolve-'));
|
|
}
|
|
|
|
function cleanupDir(dir) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
function writeInstinct(root, id, trigger, confidence = 0.8, domain = 'workflow') {
|
|
const filePath = path.join(root, 'instincts', 'personal', `${id}.yaml`);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(
|
|
filePath,
|
|
[
|
|
'---',
|
|
`id: ${id}`,
|
|
`trigger: ${trigger}`,
|
|
`confidence: ${confidence}`,
|
|
`domain: ${domain}`,
|
|
'scope: global',
|
|
'---',
|
|
'',
|
|
'## Action',
|
|
'',
|
|
`Action for ${id}.`,
|
|
'',
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
// cwd is the temp dir (not a git repo) so project detection falls back to
|
|
// global scope and the fixture instincts are the only ones loaded.
|
|
function runEvolve(root, args = []) {
|
|
return spawnSync(PYTHON3, [cliPath, 'evolve', ...args], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLV2_HOMUNCULUS_DIR: root,
|
|
HOME: path.join(root, 'home'),
|
|
USERPROFILE: path.join(root, 'home'),
|
|
CLAUDE_PROJECT_DIR: '',
|
|
},
|
|
});
|
|
}
|
|
|
|
function clusterCount(stdout) {
|
|
const match = stdout.match(/Potential skill clusters found:\s*(\d+)/);
|
|
assert.ok(match, `cluster count missing from output:\n${stdout}`);
|
|
return Number(match[1]);
|
|
}
|
|
|
|
console.log('\n=== Testing instinct-cli.py evolve clustering ===\n');
|
|
|
|
// evolve refuses to analyze fewer than 3 instincts, so each fixture adds a
|
|
// filler whose trigger shares no keywords with the pair under test.
|
|
const FILLER = ['filler-unrelated', 'when rotating expired signing certificates'];
|
|
|
|
test('instincts with overlapping trigger keywords form one cluster', () => {
|
|
const root = createTempDir();
|
|
try {
|
|
writeInstinct(root, 'l10n-merge', 'when adding a language to the localization dictionary');
|
|
writeInstinct(root, 'l10n-verify', 'when verifying the localization dictionary for a language');
|
|
writeInstinct(root, ...FILLER);
|
|
|
|
const result = runEvolve(root);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(clusterCount(result.stdout), 1);
|
|
} finally {
|
|
cleanupDir(root);
|
|
}
|
|
});
|
|
|
|
test('unrelated triggers do not cluster together', () => {
|
|
const root = createTempDir();
|
|
try {
|
|
writeInstinct(root, 'docker-build', 'when building container images for deployment');
|
|
writeInstinct(root, 'sql-index', 'when tuning slow database queries');
|
|
writeInstinct(root, ...FILLER);
|
|
|
|
const result = runEvolve(root);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(clusterCount(result.stdout), 0);
|
|
} finally {
|
|
cleanupDir(root);
|
|
}
|
|
});
|
|
|
|
test('a single shared keyword is not enough to cluster', () => {
|
|
const root = createTempDir();
|
|
try {
|
|
writeInstinct(root, 'bash-archives', 'when sampling compressed archives with bash pipelines');
|
|
writeInstinct(root, 'bash-signing', 'when inspecting bash exit codes after failures');
|
|
writeInstinct(root, ...FILLER);
|
|
|
|
const result = runEvolve(root);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(clusterCount(result.stdout), 0);
|
|
} finally {
|
|
cleanupDir(root);
|
|
}
|
|
});
|
|
|
|
test('preview command name matches the file --generate writes', () => {
|
|
const root = createTempDir();
|
|
try {
|
|
writeInstinct(root, 'reddit-scrape', 'when extracting data from Reddit pages');
|
|
writeInstinct(root, 'filler-one', 'when rotating expired signing certificates', 0.8, 'testing');
|
|
writeInstinct(root, 'filler-two', 'when pruning stale feature branches', 0.8, 'testing');
|
|
|
|
const preview = runEvolve(root);
|
|
assert.strictEqual(preview.status, 0, preview.stderr);
|
|
|
|
const match = preview.stdout.match(/^\s+\/(\S+)$/m);
|
|
assert.ok(match, `no command candidate in output:\n${preview.stdout}`);
|
|
const previewName = match[1];
|
|
|
|
// The old preview stripped every "a " occurrence, mangling "data from"
|
|
// into "datfrom" and advertising a name --generate never wrote.
|
|
assert.ok(
|
|
!previewName.includes('datfrom'),
|
|
`preview mangled the trigger: ${previewName}`
|
|
);
|
|
|
|
const generated = runEvolve(root, ['--generate']);
|
|
assert.strictEqual(generated.status, 0, generated.stderr);
|
|
|
|
const commandFile = path.join(root, 'evolved', 'commands', `${previewName}.md`);
|
|
assert.ok(
|
|
fs.existsSync(commandFile),
|
|
`expected ${commandFile}, got: ${fs.readdirSync(path.join(root, 'evolved', 'commands')).join(', ')}`
|
|
);
|
|
} finally {
|
|
cleanupDir(root);
|
|
}
|
|
});
|
|
|
|
console.log(`\nPassed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
|
|
process.exit(failed > 0 ? 1 : 0);
|