#!/usr/bin/env node
/**
* review-rule-candidates.js — Interactive review of proposed rules
*
* Reads data/rule-candidates.jsonl (written by stream-correction on corrections).
* For each pending candidate: show symptom, trigger files, suggested rule.
* User accepts, refines, or rejects.
* Accepted rules are appended to data/learned-rules.json (or stored as atoms).
*
* Usage:
* node atomspace/review-rule-candidates.js # review all pending
* node atomspace/review-rule-candidates.js --list # list without reviewing
* node atomspace/review-rule-candidates.js --clear # clear all pending (reject all)
*/
const fs = require('fs')
const path = require('path')
const readline = require('readline')
const DATA_DIR = path.join(__dirname, '..', 'data')
const CANDIDATES_FILE = path.join(DATA_DIR, 'rule-candidates.jsonl')
const LEARNED_RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
const ARCHIVE_FILE = path.join(DATA_DIR, 'rule-candidates-archive.jsonl')
function readCandidates () {
if (!fs.existsSync(CANDIDATES_FILE)) return []
const content = fs.readFileSync(CANDIDATES_FILE, 'utf8')
return content.trim().split('\n').filter(Boolean).map(l => {
try { return JSON.parse(l) } catch { return null }
}).filter(Boolean)
}
function writeCandidates (candidates) {
fs.writeFileSync(CANDIDATES_FILE, candidates.map(c => JSON.stringify(c)).join('\n') + (candidates.length > 0 ? '\n' : ''))
}
function archive (candidate) {
fs.appendFileSync(ARCHIVE_FILE, JSON.stringify(candidate) + '\n')
}
function promptUser (prompt) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
return new Promise(resolve => rl.question(prompt, answer => {
rl.close()
resolve(answer)
}))
}
async function main () {
const args = process.argv.slice(2)
const candidates = readCandidates()
if (candidates.length === 0) {
console.log('No pending rule candidates.')
return
}
if (args.includes('--list')) {
console.log(`${candidates.length} pending candidates:\n`)
for (const c of candidates) {
console.log(` [${c.timestamp.slice(0, 19)}]`)
console.log(` symptom: ${c.symptom.slice(0, 120)}`)
console.log(` files: ${c.triggerFiles.slice(0, 3).join(', ')}`)
console.log(` suggested: ${c.suggestedRule}`)
console.log()
}
return
}
if (args.includes('--clear')) {
console.log(`Rejecting all ${candidates.length} pending candidates...`)
for (const c of candidates) {
archive({ ...c, status: 'rejected-bulk', reviewedAt: new Date().toISOString() })
}
writeCandidates([])
console.log('Done.')
return
}
console.log(`Reviewing ${candidates.length} pending rule candidates.`)
console.log('Commands: [a]ccept [r]eject [e]dit [s]kip [q]uit\n')
const remaining = []
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i]
console.log(`─── Candidate ${i + 1} of ${candidates.length} ───`)
console.log(`Timestamp: ${c.timestamp}`)
console.log(`Symptom: ${c.symptom}`)
console.log(`Files: ${c.triggerFiles.join(', ')}`)
console.log(`Suggested: ${c.suggestedRule}`)
console.log()
const choice = (await promptUser('Action [a/r/e/s/q]: ')).trim().toLowerCase()
if (choice === 'q') {
remaining.push(...candidates.slice(i))
break
} else if (choice === 'a') {
archive({ ...c, status: 'accepted', reviewedAt: new Date().toISOString() })
// Append to learned-rules.json
try {
const rules = fs.existsSync(LEARNED_RULES_FILE)
? JSON.parse(fs.readFileSync(LEARNED_RULES_FILE, 'utf8'))
: { rules: [] }
const list = Array.isArray(rules) ? rules : (rules.rules || [])
list.push({
id: `rule-user-${Date.now().toString(36)}`,
source: 'user-accepted-candidate',
trigger: { files: c.triggerFiles, symptom: c.symptom },
action: { description: c.suggestedRule },
quality: 'specific',
reviewedAt: new Date().toISOString()
})
if (Array.isArray(rules)) fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(list, null, 2))
else { rules.rules = list; rules.totalRules = list.length; fs.writeFileSync(LEARNED_RULES_FILE, JSON.stringify(rules, null, 2)) }
console.log('✓ Accepted and added to learned-rules.json\n')
} catch (e) {
console.log('✗ Failed to persist: ' + e.message + '\n')
}
} else if (choice === 'r') {
archive({ ...c, status: 'rejected', reviewedAt: new Date().toISOString() })
console.log('✗ Rejected\n')
} else if (choice === 'e') {
const newRule = await promptUser('Refined rule: ')
if (newRule.trim()) {
archive({ ...c, suggestedRule: newRule, status: 'accepted-edited', reviewedAt: new Date().toISOString() })
console.log('✎ Accepted with edits\n')
} else {
remaining.push(c)
console.log('(skipped — no edit entered)\n')
}
} else {
remaining.push(c) // skip/unknown -> stays in queue
console.log('(skipped)\n')
}
}
writeCandidates(remaining)
console.log(`Review complete. ${remaining.length} candidates remain in queue.`)
}
if (require.main === module) {
main().catch(err => { console.error(err.message); process.exit(1) })
}