sync: skills and tool contract overhaul from private repo
This commit is contained in:
@@ -15,6 +15,7 @@ function getDimensions() {
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.icon,
|
||||
d.is_priority AS isPriority,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
@@ -27,6 +28,7 @@ function getDimensions() {
|
||||
return rows.map(row => ({
|
||||
dimension: row.dimension,
|
||||
description: row.description,
|
||||
icon: row.icon || null,
|
||||
isPriority: Boolean(row.isPriority),
|
||||
count: Number(row.count)
|
||||
}));
|
||||
@@ -99,7 +101,7 @@ function updateDimension(data) {
|
||||
const dimResult = dimStmt.run(newName, currentName);
|
||||
|
||||
if (dimResult.changes === 0) {
|
||||
throw new Error('Dimension not found');
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
// Update node_dimensions
|
||||
@@ -138,7 +140,7 @@ function updateDimension(data) {
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('At least one update field must be provided');
|
||||
throw new Error('At least one update field must be provided (description, isPriority, or newName).');
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
@@ -153,7 +155,7 @@ function updateDimension(data) {
|
||||
const result = stmt.run(...params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Dimension not found');
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -184,7 +186,7 @@ function deleteDimension(name) {
|
||||
});
|
||||
|
||||
if (!removal.removedLinks && !removal.removedRow) {
|
||||
throw new Error('Dimension not found');
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -95,7 +95,7 @@ function updateEdge(id, updates) {
|
||||
|
||||
const existing = getEdgeById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
||||
}
|
||||
|
||||
// If explanation changed, update context
|
||||
@@ -128,7 +128,7 @@ function updateEdge(id, updates) {
|
||||
function deleteEdge(id) {
|
||||
const result = query('DELETE FROM edges WHERE id = ?', [id]);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,204 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const MAX_USER_GUIDES = 10;
|
||||
|
||||
// Where guides live on disk (shared with the app)
|
||||
const GUIDES_DIR = path.join(
|
||||
os.homedir(),
|
||||
'Library', 'Application Support', 'RA-H', 'guides'
|
||||
);
|
||||
|
||||
// System guides bundled with this package
|
||||
const BUNDLED_SYSTEM_DIR = path.join(__dirname, '..', 'guides', 'system');
|
||||
const BUNDLED_USER_DIR = path.join(__dirname, '..', 'guides');
|
||||
|
||||
// System guide names (immutable, always re-seeded)
|
||||
const SYSTEM_GUIDE_NAMES = new Set([
|
||||
'start-here',
|
||||
'schema',
|
||||
'creating-nodes',
|
||||
'edges',
|
||||
'dimensions',
|
||||
'extract',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown without external deps.
|
||||
* Returns { data: {}, content: string }
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content: raw.trim() };
|
||||
|
||||
const yamlBlock = match[1];
|
||||
const content = match[2];
|
||||
const data = {};
|
||||
|
||||
for (const line of yamlBlock.split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
// Handle booleans
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return { data, content: content.trim() };
|
||||
}
|
||||
|
||||
function isSystemGuide(filename) {
|
||||
const name = filename.replace('.md', '');
|
||||
return SYSTEM_GUIDE_NAMES.has(name);
|
||||
}
|
||||
|
||||
function ensureGuidesDir() {
|
||||
if (!fs.existsSync(GUIDES_DIR)) {
|
||||
fs.mkdirSync(GUIDES_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed guides on first run.
|
||||
* System guides always overwrite. User guides only seed if missing.
|
||||
*/
|
||||
function seedGuides() {
|
||||
ensureGuidesDir();
|
||||
|
||||
// Always re-seed system guides (immutable)
|
||||
if (fs.existsSync(BUNDLED_SYSTEM_DIR)) {
|
||||
const files = fs.readdirSync(BUNDLED_SYSTEM_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
fs.copyFileSync(path.join(BUNDLED_SYSTEM_DIR, file), path.join(GUIDES_DIR, file));
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default user guides only if they don't exist
|
||||
if (fs.existsSync(BUNDLED_USER_DIR)) {
|
||||
const files = fs.readdirSync(BUNDLED_USER_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const dest = path.join(GUIDES_DIR, file);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.copyFileSync(path.join(BUNDLED_USER_DIR, file), dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
seedGuides();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all guides with name, description, immutable flag.
|
||||
*/
|
||||
function listGuides() {
|
||||
init();
|
||||
if (!fs.existsSync(GUIDES_DIR)) return [];
|
||||
|
||||
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
|
||||
|
||||
const guides = files.map(file => {
|
||||
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
|
||||
const { data } = parseFrontmatter(raw);
|
||||
const immutable = isSystemGuide(file) || data.immutable === true;
|
||||
return {
|
||||
name: data.name || file.replace('.md', ''),
|
||||
description: data.description || '',
|
||||
immutable,
|
||||
};
|
||||
});
|
||||
|
||||
// System guides first, then user guides alphabetically
|
||||
return guides.sort((a, b) => {
|
||||
if (a.immutable && !b.immutable) return -1;
|
||||
if (!a.immutable && b.immutable) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a guide by name. Returns full content.
|
||||
*/
|
||||
function readGuide(name) {
|
||||
init();
|
||||
const candidates = [`${name}.md`, `${name.toLowerCase()}.md`];
|
||||
|
||||
for (const filename of candidates) {
|
||||
const filepath = path.join(GUIDES_DIR, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
const raw = fs.readFileSync(filepath, 'utf-8');
|
||||
const { data, content } = parseFrontmatter(raw);
|
||||
const immutable = isSystemGuide(filename) || data.immutable === true;
|
||||
return {
|
||||
name: data.name || name,
|
||||
description: data.description || '',
|
||||
immutable,
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write or update a guide. Rejects writes to system guides.
|
||||
*/
|
||||
function writeGuide(name, content) {
|
||||
init();
|
||||
const filename = `${name.toLowerCase()}.md`;
|
||||
|
||||
if (isSystemGuide(filename)) {
|
||||
return { success: false, error: `Guide "${name}" is a system guide and cannot be modified.` };
|
||||
}
|
||||
|
||||
const filepath = path.join(GUIDES_DIR, filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
const userCount = listGuides().filter(g => !g.immutable).length;
|
||||
if (userCount >= MAX_USER_GUIDES) {
|
||||
return { success: false, error: `Maximum of ${MAX_USER_GUIDES} custom guides reached. Delete a guide first.` };
|
||||
}
|
||||
}
|
||||
|
||||
ensureGuidesDir();
|
||||
fs.writeFileSync(filepath, content, 'utf-8');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a guide. Rejects deletes of system guides.
|
||||
*/
|
||||
function deleteGuide(name) {
|
||||
init();
|
||||
const candidates = [`${name}.md`, `${name.toLowerCase()}.md`];
|
||||
|
||||
for (const filename of candidates) {
|
||||
if (isSystemGuide(filename)) {
|
||||
return { success: false, error: `Guide "${name}" is a system guide and cannot be deleted.` };
|
||||
}
|
||||
const filepath = path.join(GUIDES_DIR, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.unlinkSync(filepath);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: `Guide "${name}" not found.` };
|
||||
}
|
||||
const skillService = require('./skillService');
|
||||
|
||||
module.exports = {
|
||||
listGuides,
|
||||
readGuide,
|
||||
writeGuide,
|
||||
deleteGuide,
|
||||
listGuides: skillService.listSkills,
|
||||
readGuide: skillService.readSkill,
|
||||
writeGuide: skillService.writeSkill,
|
||||
deleteGuide: skillService.deleteSkill,
|
||||
};
|
||||
|
||||
@@ -180,7 +180,7 @@ function updateNode(id, updates, options = {}) {
|
||||
// Check node exists
|
||||
const existing = getNodeById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
@@ -251,7 +251,7 @@ function updateNode(id, updates, options = {}) {
|
||||
function deleteNode(id) {
|
||||
const result = query('DELETE FROM nodes WHERE id = ?', [id]);
|
||||
if (result.changes === 0) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const SKILLS_DIR = path.join(
|
||||
os.homedir(),
|
||||
'Library', 'Application Support', 'RA-H', 'skills'
|
||||
);
|
||||
|
||||
const LEGACY_GUIDES_DIR = path.join(
|
||||
os.homedir(),
|
||||
'Library', 'Application Support', 'RA-H', 'guides'
|
||||
);
|
||||
|
||||
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
||||
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul');
|
||||
|
||||
const SEEDED_SKILL_IDS = new Set([
|
||||
'db-operations',
|
||||
'create-skill',
|
||||
'audit',
|
||||
'traverse',
|
||||
'onboarding',
|
||||
'persona',
|
||||
'calibration',
|
||||
'connect',
|
||||
]);
|
||||
|
||||
const DEPRECATED_SKILL_IDS = new Set([
|
||||
'start-here',
|
||||
'schema',
|
||||
'creating-nodes',
|
||||
'edges',
|
||||
'dimensions',
|
||||
'extract',
|
||||
'troubleshooting',
|
||||
'integrate',
|
||||
'test-guide',
|
||||
'ghostwriting-brad',
|
||||
'write-the-debrief',
|
||||
'prep',
|
||||
'preferences',
|
||||
'research',
|
||||
'survey',
|
||||
'traverse-graph',
|
||||
]);
|
||||
|
||||
function parseFrontmatter(raw) {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content: raw.trim() };
|
||||
|
||||
const yamlBlock = match[1];
|
||||
const content = match[2];
|
||||
const data = {};
|
||||
|
||||
for (const line of yamlBlock.split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return { data, content: content.trim() };
|
||||
}
|
||||
|
||||
function stripMdExtension(value) {
|
||||
return value.replace(/\.md$/i, '');
|
||||
}
|
||||
|
||||
function normalizeSkillId(value) {
|
||||
return stripMdExtension(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function listMarkdownFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
|
||||
}
|
||||
|
||||
function ensureSkillsDir() {
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyGuides() {
|
||||
const files = listMarkdownFiles(LEGACY_GUIDES_DIR);
|
||||
for (const file of files) {
|
||||
const dest = path.join(SKILLS_DIR, file);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.copyFileSync(path.join(LEGACY_GUIDES_DIR, file), dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedSkills() {
|
||||
ensureSkillsDir();
|
||||
|
||||
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
|
||||
const bundledById = new Map();
|
||||
for (const file of bundledFiles) {
|
||||
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
|
||||
}
|
||||
|
||||
for (const skillId of SEEDED_SKILL_IDS) {
|
||||
const source = bundledById.get(skillId);
|
||||
if (!source) continue;
|
||||
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.copyFileSync(source, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSeededBaseline() {
|
||||
if (fs.existsSync(SEED_MIGRATION_FLAG)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
|
||||
const bundledById = new Map();
|
||||
for (const file of bundledFiles) {
|
||||
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
|
||||
}
|
||||
|
||||
for (const skillId of SEEDED_SKILL_IDS) {
|
||||
const source = bundledById.get(skillId);
|
||||
if (!source) continue;
|
||||
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
|
||||
fs.copyFileSync(source, dest);
|
||||
}
|
||||
|
||||
fs.writeFileSync(SEED_MIGRATION_FLAG, 'ok', 'utf-8');
|
||||
}
|
||||
|
||||
function pruneDeprecatedSkills() {
|
||||
const files = listMarkdownFiles(SKILLS_DIR);
|
||||
for (const file of files) {
|
||||
const normalized = normalizeSkillId(file);
|
||||
if (DEPRECATED_SKILL_IDS.has(normalized)) {
|
||||
fs.unlinkSync(path.join(SKILLS_DIR, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillFilename(name) {
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md'));
|
||||
const normalizedInput = normalizeSkillId(name);
|
||||
const directCandidates = [
|
||||
`${name}.md`,
|
||||
`${name.toLowerCase()}.md`,
|
||||
normalizedInput ? `${normalizedInput}.md` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (files.includes(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (normalizeSkillId(file) === normalizedInput) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
|
||||
const { data } = parseFrontmatter(raw);
|
||||
if (typeof data.name === 'string' && normalizeSkillId(data.name) === normalizedInput) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
ensureSkillsDir();
|
||||
migrateLegacyGuides();
|
||||
migrateSeededBaseline();
|
||||
seedSkills();
|
||||
pruneDeprecatedSkills();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function listSkills() {
|
||||
init();
|
||||
if (!fs.existsSync(SKILLS_DIR)) return [];
|
||||
|
||||
const files = fs.readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md'));
|
||||
const skills = files.map((file) => {
|
||||
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
|
||||
const { data } = parseFrontmatter(raw);
|
||||
return {
|
||||
name: data.name || file.replace('.md', ''),
|
||||
description: data.description || '',
|
||||
immutable: false,
|
||||
};
|
||||
});
|
||||
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function readSkill(name) {
|
||||
init();
|
||||
const filename = resolveSkillFilename(name);
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filepath = path.join(SKILLS_DIR, filename);
|
||||
const raw = fs.readFileSync(filepath, 'utf-8');
|
||||
const { data, content } = parseFrontmatter(raw);
|
||||
return {
|
||||
name: data.name || stripMdExtension(filename),
|
||||
description: data.description || '',
|
||||
immutable: false,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSkill(name, content) {
|
||||
init();
|
||||
const normalizedName = normalizeSkillId(name);
|
||||
const existingFilename = resolveSkillFilename(name);
|
||||
const filename = existingFilename || `${normalizedName || name.toLowerCase()}.md`;
|
||||
const filepath = path.join(SKILLS_DIR, filename);
|
||||
|
||||
ensureSkillsDir();
|
||||
fs.writeFileSync(filepath, content, 'utf-8');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function deleteSkill(name) {
|
||||
init();
|
||||
const filename = resolveSkillFilename(name);
|
||||
if (!filename) {
|
||||
return { success: false, error: `Skill "${name}" not found.` };
|
||||
}
|
||||
|
||||
const filepath = path.join(SKILLS_DIR, filename);
|
||||
fs.unlinkSync(filepath);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listSkills,
|
||||
readSkill,
|
||||
writeSkill,
|
||||
deleteSkill,
|
||||
};
|
||||
Reference in New Issue
Block a user