diff --git a/scripts/validation-smoke.js b/scripts/validation-smoke.js index 57318c6..5cde014 100644 --- a/scripts/validation-smoke.js +++ b/scripts/validation-smoke.js @@ -55,9 +55,35 @@ assert.ok(memIdx.error && memIdx.error.includes('Entry 0'), 'error references co // ---- validateRules ---- -// GIVEN valid rules data +// GIVEN valid rules data (legacy flat-string format) const rulesOk = validateRules({ coding: 'Use strict mode', general: 'Be helpful', soul: 'Curious' }); -assert.strictEqual(rulesOk.valid, true, 'valid rules passes'); +assert.strictEqual(rulesOk.valid, true, 'valid flat rules passes'); + +// GIVEN valid rules data (new priority-object format) +const rulesPriorityOk = validateRules({ + coding: { hard: 'No unused vars', soft: 'Use strict mode' }, + general: { hard: 'Be truthful', soft: 'Be helpful' }, + soul: { soft: 'Curious and direct' }, +}); +assert.strictEqual(rulesPriorityOk.valid, true, 'valid priority rules passes'); + +// GIVEN soul with invalid priority +const rulesSoulBad = validateRules({ coding: '', general: '', soul: { hard: 'not allowed' } }); +assert.strictEqual(rulesSoulBad.valid, false, 'soul with hard priority fails'); +assert.ok(rulesSoulBad.error && rulesSoulBad.error.includes('soul'), 'error mentions soul'); + +// GIVEN coding with invalid priority +const rulesCodingBad = validateRules({ + coding: { hard: 'ok', soft: 'ok', urgent: 'not allowed' }, + general: '', + soul: '', +}); +assert.strictEqual(rulesCodingBad.valid, false, 'coding with invalid priority fails'); +assert.ok(rulesCodingBad.error && rulesCodingBad.error.includes('coding'), 'error mentions coding'); + +// GIVEN mixed flat and object +const rulesMixed = validateRules({ coding: 'text', general: { soft: 'text' }, soul: '' }); +assert.strictEqual(rulesMixed.valid, true, 'mixed flat and object rules passes'); // GIVEN missing one of the required keys const rulesMissing = validateRules({ coding: 'x', general: 'y' }); @@ -68,6 +94,10 @@ assert.ok(rulesMissing.error && rulesMissing.error.includes('soul'), 'error ment const rulesWrongType = validateRules({ coding: 42, general: '', soul: '' }); assert.strictEqual(rulesWrongType.valid, false, 'numeric coding value fails'); +// GIVEN array instead of object for coding +const rulesArray = validateRules({ coding: ['a', 'b'], general: '', soul: '' }); +assert.strictEqual(rulesArray.valid, false, 'array coding value fails'); + // GIVEN null input assert.strictEqual(validateRules(null).valid, false, 'null rules fails'); diff --git a/server/compiler.js b/server/compiler.js index 8e45569..604939a 100644 --- a/server/compiler.js +++ b/server/compiler.js @@ -67,7 +67,7 @@ function compileForClaude(ctx) { .join('\n'); const rulesBlock = ctx.rules - ? `## Operational Rules\n- **Coding:** ${ctx.rules.coding}\n- **General:** ${ctx.rules.general}\n- **Soul:** ${ctx.rules.soul}\n` + ? `## Operational Rules\n- **Coding:** ${flattenSection(ctx.rules.coding, ['hard', 'soft'])}\n- **General:** ${flattenSection(ctx.rules.general, ['hard', 'soft'])}\n- **Soul:** ${flattenSection(ctx.rules.soul, ['soft'])}\n` : ''; const resume = sessionStartBlock(ctx); @@ -103,7 +103,7 @@ function compileForCursor(ctx) { if (ctx.rules) { sections.push( - `# Rules\n\n## Coding\n${ctx.rules.coding}\n\n## General\n${ctx.rules.general}\n\n## Personality\n${ctx.rules.soul}`, + `# Rules\n\n${flattenSectionLabeled(ctx.rules.coding, 'Coding', ['hard', 'soft'])}\n\n${flattenSectionLabeled(ctx.rules.general, 'General', ['hard', 'soft'])}\n\n${flattenSectionLabeled(ctx.rules.soul, 'Soul', ['soft'])}`, ); } @@ -153,13 +153,13 @@ agent: sections.push(`## Rules ### Coding -${ctx.rules.coding} +${flattenSection(ctx.rules.coding, ['hard', 'soft'])} ### General -${ctx.rules.general} +${flattenSection(ctx.rules.general, ['hard', 'soft'])} ### Personality -${ctx.rules.soul}`); +${flattenSection(ctx.rules.soul, ['soft'])}`); } if (ctx.memory && ctx.memory.entries && ctx.memory.entries.length) { @@ -181,7 +181,9 @@ function compileForCopilot(ctx) { if (resume) sections.push(resume); if (ctx.rules) { - sections.push(`# Instructions\n\n${ctx.rules.coding}\n\n${ctx.rules.general}`); + sections.push( + `# Instructions\n\n${flattenSection(ctx.rules.coding, ['hard', 'soft'])}\n\n${flattenSection(ctx.rules.general, ['hard', 'soft'])}`, + ); } if (ctx.activeSkills.length) { @@ -208,7 +210,9 @@ function compileForWindsurf(ctx) { if (resume) sections.push(resume); if (ctx.rules) { - sections.push(`# Rules\n${ctx.rules.coding}\n${ctx.rules.general}`); + sections.push( + `# Rules\n${flattenSection(ctx.rules.coding, ['hard', 'soft'])}\n${flattenSection(ctx.rules.general, ['hard', 'soft'])}`, + ); } if (ctx.activeSkills.length) { @@ -275,36 +279,40 @@ function renderSkills(skills, cfg) { function renderRules(rules, cfg) { if (!rules || !cfg) return null; + const flat = { + coding: flattenSection(rules.coding, ['hard', 'soft']), + general: flattenSection(rules.general, ['hard', 'soft']), + soul: flattenSection(rules.soul, ['soft']), + }; if (cfg.kind === 'flat') { return cfg.keys - .map((k) => rules[k]) + .map((k) => flat[k]) .filter(Boolean) .join('\n\n'); } if (cfg.kind === 'wrapped') { const body = cfg.keys - .map((k) => rules[k]) + .map((k) => flat[k]) .filter(Boolean) .join('\n\n'); return `${cfg.header}\n${body}`; } if (cfg.kind === 'wrapped-inline') { const body = cfg.entries - .map(([prefix, key]) => (rules[key] ? `${prefix}${rules[key]}` : null)) + .map(([prefix, key]) => (flat[key] ? `${prefix}${flat[key]}` : null)) .filter(Boolean) .join('\n'); return `${cfg.header}\n${body}`; } if (cfg.kind === 'sections') { return cfg.entries - .map(([heading, key]) => (rules[key] ? `## ${heading}\n${rules[key]}` : null)) + .map(([heading, key]) => (flat[key] ? `## ${heading}\n${flat[key]}` : null)) .filter(Boolean) .join('\n\n'); } if (cfg.kind === 'split-sections') { - // Each entry becomes its own top-level section pushed separately. return cfg.entries - .map(([heading, key]) => (rules[key] ? `## ${heading}\n${rules[key]}` : null)) + .map(([heading, key]) => (flat[key] ? `## ${heading}\n${flat[key]}` : null)) .filter(Boolean); } throw new Error(`Unknown rules kind: ${cfg.kind}`); @@ -342,9 +350,10 @@ function compileForOllama(ctx) { } if (ctx.rules) { - sysLines.push(`Coding rules: ${ctx.rules.coding}`); - sysLines.push(`General rules: ${ctx.rules.general}`); - if (ctx.rules.soul) sysLines.push(`Personality: ${ctx.rules.soul}`); + sysLines.push(`Coding rules: ${flattenSection(ctx.rules.coding, ['hard', 'soft'])}`); + sysLines.push(`General rules: ${flattenSection(ctx.rules.general, ['hard', 'soft'])}`); + const soulText = flattenSection(ctx.rules.soul, ['soft']); + if (soulText) sysLines.push(`Personality: ${soulText}`); } if (ctx.activeSkills.length) { @@ -421,15 +430,105 @@ function buildContext(opts) { return { memory, - rules: rules - ? { coding: rules.coding || '', general: rules.general || '', soul: rules.soul || '' } - : null, + rules: rules ? normalizeRules(rules) : null, sessionStart: rules?.sessionStart || '', activeSkills, totalSkills: allSkills.length, }; } +/** + * Normalize rules from either legacy flat-string format or new priority-object format + * into the canonical priority-object format. + * + * Legacy: { coding: "text", general: "text", soul: "text" } + * New: { coding: { hard: "...", soft: "..." }, general: {...}, soul: { soft: "..." } } + * + * @param {object} rules + * @returns {object} + */ +function normalizeRules(rules) { + const codingPriorities = ['hard', 'soft']; + const generalPriorities = ['hard', 'soft']; + const soulPriorities = ['soft']; + + const coding = + typeof rules.coding === 'string' + ? { soft: rules.coding } + : typeof rules.coding === 'object' && rules.coding !== null + ? pickPriorities(rules.coding, codingPriorities) + : {}; + const general = + typeof rules.general === 'string' + ? { soft: rules.general } + : typeof rules.general === 'object' && rules.general !== null + ? pickPriorities(rules.general, generalPriorities) + : {}; + const soul = + typeof rules.soul === 'string' + ? { soft: rules.soul } + : typeof rules.soul === 'object' && rules.soul !== null + ? pickPriorities(rules.soul, soulPriorities) + : {}; + + return { coding, general, soul }; +} + +/** + * Flatten a priority-object section into a single string. + * Each priority gets a labeled section. Empty priorities are omitted. + * @param {object|string} section + * @param {string[]} priorities + * @returns {string} + */ +function flattenSection(section, priorities) { + if (typeof section === 'string') return section; + if (!section || typeof section !== 'object') return ''; + const parts = []; + for (const p of priorities) { + const text = (section[p] || '').trim(); + if (text) parts.push(text); + } + return parts.join('\n\n'); +} + +/** + * Flatten a priority-object section into labeled sections for output. + * Each priority gets its own heading prefix. + * @param {object|string} section + * @param {string} sectionLabel - e.g. "Coding" or "General" or "Soul" + * @param {string[]} priorities + * @returns {string} + */ +function flattenSectionLabeled(section, sectionLabel, priorities) { + if (typeof section === 'string') return `${sectionLabel}\n${section}`; + if (!section || typeof section !== 'object') return ''; + const parts = []; + for (const p of priorities) { + const text = (section[p] || '').trim(); + if (text) { + const label = p === 'hard' ? 'Hard rule' : 'Soft guidance'; + parts.push(`### ${label}\n${text}`); + } + } + if (!parts.length) return ''; + return `## ${sectionLabel}\n\n${parts.join('\n\n')}`; +} + +/** + * Pick only allowed priorities from a rules object, ignoring unknown keys. + * @param {object} obj + * @param {string[]} allowed + * @returns {object} + */ +function pickPriorities(obj, allowed) { + const out = {}; + for (const key of allowed) { + if (typeof obj[key] === 'string') out[key] = obj[key]; + } + return out; +} + // Resume bookmark — same block injected into every adapter so AI agents // route to the handoff doc before exploring the repo on session resume. function sessionStartBlock(ctx) { diff --git a/server/lib/validation.js b/server/lib/validation.js index 9eb9ffb..5150b70 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -20,8 +20,28 @@ function validateMemory(data) { function validateRules(data) { if (!data || typeof data !== 'object') return { valid: false, error: 'Must be a JSON object' }; if (data._parseError) return { valid: false, error: 'Invalid JSON in request body' }; - for (const key of ['coding', 'general', 'soul']) { - if (typeof data[key] !== 'string') return { valid: false, error: `Missing or invalid "${key}" string` }; + const codingPriorities = ['hard', 'soft']; + const generalPriorities = ['hard', 'soft']; + const soulPriorities = ['soft']; + const sections = [ + { key: 'coding', allowed: codingPriorities }, + { key: 'general', allowed: generalPriorities }, + { key: 'soul', allowed: soulPriorities }, + ]; + for (const { key, allowed } of sections) { + const val = data[key]; + if (typeof val === 'string') continue; + if (!val || typeof val !== 'object' || Array.isArray(val)) { + return { valid: false, error: `Missing or invalid "${key}" section` }; + } + for (const [pkey, pval] of Object.entries(val)) { + if (!allowed.includes(pkey)) { + return { valid: false, error: `"${key}" does not allow priority "${pkey}"` }; + } + if (typeof pval !== 'string') { + return { valid: false, error: `"${key}.${pkey}" must be a string` }; + } + } } return { valid: true, error: null }; } diff --git a/ui/config.js b/ui/config.js index 278c03f..d16ddfd 100644 --- a/ui/config.js +++ b/ui/config.js @@ -3,27 +3,53 @@ // config.js - Soul & Rules tab with keyboard save const ConfigTab = (() => { - const ruleFields = [ - ['rules-coding', 'rules-coding-count'], - ['rules-general', 'rules-general-count'], - ['rules-soul', 'rules-soul-count'], - ]; + // Priority sections per rule category (must match RulesLab.PRIORITY_SECTIONS) + const PRIORITY_SECTIONS = { + coding: ['hard', 'preference', 'style'], + general: ['hard', 'preference', 'style'], + soul: ['preference'], + }; + + /** Get all textarea IDs for a given section key */ + function textareasFor(key) { + return PRIORITY_SECTIONS[key].map((p) => `rules-${key}-${p}`); + } + + /** Get all textarea IDs across all sections */ + function allTextareaIds() { + return Object.keys(PRIORITY_SECTIONS).flatMap(textareasFor); + } function load() { const r = RS.get(); - document.getElementById('rules-coding').value = r.coding || ''; - document.getElementById('rules-general').value = r.general || ''; - document.getElementById('rules-soul').value = r.soul || ''; + Object.keys(PRIORITY_SECTIONS).forEach((key) => { + const section = r[key]; + PRIORITY_SECTIONS[key].forEach((p) => { + const el = document.getElementById(`rules-${key}-${p}`); + if (!el) return; + if (typeof section === 'string') { + el.value = p === 'preference' ? section : ''; + } else if (section && typeof section === 'object') { + el.value = section[p] || ''; + } else { + el.value = ''; + } + }); + }); updateRuleMetrics(); } function save() { if (typeof RulesLab !== 'undefined') RulesLab.beforeSave(); - RS.save({ - coding: document.getElementById('rules-coding').value.trim(), - general: document.getElementById('rules-general').value.trim(), - soul: document.getElementById('rules-soul').value.trim(), + const data = {}; + Object.keys(PRIORITY_SECTIONS).forEach((key) => { + data[key] = {}; + PRIORITY_SECTIONS[key].forEach((p) => { + const el = document.getElementById(`rules-${key}-${p}`); + data[key][p] = el?.value?.trim() || ''; + }); }); + RS.save(data); updateRuleMetrics(); flash('rules-saved'); } @@ -36,7 +62,7 @@ const ConfigTab = (() => { danger: true, }); if (!ok) return; - RS.save({ ...DEFAULT_RULES }); + RS.save(structuredClone(DEFAULT_RULES)); load(); if (typeof RulesLab !== 'undefined') RulesLab.beforeSave(); flash('rules-saved'); @@ -44,12 +70,17 @@ const ConfigTab = (() => { } function updateRuleMetrics() { - ruleFields.forEach(([inputId, metricId]) => { - const input = document.getElementById(inputId); - const metric = document.getElementById(metricId); - if (!input || !metric) return; - const words = input.value.trim().split(/\s+/).filter(Boolean).length; - const lines = input.value.split(/\n/).filter((line) => line.trim()).length; + Object.keys(PRIORITY_SECTIONS).forEach((key) => { + const metric = document.getElementById(`rules-${key}-count`); + if (!metric) return; + let words = 0; + let lines = 0; + PRIORITY_SECTIONS[key].forEach((p) => { + const el = document.getElementById(`rules-${key}-${p}`); + if (!el) return; + words += el.value.trim().split(/\s+/).filter(Boolean).length; + lines += el.value.split(/\n/).filter((l) => l.trim()).length; + }); metric.textContent = `${words} words / ${lines} lines`; }); } @@ -152,8 +183,8 @@ const ConfigTab = (() => { if (typeof RulesLab !== 'undefined') RulesLab.mount(); load(); initKeyboardSave(); - ruleFields.forEach(([inputId]) => { - document.getElementById(inputId)?.addEventListener('input', () => { + allTextareaIds().forEach((id) => { + document.getElementById(id)?.addEventListener('input', () => { updateRuleMetrics(); if (typeof RulesLab !== 'undefined') RulesLab.refresh(); }); diff --git a/ui/data.js b/ui/data.js index 4ed2ae7..cbd74be 100644 --- a/ui/data.js +++ b/ui/data.js @@ -21,8 +21,13 @@ const DEFAULT_SOUL = `Helpful, concise, and logical. Objective and critical thinker.`; const DEFAULT_RULES = { - coding: `Modular code files. -Comment the why, not the what.`, - general: `Memory is a core skill. Think independently.`, - soul: DEFAULT_SOUL, + coding: { + hard: '', + soft: 'Modular code files.\nComment the why, not the what.', + }, + general: { + hard: '', + soft: 'Memory is a core skill. Think independently.', + }, + soul: { soft: DEFAULT_SOUL }, }; diff --git a/ui/rules-lab.js b/ui/rules-lab.js index e508461..e1db80a 100644 --- a/ui/rules-lab.js +++ b/ui/rules-lab.js @@ -6,9 +6,21 @@ const RulesLab = (() => { const STORE_KEY = 'ce_rules_lab'; const sections = ['coding', 'general', 'soul']; const labels = { coding: 'Coding Rules', general: 'General Rules', soul: 'Soul' }; + + const PRIORITY_SECTIONS = { + coding: ['hard', 'soft'], + general: ['hard', 'soft'], + soul: ['soft'], + }; + + const PRIORITY_LABELS = { + hard: 'Hard rules', + soft: 'Soft rules', + }; + const defaultMeta = { enabled: { coding: true, general: true, soul: true }, - priority: { coding: 'hard', general: 'hard', soul: 'preference' }, + priority: { coding: 'hard', general: 'hard', soul: 'soft' }, profiles: {}, history: [], lastSaved: null, @@ -40,9 +52,9 @@ const RulesLab = (() => {