Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions scripts/validation-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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');

Expand Down
137 changes: 118 additions & 19 deletions server/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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'])}`,
);
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 22 additions & 2 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
Loading
Loading