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
9 changes: 8 additions & 1 deletion src/agent/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import type { DocumentStore } from "../storage/store.js";
import type { SessionStore } from "../storage/session-store.js";
import type { SourceManifestManager } from "../sources/manifest.js";
import type { MarvinProjectConfig } from "../core/config.js";
import { createDecisionTools } from "./tools/decisions.js";
import { createActionTools } from "./tools/actions.js";
import { createQuestionTools } from "./tools/questions.js";
Expand All @@ -24,6 +25,8 @@ export interface McpServerOptions {
skillTools?: SdkMcpToolDefinition<any>[];
projectName?: string;
navGroups?: NavGroup[];
config?: MarvinProjectConfig;
marvinDir?: string;
}

export function createMarvinMcpServer(
Expand All @@ -42,7 +45,11 @@ export function createMarvinMcpServer(
...(options?.projectName && options?.navGroups
? createWebTools(store, options.projectName, options.navGroups)
: []),
...createDoctorTools(store),
...createDoctorTools(store, {
config: options?.config,
manifest: options?.manifest,
marvinDir: options?.marvinDir,
}),
];

return createSdkMcpServer({
Expand Down
4 changes: 4 additions & 0 deletions src/agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export async function startSession(options: SessionOptions): Promise<void> {
skillTools: codeSkillTools,
projectName: config.project.name,
navGroups,
config: config.project,
marvinDir,
});
const systemPrompt = buildSystemPrompt(
persona,
Expand Down Expand Up @@ -188,6 +190,8 @@ export async function startSession(options: SessionOptions): Promise<void> {
"mcp__marvin-governance__get_dashboard_board",
"mcp__marvin-governance__get_dashboard_upcoming",
"mcp__marvin-governance__get_dashboard_sprint_summary",
"mcp__marvin-governance__run_doctor",
"mcp__marvin-governance__check_project_health",
...pluginTools.map((t) => `mcp__marvin-governance__${t.name}`),
...codeSkillTools.map((t) => `mcp__marvin-governance__${t.name}`),
],
Expand Down
59 changes: 58 additions & 1 deletion src/agent/tools/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { z } from "zod/v4";
import { tool, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import type { DocumentStore } from "../../storage/store.js";
import type { SourceManifestManager } from "../../sources/manifest.js";
import type { MarvinProjectConfig } from "../../core/config.js";
import { runDoctorScan, runDoctorFix } from "../../doctor/engine.js";
import { runHealthCheck } from "../../doctor/health/engine.js";

export function createDoctorTools(store: DocumentStore): SdkMcpToolDefinition<any>[] {
export interface DoctorToolOptions {
config?: MarvinProjectConfig;
manifest?: SourceManifestManager;
marvinDir?: string;
}

export function createDoctorTools(
store: DocumentStore,
options?: DoctorToolOptions,
): SdkMcpToolDefinition<any>[] {
return [
tool(
"run_doctor",
Expand Down Expand Up @@ -48,5 +60,50 @@ export function createDoctorTools(store: DocumentStore): SdkMcpToolDefinition<an
}
},
),

tool(
"check_project_health",
"Run governance health checks on the project. Returns soft recommendations about missing setup (sprints, Jira, source processing) and phase readiness — not document-level issues (use run_doctor for those).",
{},
async () => {
if (!options?.config || !options?.marvinDir) {
return {
content: [
{
type: "text" as const,
text: "Health check unavailable: project config or marvinDir not initialized.",
},
],
isError: true,
};
}

const { config, marvinDir, manifest } = options;

try {
const report = runHealthCheck({ store, config, manifest, marvinDir });

return {
content: [
{
type: "text" as const,
text: JSON.stringify(report, null, 2),
},
],
};
} catch (err) {
return {
content: [
{
type: "text" as const,
text: `Health check error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
{ annotations: { readOnlyHint: true } },
),
];
}
5 changes: 5 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export interface JiraProjectConfig {
};
}

export interface AemConfig {
currentPhase?: string;
}

export interface MarvinProjectConfig {
name: string;
methodology?: string;
Expand All @@ -70,6 +74,7 @@ export interface MarvinProjectConfig {
git?: GitConfig;
skills?: Record<string, string[]>;
jira?: JiraProjectConfig;
aem?: AemConfig;
}

export interface PersonaConfigOverride {
Expand Down
47 changes: 47 additions & 0 deletions src/doctor/health/checks/empty-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { HealthCheck, HealthContext, HealthFinding } from "../types.js";

const CHECK_ID = "empty-project";
const CHECK_NAME = "Empty Project Detection";

/** Flags new projects with zero artifacts but source files ready for processing. */
export const emptyProjectCheck: HealthCheck = {
id: CHECK_ID,
name: CHECK_NAME,
description: "Detects new projects with no artifacts and suggests onboarding steps",

run(ctx: HealthContext): HealthFinding[] {
const counts = ctx.store.counts();
const totalArtifacts = Object.values(counts).reduce((sum, n) => sum + n, 0);

if (totalArtifacts > 0) return [];

const findings: HealthFinding[] = [];

// Check for unprocessed sources (manifest already scanned by engine)
if (ctx.manifest) {
const pending = ctx.manifest.list("pending");
if (pending.length > 0) {
findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: `Project has no artifacts yet, but ${pending.length} source file(s) are ready for processing.`,
suggestion:
"Set a persona with set_persona (e.g. 'po'), then ingest the source files to generate initial artifacts.",
});
return findings;
}
}

findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "observation",
message: "Project is empty — no artifacts or source files found.",
suggestion:
"Start by placing source documents in .marvin/sources/ or create your first artifact with set_persona.",
});

return findings;
},
};
17 changes: 17 additions & 0 deletions src/doctor/health/checks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { HealthCheck } from "../types.js";
import { emptyProjectCheck } from "./empty-project.js";
import { unprocessedSourcesCheck } from "./unprocessed-sources.js";
import { noSprintsCheck } from "./no-sprints.js";
import { unassignedActionsCheck } from "./unassigned-actions.js";
import { noJiraProjectCheck } from "./no-jira-project.js";
import { phaseReadinessCheck } from "./phase-readiness.js";

/** Health checks in priority order — most actionable first. */
export const allHealthChecks: HealthCheck[] = [
emptyProjectCheck,
unprocessedSourcesCheck,
noSprintsCheck,
unassignedActionsCheck,
noJiraProjectCheck,
phaseReadinessCheck,
];
30 changes: 30 additions & 0 deletions src/doctor/health/checks/no-jira-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { HealthCheck, HealthContext, HealthFinding } from "../types.js";

const CHECK_ID = "no-jira-project";
const CHECK_NAME = "Jira Project Not Configured";

/** Flags projects with artifacts but no Jira project key configured. */
export const noJiraProjectCheck: HealthCheck = {
id: CHECK_ID,
name: CHECK_NAME,
description: "Detects projects with artifacts but no Jira integration configured",

run(ctx: HealthContext): HealthFinding[] {
const counts = ctx.store.counts();
const totalArtifacts = Object.values(counts).reduce((sum, n) => sum + n, 0);
if (totalArtifacts === 0) return [];

if (ctx.config.jira?.projectKey?.trim()) return [];

return [
{
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "observation",
message: "No Jira project key configured for this project.",
suggestion:
"Add a jira.projectKey to .marvin/config.yaml to enable Jira sync and artifact push.",
},
];
},
};
38 changes: 38 additions & 0 deletions src/doctor/health/checks/no-sprints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { HealthCheck, HealthContext, HealthFinding } from "../types.js";

const CHECK_ID = "no-sprints";
const CHECK_NAME = "Missing Sprint Setup";

/** Flags projects that have actionable work but no sprints defined. */
export const noSprintsCheck: HealthCheck = {
id: CHECK_ID,
name: CHECK_NAME,
description: "Detects projects with actions or epics but no sprints defined",

run(ctx: HealthContext): HealthFinding[] {
const counts = ctx.store.counts();

const hasWork = (counts["action"] ?? 0) > 0 || (counts["epic"] ?? 0) > 0;
if (!hasWork) return [];

const hasSprints = (counts["sprint"] ?? 0) > 0;
if (hasSprints) return [];

const actionCount = counts["action"] ?? 0;
const epicCount = counts["epic"] ?? 0;
const parts: string[] = [];
if (actionCount > 0) parts.push(`${actionCount} action(s)`);
if (epicCount > 0) parts.push(`${epicCount} epic(s)`);

return [
{
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: `Project has ${parts.join(" and ")} but no sprints defined.`,
suggestion:
"Consider setting up a Sprint 0 to organize bootstrapping work, then plan Sprint 1 for delivery.",
},
];
},
};
82 changes: 82 additions & 0 deletions src/doctor/health/checks/phase-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { HealthCheck, HealthContext, HealthFinding } from "../types.js";

const CHECK_ID = "phase-readiness";
const CHECK_NAME = "AEM Phase Readiness";

/** Checks AEM phase gate prerequisites for the current phase. */
export const phaseReadinessCheck: HealthCheck = {
id: CHECK_ID,
name: CHECK_NAME,
description: "Checks whether AEM phase gate prerequisites are met for the current phase",

run(ctx: HealthContext): HealthFinding[] {
if (ctx.config.methodology !== "sap-aem") return [];

const phase = ctx.config.aem?.currentPhase;
if (!phase) return [];

const findings: HealthFinding[] = [];

if (phase === "assess-use-case") {
const useCases = ctx.store.list({ type: "use-case" });
if (useCases.length === 0) {
findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: "Phase 1 (Assess Use Case) is active but no use cases have been defined.",
suggestion: "Use the PO persona to create use cases before advancing to Phase 2.",
});
} else {
const drafts = useCases.filter((uc) => uc.frontmatter.status === "draft");
if (drafts.length === useCases.length) {
findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "observation",
message: `All ${drafts.length} use case(s) are still in draft status.`,
suggestion:
"Assess and approve use cases before advancing to Phase 2 (Assess Technology).",
});
}
}
}

if (phase === "assess-technology") {
const tas = ctx.store.list({ type: "tech-assessment" });
const approvedUCs = ctx.store
.list({ type: "use-case" })
.filter((uc) => uc.frontmatter.status === "approved");

if (approvedUCs.length > 0 && tas.length === 0) {
findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: `Phase 2 is active with ${approvedUCs.length} approved use case(s) but no tech assessments created.`,
suggestion: "Use the TL persona to create tech assessments linked to approved use cases.",
});
}
}

if (phase === "define-solution") {
const designs = ctx.store.list({ type: "extension-design" });
const recommendedTAs = ctx.store
.list({ type: "tech-assessment" })
.filter((ta) => ta.frontmatter.status === "recommended");

if (recommendedTAs.length > 0 && designs.length === 0) {
findings.push({
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: `Phase 3 is active with ${recommendedTAs.length} recommended tech assessment(s) but no extension designs created.`,
suggestion:
"Use the TL persona to create extension designs linked to recommended assessments.",
});
}
}

return findings;
},
};
37 changes: 37 additions & 0 deletions src/doctor/health/checks/unassigned-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { HealthCheck, HealthContext, HealthFinding } from "../types.js";

const CHECK_ID = "unassigned-actions";
const CHECK_NAME = "Actions Without Sprint";

/** Flags open actions that are not assigned to any sprint. */
export const unassignedActionsCheck: HealthCheck = {
id: CHECK_ID,
name: CHECK_NAME,
description: "Detects open actions not assigned to a sprint",

run(ctx: HealthContext): HealthFinding[] {
const counts = ctx.store.counts();
if ((counts["sprint"] ?? 0) === 0) return []; // no-sprints check covers this

const actions = ctx.store.list({ type: "action" });
const openActions = actions.filter(
(a) => a.frontmatter.status === "open" || a.frontmatter.status === "in-progress",
);
if (openActions.length === 0) return [];

const unassigned = openActions.filter(
(a) => !a.frontmatter.sprint && !a.frontmatter.tags?.some((t) => t.startsWith("sprint:")),
);
if (unassigned.length === 0) return [];

return [
{
checkId: CHECK_ID,
checkName: CHECK_NAME,
severity: "recommendation",
message: `${unassigned.length} open action(s) are not assigned to any sprint.`,
suggestion: "Assign these actions to a sprint so they appear in delivery tracking.",
},
];
},
};
Loading
Loading