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 = (() => {
- ${ruleEditor('coding', 'Coding Rules', 7)} - ${ruleEditor('general', 'General Rules', 7)} - ${ruleEditor('soul', 'Soul', 8, true)} + ${ruleEditor('coding', 'Coding Rules')} + ${ruleEditor('general', 'General Rules')} + ${ruleEditor('soul', 'Soul')}
@@ -106,22 +118,30 @@ const RulesLab = (() => {
- `; - } - - function ruleEditor(key, label, rows, wide = false) { + `; + } + + function ruleEditor(key, label) { + const priorities = PRIORITY_SECTIONS[key]; + const isWide = key === 'soul'; + const sectionsHtml = priorities + .map( + (p) => ` +
+ + +
`, + ) + .join(''); return ` -
+
${label}0 words
-
- +
+ ${sectionsHtml} +
`; } @@ -163,18 +183,36 @@ const RulesLab = (() => { localStorage.setItem(STORE_KEY, JSON.stringify(meta)); } + /** Read current values from all priority textareas into nested rules format */ function draft() { - return { - coding: document.getElementById('rules-coding')?.value || '', - general: document.getElementById('rules-general')?.value || '', - soul: document.getElementById('rules-soul')?.value || '', - }; + const rules = {}; + for (const key of sections) { + const priorities = PRIORITY_SECTIONS[key]; + rules[key] = {}; + for (const p of priorities) { + const el = document.getElementById(`rules-${key}-${p}`); + rules[key][p] = el?.value || ''; + } + } + return rules; } + /** Set values of all priority textareas from a rules object (flat or nested) */ function setDraft(rules) { sections.forEach((key) => { - const input = document.getElementById(`rules-${key}`); - if (input) input.value = rules?.[key] || ''; + const priorities = PRIORITY_SECTIONS[key]; + const section = rules?.[key]; + priorities.forEach((p) => { + const el = document.getElementById(`rules-${key}-${p}`); + if (!el) return; + if (typeof section === 'string') { + el.value = p === 'soft' ? section : ''; + } else if (section && typeof section === 'object') { + el.value = section[p] || ''; + } else { + el.value = ''; + } + }); }); ConfigTab.updateRuleMetrics?.(); refresh(); @@ -183,7 +221,6 @@ const RulesLab = (() => { function controlsToMeta() { sections.forEach((key) => { meta.enabled[key] = document.getElementById(`rules-${key}-enabled`)?.checked !== false; - meta.priority[key] = document.getElementById(`rules-${key}-priority`)?.value || meta.priority[key]; }); saveMeta(); } @@ -191,9 +228,7 @@ const RulesLab = (() => { function applyMetaToControls() { sections.forEach((key) => { const enabled = document.getElementById(`rules-${key}-enabled`); - const priority = document.getElementById(`rules-${key}-priority`); if (enabled) enabled.checked = meta.enabled[key] !== false; - if (priority) priority.value = meta.priority[key] || defaultMeta.priority[key]; }); } @@ -218,7 +253,6 @@ const RulesLab = (() => { ts: new Date().toISOString(), rules, enabled: { ...meta.enabled }, - priority: { ...meta.priority }, }; if (last && JSON.stringify(last.rules) === JSON.stringify(rules)) return; meta.history = [snapshot, ...meta.history].slice(0, 12); @@ -227,29 +261,37 @@ const RulesLab = (() => { function ensureDefaultProfiles() { const defaults = { Default: { - rules: { ...DEFAULT_RULES }, + rules: { + coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.' }, + general: { hard: '', soft: 'Memory is a core skill. Think independently.' }, + soul: { soft: 'Helpful, concise, and logical.\nObjective and critical thinker.' }, + }, enabled: { ...defaultMeta.enabled }, - priority: { ...defaultMeta.priority }, }, 'Strict Review': { rules: { - coding: - 'Prioritise bugs, regressions, missing tests, unsafe assumptions, and architecture drift.\nKeep findings specific and line-referenced.', - general: - 'Challenge weak reasoning. State uncertainty clearly. Do not overfit to the user request if the evidence points elsewhere.', - soul: 'Direct, concise, critical, and practical.', + coding: { + hard: 'Prioritise bugs, regressions, missing tests, unsafe assumptions, and architecture drift.\nKeep findings specific and line-referenced.', + soft: '', + }, + general: { + hard: 'Challenge weak reasoning. State uncertainty clearly. Do not overfit to the user request if the evidence points elsewhere.', + soft: '', + }, + soul: { soft: 'Direct, concise, critical, and practical.' }, }, enabled: { coding: true, general: true, soul: true }, - priority: { coding: 'hard', general: 'hard', soul: 'style' }, }, Research: { rules: { - coding: DEFAULT_RULES.coding, - general: 'Verify time-sensitive facts. Prefer primary sources. Separate evidence from inference.', - soul: 'Careful, source-led, and explicit about uncertainty.', + coding: { hard: '', soft: 'Modular code files.\nComment the why, not the what.' }, + general: { + hard: 'Verify time-sensitive facts. Prefer primary sources. Separate evidence from inference.', + soft: '', + }, + soul: { soft: 'Careful, source-led, and explicit about uncertainty.' }, }, enabled: { coding: true, general: true, soul: true }, - priority: { coding: 'preference', general: 'hard', soul: 'preference' }, }, }; meta.profiles = { ...defaults, ...meta.profiles }; @@ -276,7 +318,6 @@ const RulesLab = (() => { meta.profiles[name] = { rules: draft(), enabled: { ...meta.enabled }, - priority: { ...meta.priority }, }; saveMeta(); if (input) input.value = ''; @@ -297,7 +338,6 @@ const RulesLab = (() => { }); if (!ok) return; meta.enabled = { ...defaultMeta.enabled, ...(profile.enabled || {}) }; - meta.priority = { ...defaultMeta.priority, ...(profile.priority || {}) }; applyMetaToControls(); setDraft(profile.rules); saveMeta(); @@ -325,7 +365,20 @@ const RulesLab = (() => { const rules = draft(); return sections .filter((key) => meta.enabled[key] !== false) - .map((key) => ({ key, label: labels[key], priority: meta.priority[key], text: rules[key].trim() })); + .map((key) => ({ + key, + label: labels[key], + text: flattenSectionText(rules[key], PRIORITY_SECTIONS[key]), + })); + } + + function flattenSectionText(section, priorities) { + if (typeof section === 'string') return section; + if (!section || typeof section !== 'object') return ''; + return priorities + .map((p) => (section[p] || '').trim()) + .filter(Boolean) + .join('\n\n'); } function renderPreview() { @@ -334,17 +387,11 @@ const RulesLab = (() => { const target = document.getElementById('rules-preview-target')?.value || 'agents'; const items = activeSections(); if (target === 'cursor') { - host.textContent = items - .map((item) => `[${priorityLabel(item.priority)}] ${item.label}\n${item.text}`) - .join('\n\n'); + host.textContent = items.map((item) => `${item.label}\n${item.text}`).join('\n\n'); return; } const title = target === 'claude' ? '# Context Engine Rules' : '# Rules'; - host.textContent = `${title}\n\n${items.map((item) => `## ${item.label}\nPriority: ${priorityLabel(item.priority)}\n${item.text}`).join('\n\n')}`; - } - - function priorityLabel(value) { - return value === 'hard' ? 'Hard rule' : value === 'style' ? 'Style guidance' : 'Preference'; + host.textContent = `${title}\n\n${items.map((item) => `## ${item.label}\n${item.text}`).join('\n\n')}`; } function issue(kind, title, body) { @@ -374,7 +421,12 @@ const RulesLab = (() => { } function flattenRules(rules) { - return sections.flatMap((key) => [`## ${labels[key]}`, ...(rules[key] || '').split('\n')]); + return sections.flatMap((key) => { + const section = rules?.[key]; + const priorities = PRIORITY_SECTIONS[key]; + const text = flattenSectionText(section, priorities); + return [`## ${labels[key]}`, ...(text ? text.split('\n') : [])]; + }); } function simpleDiff(before, after) { @@ -405,13 +457,23 @@ const RulesLab = (() => { (snap, index) => ` `, ) .join('') : '
Save changes to create snapshots.
'; } + function flattenRulesText(rules) { + return sections + .map((key) => { + const section = rules?.[key]; + const priorities = PRIORITY_SECTIONS[key]; + return flattenSectionText(section, priorities); + }) + .join(' '); + } + async function restoreHistory(index) { const snap = meta.history[index]; if (!snap) return; @@ -422,7 +484,6 @@ const RulesLab = (() => { }); if (!ok) return; meta.enabled = { ...defaultMeta.enabled, ...(snap.enabled || {}) }; - meta.priority = { ...defaultMeta.priority, ...(snap.priority || {}) }; applyMetaToControls(); setDraft(snap.rules); } @@ -444,7 +505,7 @@ const RulesLab = (() => { .map((entry) => (typeof entry === 'string' ? entry : entry.content || '')) .join('\n') .toLowerCase(); - const text = Object.values(draft()).join('\n').toLowerCase(); + const text = flattenRulesText(draft()).toLowerCase(); const notes = []; if ( /no memory|ignore memory|do not use memory/.test(text) && @@ -501,5 +562,6 @@ const RulesLab = (() => { applyProfile, restoreHistory, switchPanel, + PRIORITY_SECTIONS, }; })(); diff --git a/ui/store.js b/ui/store.js index f8d9702..b88aa72 100644 --- a/ui/store.js +++ b/ui/store.js @@ -9,7 +9,7 @@ const API = '/api'; * @typedef {{ title?: string, message?: string, confirmText?: string, cancelText?: string, danger?: boolean }} ConfirmOptions * @typedef {{ returnErrors?: boolean }} ApiFetchOptions * @typedef {{ version?: string, last_updated?: string, entries: Array }} MemoryData - * @typedef {{ coding?: string, general?: string, soul?: string, [key: string]: unknown }} RulesData + * @typedef {{ coding: Object, general: Object, soul: Object, [key: string]: unknown }} RulesData */ // ---- APP DIALOGS ---- @@ -356,6 +356,55 @@ const MS = { }; // ---- RULES ---- +/** @type {Object} */ +const PRIORITY_SECTIONS = { + coding: ['hard', 'soft'], + general: ['hard', 'soft'], + soul: ['soft'], +}; + +/** Migrate legacy flat-string rules to new priority-object format + * @param {any} rules + * @returns {RulesData} + */ +function migrateRules(rules) { + if (!rules) return structuredClone(DEFAULT_RULES); + /** @type {RulesData} */ + const result = { coding: {}, general: {}, soul: {} }; + for (const key of /** @type {('coding'|'general'|'soul')[]} */ (['coding', 'general', 'soul'])) { + const section = rules[key]; + const priorities = /** @type {string[]} */ (PRIORITY_SECTIONS[key]); + if (typeof section === 'string') { + priorities.forEach((p) => { + result[key][p] = p === 'soft' ? section : ''; + }); + } else if (section && typeof section === 'object') { + priorities.forEach((p) => { + if (p === 'soft') { + const soft = typeof section.soft === 'string' ? section.soft : ''; + if (soft) { + result[key][p] = soft; + } else { + const pref = typeof section.preference === 'string' ? section.preference : ''; + const style = typeof section.style === 'string' ? section.style : ''; + const parts = []; + if (pref) parts.push('## Preference\n' + pref); + if (style) parts.push('## Style\n' + style); + result[key][p] = parts.join('\n\n'); + } + } else { + result[key][p] = typeof section[p] === 'string' ? section[p] : ''; + } + }); + } else { + priorities.forEach((p) => { + result[key][p] = ''; + }); + } + } + return result; +} + const RS = { /** @type {RulesData | null} */ _cache: null, @@ -365,11 +414,11 @@ const RS = { const raw = localStorage.getItem('ce_rules'); const s = raw ? JSON.parse(raw) : null; if (s) { - this._cache = s; - return s; + this._cache = migrateRules(s); + return this._cache; } } catch {} - return { ...DEFAULT_RULES }; + return structuredClone(DEFAULT_RULES); }, /** @param {RulesData} rules */ save(rules) { @@ -389,7 +438,7 @@ const RS = { async loadFromServer() { const data = await apiFetch('/rules'); if (data) { - const rules = { coding: data.coding, general: data.general, soul: data.soul }; + const rules = migrateRules(data); this._cache = rules; localStorage.setItem('ce_rules', JSON.stringify(rules)); return rules; diff --git a/ui/styles/tab-config.css b/ui/styles/tab-config.css index 5c82515..9a3c1b3 100644 --- a/ui/styles/tab-config.css +++ b/ui/styles/tab-config.css @@ -162,6 +162,31 @@ align-items: center; } +.rules-priority-group { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.rules-priority-section { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.rules-priority-label { + display: inline-flex; + align-items: center; + gap: var(--s-1); + color: var(--text-3); + font-family: var(--mono); + font-size: var(--fs-01); + font-weight: 600; + letter-spacing: var(--tr-wide); + text-transform: uppercase; + white-space: nowrap; +} + .rules-block-controls label { display: inline-flex; align-items: center;