- Create scripts/utils/propose-pattern.js to propose new patterns or extend existing ones based on a newly applied workaround/fix. - Default to dry-run preview; --apply writes files only after human review. - Matches against existing patterns via keyword overlap and proposes an update when the shape is similar enough, or a new pattern file otherwise. - Update patterns/README.md with feedback-loop instructions. - Update MEMORY.md to document the plasticity loop. - Update CONTEXT.md decisions log and current tasks.
300 lines
9.8 KiB
JavaScript
300 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* propose-pattern.js — Feedback loop for the cross-project pattern registry.
|
|
*
|
|
* Use this when an agent applies a workaround, fix, or design choice that is
|
|
* structurally reusable across projects. It compares the new lesson against
|
|
* existing patterns and either proposes an update to an existing pattern or a
|
|
* brand-new pattern file + manifest entry.
|
|
*
|
|
* Default mode is dry-run / preview. Pass --apply to write changes.
|
|
*
|
|
* Examples:
|
|
* node scripts/utils/propose-pattern.js \
|
|
* --task="n8n workflow sends LLM-generated text to LinkedIn" \
|
|
* --fix="JSON.stringify the post body and validate with JSON.parse before the HTTP Request node" \
|
|
* --projects="linkedin-automation" \
|
|
* --symptom="LinkedIn API returns malformed JSON payload errors" \
|
|
* --root-cause="LLM output contains unescaped quotes and newlines"
|
|
*
|
|
* node scripts/utils/propose-pattern.js --apply --file=/tmp/proposed-pattern.json
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const WORKSPACE = '/home/jcbeasley/.openclaw/workspace';
|
|
const PATTERNS_DIR = path.join(WORKSPACE, 'patterns');
|
|
const MANIFEST_PATH = path.join(PATTERNS_DIR, 'patterns.json');
|
|
|
|
function slugify(name) {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
function tokenSet(text) {
|
|
return new Set(
|
|
(text || '')
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9_-]+/)
|
|
.filter(t => t.length > 2)
|
|
);
|
|
}
|
|
|
|
function jaccard(a, b) {
|
|
if (a.size === 0 || b.size === 0) return 0;
|
|
const intersection = new Set([...a].filter(x => b.has(x)));
|
|
return intersection.size / (a.size + b.size - intersection.size);
|
|
}
|
|
|
|
function loadManifest() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
} catch (err) {
|
|
return { version: new Date().toISOString().slice(0, 10), patterns: [] };
|
|
}
|
|
}
|
|
|
|
function findBestMatch(taskText, fixText, manifest) {
|
|
const queryTokens = new Set([...tokenSet(taskText), ...tokenSet(fixText)]);
|
|
let best = { pattern: null, score: 0 };
|
|
for (const pattern of manifest.patterns || []) {
|
|
const patternTokens = new Set([
|
|
...tokenSet(pattern.name),
|
|
...tokenSet(pattern.id),
|
|
...(pattern.tags || []).flatMap(t => [...tokenSet(t)]),
|
|
...(pattern.affected_projects || []).flatMap(p => [...tokenSet(p)])
|
|
]);
|
|
const score = jaccard(queryTokens, patternTokens);
|
|
if (score > best.score) best = { pattern, score };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function buildPatternMarkdown(opts) {
|
|
const related = (opts.related_patterns || [])
|
|
.filter(id => id && id.trim())
|
|
.map(id => `- \`${id}\``)
|
|
.join('\n') || '- None yet.';
|
|
|
|
return `# Pattern: ${opts.name}
|
|
|
|
## Symptom
|
|
${opts.symptom || 'TODO: describe the symptom'}
|
|
|
|
## Affected Projects
|
|
${(opts.projects || []).map(p => `- ${p}`).join('\n') || '- TODO'}
|
|
|
|
## Root Cause
|
|
${opts.root_cause || 'TODO: describe the root cause'}
|
|
|
|
## Standard Fix
|
|
${opts.fix || 'TODO: describe the standard fix'}
|
|
|
|
## When to Apply
|
|
${opts.when_to_apply || 'TODO: describe when to apply this pattern'}
|
|
|
|
## Verification
|
|
${opts.verification || 'TODO: describe how to verify the fix'}
|
|
|
|
## Related Patterns
|
|
${related}
|
|
`;
|
|
}
|
|
|
|
function buildProposal(argv) {
|
|
const manifest = loadManifest();
|
|
const match = findBestMatch(argv.task, argv.fix, manifest);
|
|
const threshold = 0.15;
|
|
|
|
if (match.pattern && match.score >= threshold) {
|
|
// Propose update to existing pattern
|
|
const updatedProjects = Array.from(new Set([
|
|
...(match.pattern.affected_projects || []),
|
|
...(argv.projects || [])
|
|
]));
|
|
|
|
return {
|
|
mode: 'update',
|
|
score: match.score,
|
|
existing: match.pattern,
|
|
proposal: {
|
|
id: match.pattern.id,
|
|
name: match.pattern.name,
|
|
affected_projects: updatedProjects,
|
|
rationale: `Extend pattern "${match.pattern.id}" with newly observed project(s) and/or refined fix.`,
|
|
additions: {
|
|
affected_projects: argv.projects || []
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Propose new pattern
|
|
const name = argv.name || argv.task.split(/[.!?]/)[0].slice(0, 60);
|
|
const id = argv.id || slugify(name);
|
|
const tags = argv.tags || tokenSet(`${argv.task} ${argv.fix} ${argv.symptom} ${argv.root_cause}`);
|
|
|
|
return {
|
|
mode: 'create',
|
|
score: match.score,
|
|
nearest_existing: match.pattern,
|
|
proposal: {
|
|
id,
|
|
name,
|
|
file: `patterns/${id}.md`,
|
|
tags: Array.from(tags),
|
|
affected_projects: argv.projects || [],
|
|
related_patterns: argv.related || [],
|
|
markdown: buildPatternMarkdown({
|
|
name,
|
|
symptom: argv.symptom,
|
|
projects: argv.projects || [],
|
|
root_cause: argv.root_cause,
|
|
fix: argv.fix,
|
|
when_to_apply: argv.when_to_apply,
|
|
verification: argv.verification,
|
|
related_patterns: argv.related || []
|
|
})
|
|
}
|
|
};
|
|
}
|
|
|
|
function printProposal(proposal) {
|
|
if (proposal.mode === 'update') {
|
|
console.log('\n=== PROPOSED PATTERN UPDATE (dry-run) ===\n');
|
|
console.log(`Pattern: ${proposal.existing.id}`);
|
|
console.log(`Match score: ${(proposal.score * 100).toFixed(1)}%`);
|
|
console.log(`Rationale: ${proposal.proposal.rationale}`);
|
|
console.log('\nExisting affected projects:');
|
|
for (const p of proposal.existing.affected_projects || []) console.log(` - ${p}`);
|
|
console.log('\nProjects to add:');
|
|
for (const p of proposal.proposal.additions.affected_projects) console.log(` + ${p}`);
|
|
console.log('\nRun with --apply to update patterns/patterns.json.');
|
|
} else {
|
|
console.log('\n=== PROPOSED NEW PATTERN (dry-run) ===\n');
|
|
console.log(`ID: ${proposal.proposal.id}`);
|
|
console.log(`Name: ${proposal.proposal.name}`);
|
|
console.log(`File: ${proposal.proposal.file}`);
|
|
console.log(`Tags: ${proposal.proposal.tags.join(', ')}`);
|
|
console.log(`Projects: ${proposal.proposal.affected_projects.join(', ') || '(none)'}`);
|
|
console.log(`Match score: ${proposal.score > 0 ? (proposal.score * 100).toFixed(1) + '%' : 'none'}`);
|
|
if (proposal.nearest_existing) {
|
|
console.log(`Nearest existing pattern: ${proposal.nearest_existing.id} (${(proposal.score * 100).toFixed(1)}% match)`);
|
|
}
|
|
console.log('\n--- Markdown preview ---\n');
|
|
console.log(proposal.proposal.markdown);
|
|
console.log('\nRun with --apply to write the file and update patterns/patterns.json.');
|
|
}
|
|
}
|
|
|
|
function applyProposal(proposal) {
|
|
const manifest = loadManifest();
|
|
|
|
if (proposal.mode === 'update') {
|
|
const idx = manifest.patterns.findIndex(p => p.id === proposal.proposal.id);
|
|
if (idx === -1) throw new Error(`Pattern ${proposal.proposal.id} not found in manifest`);
|
|
manifest.patterns[idx].affected_projects = proposal.proposal.affected_projects;
|
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
console.log(`\nUpdated manifest: ${proposal.proposal.id} affected_projects`);
|
|
return;
|
|
}
|
|
|
|
// Create new pattern
|
|
const filePath = path.join(WORKSPACE, proposal.proposal.file);
|
|
if (fs.existsSync(filePath)) {
|
|
throw new Error(`Pattern file already exists: ${proposal.proposal.file}`);
|
|
}
|
|
fs.writeFileSync(filePath, proposal.proposal.markdown);
|
|
|
|
const manifestEntry = {
|
|
id: proposal.proposal.id,
|
|
name: proposal.proposal.name,
|
|
file: proposal.proposal.file,
|
|
tags: proposal.proposal.tags,
|
|
affected_projects: proposal.proposal.affected_projects,
|
|
related_patterns: proposal.proposal.related_patterns
|
|
};
|
|
manifest.patterns.push(manifestEntry);
|
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
console.log(`\nCreated ${proposal.proposal.file}`);
|
|
console.log(`Updated patterns/patterns.json`);
|
|
}
|
|
|
|
function parseArgv() {
|
|
const argv = { _: [] };
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const arg = process.argv[i];
|
|
if (arg === '--apply') argv.apply = true;
|
|
else if (arg === '--help' || arg === '-h') argv.help = true;
|
|
else if (arg.startsWith('--')) {
|
|
const key = arg.slice(2).replace(/-/g, '_');
|
|
const next = process.argv[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
if (['projects', 'tags', 'related'].includes(key)) {
|
|
argv[key] = next.split(',').map(s => s.trim()).filter(Boolean);
|
|
} else {
|
|
argv[key] = next;
|
|
}
|
|
i++;
|
|
} else {
|
|
argv[key] = true;
|
|
}
|
|
} else {
|
|
argv._.push(arg);
|
|
}
|
|
}
|
|
return argv;
|
|
}
|
|
|
|
function main() {
|
|
const argv = parseArgv();
|
|
|
|
if (argv.help) {
|
|
console.log(`Usage: node scripts/utils/propose-pattern.js [options]
|
|
|
|
Options:
|
|
--task "..." Description of the task/problem
|
|
--fix "..." The workaround or fix applied
|
|
--projects p1,p2 Comma-separated affected project identifiers
|
|
--symptom "..." What the user/agent sees
|
|
--root-cause "..." Why it happens
|
|
--when-to-apply "..." When to apply this pattern to new tasks
|
|
--verification "..." How to verify the fix
|
|
--related p1,p2 Comma-separated related pattern IDs
|
|
--name "..." Override the generated pattern name
|
|
--id "..." Override the generated pattern ID
|
|
--apply Write files after review (default is dry-run)
|
|
--file path.json Load proposal inputs from JSON file
|
|
-h, --help Show this help
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let inputs = argv;
|
|
if (argv.file) {
|
|
inputs = { ...JSON.parse(fs.readFileSync(argv.file, 'utf8')), ...argv };
|
|
}
|
|
|
|
if (!inputs.task && !inputs.fix && !inputs.symptom) {
|
|
console.error('Error: at least one of --task, --fix, or --symptom is required.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const proposal = buildProposal(inputs);
|
|
printProposal(proposal);
|
|
|
|
if (argv.apply) {
|
|
applyProposal(proposal);
|
|
console.log('\nDone. Run `node architecture/pipeline.js "sample query"` to verify retrieval.');
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { buildProposal, applyProposal, findBestMatch, loadManifest };
|