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
35 changes: 33 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,49 @@ To use: read `.claude/command/{name}.md` and follow its protocol.

---

## Codex Integration (second-opinion runtime)

Codex CLI runs in parallel to Claude via the official `codex@openai-codex` plugin. Use for second opinions, deeper investigations, or to offload long tasks while Claude continues other work.

### Slash commands
| Command | Use when |
|---------|----------|
| `/codex:review` | Second-opinion code review of working tree or branch diff |
| `/codex:adversarial-review` | Aggressive review — challenges design choices, not just bugs |
| `/codex:rescue <task>` | Hand a substantial debugging/implementation task to Codex |
| `/codex:status` | List running Codex tasks |
| `/codex:result` | Fetch result of a finished Codex task |
| `/codex:cancel` | Cancel a running Codex task |
| `/codex:setup` | Verify/configure Codex runtime |

Flags: `--wait` (foreground) / `--background` (detach), `--write` (default for rescue, off for reviews), `--resume` (continue last Codex thread in this repo), `--fresh` (start new thread).

### Custom subagents (user-level, available in all projects)
Invoke via the `Agent` tool with `subagent_type`:

| Subagent | When to dispatch |
|----------|------------------|
| `codex-rescue` | Generic rescue — Claude is stuck or needs second implementation pass |
| `codex-security-auditor` | Before merging auth/secrets/input changes — OWASP Top 10 read-only audit |
| `codex-perf-analyst` | Before shipping a page that targets Lighthouse > 90 — RSC/bundle/data-fetching audit |
| `codex-coolify-validator` | Before deploying — catches hardcoded paths, missing env vars, Docker footguns |

All custom subagents run **read-only** by default and default to `--background`. They forward a pre-framed prompt to `codex-companion.mjs task`; do not call Codex directly from the main thread when one of these specialized agents fits.

---

## Auto-hooks (project-scoped)

The following are wired in `.claude/settings.json` and run automatically — do **not** invoke them manually unless the task changes mid-session.

| Event | Script | Purpose |
|-------|--------|---------|
| `SessionStart` | `.claude/scripts/load_project_context.sh` | Prints 5-layer Cortex briefing into the session as system context |
| `PostToolUse` matcher `Skill` | `.claude/scripts/skill-apply-hook.sh` | Inserts an `applied` row into `.codegraph/graph.db skill_usage` so health/route ranking sees real apply signal |
| `PostToolUse` matcher `Skill` | `.claude/scripts/skill-apply-hook.sh` | Inserts an `applied` row into the **local** `.codegraph/graph.db skill_usage` |

Implications:
- Skip manual `cortex_briefing` calls at the start of a session — the briefing is already in context.
- The `Skill` tool (built-in Claude Code skills) is auto-instrumented. For MCP skills loaded via `skill_read`, telemetry is recorded server-side and is not affected by these hooks.
- The `Skill` tool (built-in Claude Code skills) is auto-instrumented into the **local** DB. Because the MCP `codegraph` server proxies to prod (`memory.fl1.it`), these local `applied` rows do **not** reach prod's route/health ranking — only MCP `skill_read`/`skill_apply`/`skill_route` record telemetry server-side. See [`packages/codegraph/docs/DEPLOY-SKILLS.md`](packages/codegraph/docs/DEPLOY-SKILLS.md) for the full two-DB / telemetry picture and how to deploy skill changes to prod.
- Both scripts walk upward from `cwd` to locate `.codegraph/graph.db`, so they work from any subdir of the workspace.

---
Expand Down
74 changes: 74 additions & 0 deletions packages/codegraph/docs/DEPLOY-SKILLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Deploying skill-catalog changes to production

The skill subsystem spans **two databases**, and updates propagate through
**two independent mechanisms**. Knowing which is which avoids the classic
"I merged new skills but prod didn't change" trap.

## Two databases

| DB | Who reads it | Skills |
|----|--------------|--------|
| **Production** — `/data/.codegraph/graph.db` on `memory.fl1.it` | Every `skill_route` / `skill_read` at runtime (the MCP `codegraph` server is a proxy to `SKILLBRAIN_MCP_URL=https://memory.fl1.it/mcp`) | source of truth for routing |
| **Local** — `<repo>/.codegraph/graph.db` | Direct `sqlite3` queries and the project `skill-apply-hook.sh` only | dev/inspection copy |

