#!/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 };