Skip to content
Open
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
2 changes: 1 addition & 1 deletion skills/RESOLVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "skill back into gbrain", "fork-only skill", "skill with the gbrain bundle", "share this skill with other gbrain clients", "harvest my skill into gbrain", "promote this skill to gbrain", "skill in the gbrain bundle", "custom skill into the gbrain core" | `skills/skillpack-harvest/SKILL.md` |
| Post-restart health + auto-fix, "did the container restart break anything", smoke test | `skills/smoke-test/SKILL.md` |
| Cross-modal review, second opinion | `skills/cross-modal-review/SKILL.md` |
| "Validate skills", skill health check | `skills/testing/SKILL.md` |
Expand Down
16 changes: 14 additions & 2 deletions src/commands/autopilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const score = health.brain_score;
const ctx = {
repoPath,
hasEmbeddingApiKey: !!(process.env.OPENAI_API_KEY || await engine.getConfig('openai_api_key')),
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || await engine.getConfig('anthropic_api_key')),
hasEmbeddingApiKey: await isGatewayTouchpointAvailable('embedding'),
hasChatApiKey: await isGatewayTouchpointAvailable('chat'),
};
const plan = computeRecommendations(health, ctx).filter((r) => r.status === 'remediable');
const estTotal = plan.reduce((s, r) => s + r.est_seconds, 0);
Expand Down Expand Up @@ -909,3 +909,15 @@ function showStatus(json: boolean) {
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

async function isGatewayTouchpointAvailable(touchpoint: 'embedding' | 'chat'): Promise<boolean> {
try {
// The CLI configures the gateway from the merged config plane before
// invoking autopilot. Direct env/DB key checks miss file-local credentials
// and non-OpenAI embedding providers.
const { isAvailable } = await import('../core/ai/gateway.ts');
return isAvailable(touchpoint);
} catch {
return false;
}
}
40 changes: 18 additions & 22 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3724,7 +3724,7 @@ export async function runRemediationPlan(
engine: BrainEngine,
args: string[],
): Promise<void> {
const { computeRecommendations, classifyChecks, maxReachableScore } =
const { computeRecommendations, classifyChecks, maxReachableScore, checksForRemediationContext } =
await import('../core/brain-score-recommendations.ts');

const targetScore = parseIntFlag(args, '--target-score') ?? 90;
Expand All @@ -3735,16 +3735,7 @@ export async function runRemediationPlan(
const health = await engine.getHealth();
const ctx = await loadRecommendationContext(engine);
const recs = computeRecommendations(health, ctx);
// Synthetic check list for classification — we don't need full doctor
// output, just the check names the recommendations care about.
const syntheticChecks = [
{ name: 'brain_score', status: 'ok' as const },
{ name: 'sync_freshness', status: 'ok' as const },
{ name: 'missing_embeddings', status: 'ok' as const },
{ name: 'dead_links', status: 'ok' as const },
{ name: 'orphan_pages', status: 'ok' as const },
];
const classifications = classifyChecks(syntheticChecks, ctx);
const classifications = classifyChecks(checksForRemediationContext(health), ctx);
const ceiling = maxReachableScore(health, classifications);

const filteredRecs = recs.filter((r) => r.status === 'remediable');
Expand Down Expand Up @@ -3816,21 +3807,14 @@ export async function runRemediate(
const skipConfirm = args.includes('--yes');
const jsonOutput = args.includes('--json');

const { computeRecommendations, classifyChecks, maxReachableScore } =
const { computeRecommendations, classifyChecks, maxReachableScore, checksForRemediationContext } =
await import('../core/brain-score-recommendations.ts');

const ctx = await loadRecommendationContext(engine);

// Pre-flight ceiling check (D13)
const initialHealth = await engine.getHealth();
const syntheticChecks = [
{ name: 'brain_score', status: 'ok' as const },
{ name: 'sync_freshness', status: 'ok' as const },
{ name: 'missing_embeddings', status: 'ok' as const },
{ name: 'dead_links', status: 'ok' as const },
{ name: 'orphan_pages', status: 'ok' as const },
];
const classifications = classifyChecks(syntheticChecks, ctx);
const classifications = classifyChecks(checksForRemediationContext(initialHealth), ctx);
const ceiling = maxReachableScore(initialHealth, classifications);
if (targetScore > ceiling) {
console.error(
Expand Down Expand Up @@ -3973,11 +3957,23 @@ async function loadRecommendationContext(engine: BrainEngine) {
repoPath: repoPath ?? undefined,
embeddingModel: embeddingModel ?? undefined,
embeddingDimensions: embeddingDimensions ? Number(embeddingDimensions) : undefined,
hasEmbeddingApiKey: !!(process.env.OPENAI_API_KEY || await engine.getConfig('openai_api_key')),
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || await engine.getConfig('anthropic_api_key')),
hasEmbeddingApiKey: await isGatewayTouchpointAvailable('embedding'),
hasChatApiKey: await isGatewayTouchpointAvailable('chat'),
};
}

async function isGatewayTouchpointAvailable(touchpoint: 'embedding' | 'chat'): Promise<boolean> {
try {
// connectEngine() configures the gateway from the merged config plane
// (env + ~/.gbrain/config.json + DB). Direct env/DB key checks miss
// file-local credentials and non-OpenAI embedding providers.
const { isAvailable } = await import('../core/ai/gateway.ts');
return isAvailable(touchpoint);
} catch {
return false;
}
}

function parseIntFlag(args: string[], flag: string): number | null {
const i = args.indexOf(flag);
if (i === -1 || i === args.length - 1) return null;
Expand Down
18 changes: 18 additions & 0 deletions src/core/brain-score-recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ export interface CheckClassification {
reason?: string;
}

/**
* Build the minimal check list used by doctor remediation surfaces.
*
* Only include deficit-specific checks when the health snapshot actually has
* that deficit. The previous caller-side synthetic list always included
* `missing_embeddings` and `dead_links`, which could surface blocked or
* remediable states for problems that were not present in the current brain
* health snapshot.
*/
export function checksForRemediationContext(health: BrainHealth): Check[] {
const checks: Check[] = [{ name: 'brain_score', status: 'ok' }];
if (health.stale_pages > 0) checks.push({ name: 'sync_freshness', status: 'warn' });
if (health.missing_embeddings > 0) checks.push({ name: 'missing_embeddings', status: 'warn' });
if (health.dead_links > 0) checks.push({ name: 'dead_links', status: 'warn' });
if (health.orphan_pages > 0) checks.push({ name: 'orphan_pages', status: 'warn' });
return checks;
}

/**
* Generate ordered Remediation list from health snapshot + context.
*
Expand Down
32 changes: 32 additions & 0 deletions test/brain-score-recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
classifyChecks,
maxReachableScore,
estimateAnthropicCost,
checksForRemediationContext,
} from '../src/core/brain-score-recommendations.ts';
import type { BrainHealth } from '../src/core/types.ts';

Expand Down Expand Up @@ -160,6 +161,37 @@ describe('computeRecommendations', () => {
});
});

describe('checksForRemediationContext', () => {
test('does not surface missing_embeddings when embedding coverage is complete', () => {
const health = makeHealth({
brain_score: 45,
missing_embeddings: 0,
dead_links: 0,
stale_pages: 0,
orphan_pages: 0,
});
const names = checksForRemediationContext(health).map((c) => c.name);
expect(names).toEqual(['brain_score']);
});

test('surfaces only active remediation deficits', () => {
const health = makeHealth({
stale_pages: 3,
missing_embeddings: 2,
dead_links: 1,
orphan_pages: 4,
});
const names = checksForRemediationContext(health).map((c) => c.name);
expect(names).toEqual([
'brain_score',
'sync_freshness',
'missing_embeddings',
'dead_links',
'orphan_pages',
]);
});
});

describe('classifyChecks (D13)', () => {
test('remediable: missing_embeddings with API key', () => {
const result = classifyChecks([{ name: 'missing_embeddings', status: 'fail' }], {
Expand Down