The local DB is **not** what Claude routes against. Verifying a fix locally is
necessary but not sufficient — you must deploy for prod to change.

## How changes reach prod

### 1. Schema / FTS fixes → automatic on every boot
`openDb()` runs `runMigrations()` unconditionally, and the MCP server calls
`openDb()` at startup. So any new migration (e.g. `035_skills_fts_triggers.sql`,
whose one-time `rebuild` repairs a corrupted FTS index) applies on the next
redeploy with no manual step.

### 2. Skill *content* → version-gated import on boot
`entrypoint.sh` imports the bundled catalog (`/app/data`) when **either**:
- the DB has zero skills (first boot), **or**
- the bundle's content hash differs from the last imported hash
(`/data/.codegraph/catalog.hash`).

`/data` is a **persistent named volume**, so before the hash gate a redeploy
with an unchanged count would *skip* import entirely and new skills never
shipped. Now a changed bundle re-imports automatically. The boot import is
**additive** (idempotent, FTS-safe upsert) — it never deprecates.

### 3. Removing skills → manual full-sync
Boot import never prunes (so dashboard-created skills aren't wiped). To make the
DB exactly mirror the filesystem bundle — deprecating skills whose files were
deleted — run inside the container:

```bash
node dist/cli.js import-skills /data --full
```

`--full` soft-deprecates (status `deprecated`, reversible) any **active** skill
absent from the bundle, and **never** touches `System` / `Lifecycle` categories
or `pending` drafts.

## Telemetry reality (usage / apply signal)

`route()` ranking derives ~22% of its score from `skill_usage` (recency +
project affinity). That table is fed on prod **only** by MCP calls
(`skill_read` → `loaded`, `skill_apply` → `applied`, `skill_route` → `routed`),
which `recordUsage()` writes server-side.

It is **not** fed by:
- the project hook `.claude/scripts/skill-apply-hook.sh` — it writes to the
**local** DB, which prod never reads;
- the user hook `~/.config/skillbrain/skill-tracker.sh` — it POSTs to
`SKILLBRAIN_TELEMETRY_URL` (default `http://localhost:7777`, usually nothing).
The `/telemetry/skill-usage` endpoint is localhost-only and, when
`SKILLBRAIN_TELEMETRY_TOKEN` is set, token-gated.

So the built-in **`Skill` tool** (superpowers etc.) apply-signal does not reach
prod ranking in the default remote-MCP setup. If you want it to, point
`SKILLBRAIN_TELEMETRY_URL` at a reachable, authenticated endpoint — or prefer
the MCP `skill_apply` tool, which records server-side.

## Routing hygiene

`route()` excludes non-task skills from recommendations: `agent:*`, `command:*`,
`lifecycle` types, and `_routing-index`. They remain fully readable via
`skill_read` / `agent_read` / `command_read` — they're just kept out of ranked
task suggestions so specific domain/process skills surface.
31 changes: 27 additions & 4 deletions packages/codegraph/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
# SkillBrain entrypoint — import skills on first boot, then start MCP HTTP server

DATA_DIR="${SKILLBRAIN_ROOT:-/data}"
HASH_FILE="$DATA_DIR/.codegraph/catalog.hash"

# Check if skills are already imported
SKILL_COUNT=$(sqlite3 "$DATA_DIR/.codegraph/graph.db" "SELECT COUNT(*) FROM skills;" 2>/dev/null || echo "0")

if [ "$SKILL_COUNT" = "0" ] || [ "$SKILL_COUNT" = "" ]; then
echo "First boot: importing skills from bundled data..."
# Hash the bundled skill catalog. /data is a PERSISTENT volume, so a plain
# "import only when empty" gate means new/edited git-tracked skills never reach
# prod on redeploy. Comparing a content hash of /app/data against the last
# imported hash re-runs the import whenever the bundle changes. Import is an
# idempotent, FTS-safe upsert (stable rowid + migration 035 triggers), so
# re-running is cheap and additive. If sha1sum is unavailable the hash is empty
# and we fall back to the original first-boot-only behaviour.
bundle_hash() {
find /app/data -type f 2>/dev/null | LC_ALL=C sort | xargs sha1sum 2>/dev/null | sha1sum 2>/dev/null | cut -d' ' -f1
}
BUNDLE_HASH=$(bundle_hash)
STORED_HASH=$(cat "$HASH_FILE" 2>/dev/null || echo "")

run_import() {
# Create workspace structure that import-skills expects
mkdir -p "$DATA_DIR/.opencode" "$DATA_DIR/.agents/skills"

Expand All @@ -26,15 +38,26 @@ if [ "$SKILL_COUNT" = "0" ] || [ "$SKILL_COUNT" = "" ]; then
done
fi

# Run import
# Run import (additive — never prunes on boot; use `import-skills --full` manually for that)
node dist/cli.js import-skills "$DATA_DIR" 2>&1

# Cleanup symlinks (data stays in SQLite)
rm -rf "$DATA_DIR/.opencode" "$DATA_DIR/.agents"

# Record the imported bundle hash so unchanged redeploys skip the import.
[ -n "$BUNDLE_HASH" ] && printf '%s' "$BUNDLE_HASH" > "$HASH_FILE"
}

if [ "$SKILL_COUNT" = "0" ] || [ "$SKILL_COUNT" = "" ]; then
echo "First boot: importing skills from bundled data..."
run_import
echo "Skills import complete."
elif [ -n "$BUNDLE_HASH" ] && [ "$BUNDLE_HASH" != "$STORED_HASH" ]; then
echo "Bundled skill catalog changed ($STORED_HASH -> $BUNDLE_HASH): re-importing..."
run_import
echo "Skills re-import complete."
else
echo "Skills already loaded: $SKILL_COUNT items"
echo "Skills already loaded and catalog unchanged: $SKILL_COUNT items"
fi

# Daily backup on boot (keeps last 30 days)
Expand Down
6 changes: 4 additions & 2 deletions packages/codegraph/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,16 @@ program
.command('import-skills')
.description('Import skills, agents, and commands from filesystem into SQLite')
.argument('[path]', 'Path to workspace root', '.')
.action(async (targetPath: string) => {
.option('--full', 'Full-sync: also deprecate active skills no longer present in the bundle (protects System/Lifecycle)')
.action(async (targetPath: string, opts: { full?: boolean }) => {
try {
const { importSkills } = await import('@skillbrain/storage')
const result = importSkills(targetPath)
const result = importSkills(targetPath, { prune: !!opts.full })
console.log(`✅ Import complete:`)
console.log(` Skills: ${result.skills}`)
console.log(` Agents: ${result.agents}`)
console.log(` Commands: ${result.commands}`)
if (opts.full) console.log(` Pruned (deprecated): ${result.pruned}`)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
console.error(`[codegraph] Import failed: ${msg}`)
Expand Down
18 changes: 16 additions & 2 deletions packages/codegraph/src/mcp/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ export function createSkillsRouter(ctx: RouteContext): Router {

// Health dashboard — MUST be declared before `/api/skills/:name`,
// otherwise Express matches "health" as the :name param and routes to detail.
router.get('/api/skills/health', (_req, res) => {
// Admin-gated (like the sibling write routes): the report exposes internal
// analytics — dead skills, confidence trends, cooccurrence — that shouldn't be
// world-readable. If a non-admin dashboard view needs it, swap for a lighter guard.
router.get('/api/skills/health', ctx.requireAdmin, (_req, res) => {
try {
const db = openDb(ctx.skillbrainRoot)
const store = new SkillsStore(db)
Expand Down Expand Up @@ -172,9 +175,20 @@ export function createSkillsRouter(ctx: RouteContext): Router {
}
})

// Telemetry: client-side Skill tool usage
// Telemetry: client-side Skill tool usage.
// localhost-only, and — because recording an 'applied' event calls
// reinforceSkill (confidence +1) which feeds ~22% of route() ranking weight —
// optionally token-gated so a co-tenant local process can't poison ranking.
// Enforced only when SKILLBRAIN_TELEMETRY_TOKEN is set (backward compatible).
router.post('/telemetry/skill-usage', json({ limit: '8kb' }), (req, res) => {
if (!ctx.isLocalhost(req)) { res.status(403).json({ error: 'localhost only' }); return }
const requiredToken = process.env.SKILLBRAIN_TELEMETRY_TOKEN
if (requiredToken) {
const auth = typeof req.headers.authorization === 'string' ? req.headers.authorization : ''
const provided = (typeof req.headers['x-telemetry-token'] === 'string' ? req.headers['x-telemetry-token'] : '')
|| auth.replace(/^Bearer\s+/i, '')
if (provided !== requiredToken) { res.status(401).json({ error: 'invalid telemetry token' }); return }
}
const { skill, action, sessionId, project, task, tool } = (req.body || {}) as {
skill?: string; action?: string; sessionId?: string
project?: string; task?: string; tool?: string
Expand Down
5 changes: 5 additions & 0 deletions packages/storage/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export function openDb(repoPath: string): Database.Database {
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.pragma('synchronous = NORMAL')
// Every MCP tool opens its own short-lived connection; the 24h decay job
// UPDATEs all active skills and can collide with concurrent recordUsage()
// writers. Without a busy_timeout that surfaces as SQLITE_BUSY with no retry —
// wait up to 5s for the lock instead.
db.pragma('busy_timeout = 5000')

runMigrations(db)

Expand Down
48 changes: 43 additions & 5 deletions packages/storage/src/import-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,25 @@ function walkDir(dir: string, callback: (file: string, name: string) => void): v
}
}

export function importSkills(workspacePath: string): { skills: number; agents: number; commands: number } {
export interface ImportSkillsOptions {
/**
* Full-sync mode. After upserting the filesystem catalog, soft-deprecate any
* ACTIVE skill that is no longer present in the discovered set — treating the
* filesystem bundle as the complete source of truth. Off by default because
* the additive path is safe for incremental/boot imports; enable only when you
* want the DB to exactly mirror the files (e.g. an intentional catalog sync).
*
* Protections: never touches System/Lifecycle categories, and only flips
* `active` rows (leaves `pending` drafts and already-`deprecated` rows alone).
* Reversible: sets status='deprecated', it does not delete.
*/
prune?: boolean
}

export function importSkills(
workspacePath: string,
opts: ImportSkillsOptions = {},
): { skills: number; agents: number; commands: number; pruned: number } {
const db = openDb(workspacePath)
const store = new SkillsStore(db)

Expand Down Expand Up @@ -348,10 +366,27 @@ export function importSkills(workspacePath: string): { skills: number; agents: n
// Batch insert
store.upsertBatch(skills)

// Optional full-sync: deprecate active skills that vanished from the bundle.
let pruned = 0
if (opts.prune) {
const discovered = new Set(skills.map((s) => s.name))
const activeNames = (db
.prepare(`SELECT name FROM skills WHERE status = 'active' AND category NOT IN ('System','Lifecycle')`)
.all() as { name: string }[]).map((r) => r.name)
const toPrune = activeNames.filter((n) => !discovered.has(n))
if (toPrune.length > 0) {
const nowIso = new Date().toISOString()
const upd = db.prepare(`UPDATE skills SET status = 'deprecated', updated_at = ? WHERE name = ?`)
const tx = db.transaction((names: string[]) => { for (const n of names) upd.run(nowIso, n) })
tx(toPrune)
pruned = toPrune.length
}
}

closeDb(db)

const domainCount = skills.filter((s) => s.type === 'domain').length
return { skills: domainCount, agents: agentCount, commands: commandCount }
return { skills: domainCount, agents: agentCount, commands: commandCount, pruned }
}

function isLifecycleSkill(name: string): boolean {
Expand Down Expand Up @@ -411,11 +446,14 @@ function extractTags(name: string, content: string): string[] {

// CLI entry point
if (process.argv[1]?.endsWith('import-skills.js')) {
const workspace = process.argv[2] || process.cwd()
console.log(`Importing skills from: ${workspace}`)
const result = importSkills(workspace)
const args = process.argv.slice(2)
const prune = args.includes('--full') || args.includes('--prune')
const workspace = args.find((a) => !a.startsWith('--')) || process.cwd()
console.log(`Importing skills from: ${workspace}${prune ? ' (full-sync: prune enabled)' : ''}`)
const result = importSkills(workspace, { prune })
console.log(`✅ Import complete:`)
console.log(` Skills: ${result.skills}`)
console.log(` Agents: ${result.agents}`)
console.log(` Commands: ${result.commands}`)
if (prune) console.log(` Pruned (deprecated): ${result.pruned}`)
}
Loading
Loading