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
4 changes: 3 additions & 1 deletion .claude/skills/contributing-to-gittensory/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ All tools are metadata-only (no source upload). Run in this order:
testFiles}` → slopRisk 0–100 + band + findings.
4. `gittensory_lint_pr_text` — `{commitMessages[], prBody, linkedIssue}` → verdict
strong/adequate/weak + specific fixes.
5. `gittensory_predict_gate` — `{login, owner, repo, title, body, labels, linkedIssues}` → predicted
5. `gittensory_validate_config` — `{content, source?}` → normalized manifest fields,
warnings, and ok/warn/error status.
6. `gittensory_predict_gate` — `{login, owner, repo, title, body, labels, linkedIssues}` → predicted
conclusion + blockers + warnings + readiness score.

(Auth'd extras: `gittensory_preflight_pr` / `…_local_diff` for lane fit + collision + queue health.)
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ gittensory-mcp analyze-branch --login jsonbored --json
gittensory-mcp preflight --login jsonbored --json
gittensory-mcp review-pr --login jsonbored --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
gittensory-mcp lint-pr-text --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
gittensory-mcp validate-config --file ./.gittensory.yml --json
gittensory-mcp slop-risk --changed-file src/widget.ts:80:2 --description "Adds retry handling." --test-file test/unit/widget.test.ts --json
gittensory-mcp issue-slop --title "Add retry handling" --body "Widget reconnects fail without bounded retries." --json
gittensory-mcp agent plan --login jsonbored --json
Expand Down
53 changes: 53 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CLI_COMMAND_SPEC = {
preflight: [],
"review-pr": [],
"lint-pr-text": [],
"validate-config": [],
"slop-risk": [],
"issue-slop": [],
profile: ["list", "create", "switch", "remove"],
Expand Down Expand Up @@ -213,6 +214,11 @@ const lintPrTextShape = {
linkedIssue: z.number().int().positive().optional(),
};

const validateConfigShape = {
content: z.string().max(256 * 1024),
source: z.enum(["repo_file", "api_record", "none"]).optional(),
};

const checkSlopRiskShape = {
changedFiles: z
.array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() }))
Expand Down Expand Up @@ -431,6 +437,16 @@ server.registerTool(
async (input) => toolResult("Gittensory PR-text lint.", await apiPost("/v1/lint/pr-text", input)),
);

server.registerTool(
"gittensory_validate_config",
{
description:
"Parse and validate a .gittensory.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Metadata-only, no GitHub writes.",
inputSchema: validateConfigShape,
},
async (input) => toolResult("Gittensory manifest validation.", await apiPost("/v1/validate/focus-manifest", input)),
);

server.registerTool(
"gittensory_check_slop_risk",
{
Expand Down Expand Up @@ -1456,6 +1472,7 @@ async function runCli(args) {
if (command === "doctor") return doctor(options);
if (command === "init-client") return initClient(options);
if (command === "lint-pr-text") return lintPrTextCli(args.slice(1));
if (command === "validate-config") return validateConfigCli(args.slice(1));
if (command === "slop-risk") return slopRiskCli(args.slice(1));
if (command === "issue-slop") return issueSlopCli(args.slice(1));
if (command === "decision-pack") return decisionPackCli(options);
Expand Down Expand Up @@ -1615,6 +1632,41 @@ async function lintPrTextCli(args) {
for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`);
}

function printValidateConfigHelp() {
process.stdout.write(
[
"Usage: gittensory-mcp validate-config --file <path> [--source repo_file|api_record|none] [--json]",
"",
"Validate a .gittensory.yml manifest before pushing.",
"Mirrors the gittensory_validate_config MCP tool and POST /v1/validate/focus-manifest. No source upload.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

async function validateConfigCli(args) {
if (!args.length || args[0] === "--help" || args[0] === "help") return printValidateConfigHelp();
const options = parseOptions(args);
if (!options.file) throw new Error("Pass --file <path> to the manifest to validate.");
const content = readCliTextFile(options.file, "Manifest");
const source = options.source;
if (source !== undefined && !["repo_file", "api_record", "none"].includes(String(source))) {
throw new Error("--source must be one of: repo_file, api_record, none");
}
const payload = await apiPost("/v1/validate/focus-manifest", {
content,
...(source !== undefined ? { source } : {}),
});
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`Manifest validation: ${payload.status}\n`);
process.stdout.write(`present=${payload.present}\n`);
for (const warning of payload.warnings ?? []) process.stdout.write(`- ${warning}\n`);
}

function printSlopRiskHelp() {
process.stdout.write(
[
Expand Down Expand Up @@ -2096,6 +2148,7 @@ function printHelp() {
gittensory-mcp preflight --login <github-login> [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json]
gittensory-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
gittensory-mcp lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
gittensory-mcp validate-config --file <path> [--source repo_file|api_record|none] [--json]
gittensory-mcp slop-risk [--description <text>] [--description-file <path>] [--changed-file <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--json]
gittensory-mcp issue-slop [--title <text>] [--body <text>] [--body-file <path>] [--json]
gittensory-mcp agent plan --login <github-login> [--repo owner/repo] [--json]
Expand Down
16 changes: 15 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ import { buildPullRequestReviewability, type PullRequestReviewability } from "..
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { buildSlopAssessment, buildIssueSlopAssessment, SLOP_RUBRIC_MARKDOWN, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../services/maintainer-activation";
import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { loadGatePrecisionReport } from "../services/gate-precision";
Expand Down Expand Up @@ -452,6 +453,11 @@ const lintPrTextSchema = z.object({
linkedIssue: z.number().int().positive().optional(),
});

const validateFocusManifestSchema = z.object({
content: z.string().max(256 * 1024),
source: z.enum(["repo_file", "api_record", "none"]).optional(),
});

// Pure local-metadata slop self-checks (no repo data, no secrets) — mirror the gittensory_check_slop_risk /
// gittensory_check_issue_slop MCP tools so the npm package can offer the same agent-native self-check.
const slopRiskSchema = z.object({
Expand Down Expand Up @@ -2788,6 +2794,13 @@ export function createApp() {
return c.json(buildPrTextLint(parsed.data));
});

app.post("/v1/validate/focus-manifest", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = validateFocusManifestSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_validate_focus_manifest_request", issues: parsed.error.issues }, 400);
return c.json(buildFocusManifestValidation(parsed.data));
});

// Agent-native slop self-checks (#530/#533): pure local-metadata, mirroring the MCP tools of the same name.
app.post("/v1/lint/slop-risk", async (c) => {
const body = await c.req.json().catch(() => null);
Expand Down Expand Up @@ -5187,6 +5200,7 @@ const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context";
const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context";
const OPPORTUNITIES_FIND_PATH = "/v1/opportunities/find";
const LINT_PR_TEXT_PATH = "/v1/lint/pr-text";
const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest";
const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk";
const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop";
// Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly
Expand Down Expand Up @@ -5253,7 +5267,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoAgentPendingActionsPath(path)) return true; // list-only: requireRepoMaintainer; decision POSTs require server tokens
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === OPPORTUNITIES_FIND_PATH) return true;
if (path === LINT_PR_TEXT_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === LINT_PR_TEXT_PATH || path === VALIDATE_FOCUS_MANIFEST_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
// Contributor extension scope reaches only `/v1/extension/contributors/<login>/*`; the handler's
// requireContributorAccess then enforces actor === login (self-only).
Expand Down
32 changes: 32 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import {
} from "./local-write-tools";
import { TEST_FRAMEWORKS } from "../signals/test-evidence";
import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag";
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
Expand Down Expand Up @@ -237,6 +238,11 @@ const lintPrTextShape = {
linkedIssue: z.number().int().positive().optional(),
};

const validateConfigShape = {
content: z.string().max(256 * 1024),
source: z.enum(["repo_file", "api_record", "none"]).optional(),
};

const preflightShape = {
repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars),
contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(),
Expand Down Expand Up @@ -1011,6 +1017,13 @@ const lintPrTextOutputSchema = {
summary: z.string().optional(),
generatedAt: z.string().optional(),
};

const validateConfigOutputSchema = {
present: z.boolean().optional(),
warnings: z.array(z.string()).optional(),
normalized: z.record(z.string(), z.unknown()).optional(),
status: z.enum(["ok", "warn", "error"]).optional(),
};
// #550: output schemas for the remaining tools (preflight/score/local-branch/agent), so MCP clients can
// machine-validate their results. Same lenient style as the schemas above — documented top-level keys,
// all optional, complex values as z.unknown(). No behavior change; these mirror the existing payloads.
Expand Down Expand Up @@ -1502,6 +1515,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(this.lintPrText(input)),
);

server.registerTool(
"gittensory_validate_config",
{
description:
"Parse and validate a .gittensory.yml manifest string using the same focus-manifest parser as the server. Returns normalized config fields, parse warnings, and an ok/warn/error status. Metadata-only, no GitHub writes.",
inputSchema: validateConfigShape,
outputSchema: validateConfigOutputSchema,
},
async (input) => this.toolResult(this.validateConfig(input)),
);

server.registerTool(
"gittensory_preflight_local_diff",
{
Expand Down Expand Up @@ -2260,6 +2284,14 @@ export class GittensoryMcp {
};
}

private validateConfig(input: { content: string; source?: "repo_file" | "api_record" | "none" | undefined }): ToolPayload {
const report = buildFocusManifestValidation(input);
return {
summary: `Gittensory manifest validation: ${report.status}.`,
data: report as unknown as Record<string, unknown>,
};
}

private async canAccessRepo(fullName: string): Promise<boolean> {
if (this.identity.kind === "session") return canLoginAccessRepo(this.env, this.identity.actor, fullName);
// The static `mcp` identity is a shared, end-user-obtainable CLI credential — scope it to the operator's
Expand Down
74 changes: 74 additions & 0 deletions src/services/focus-manifest-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
contentLaneConfigToJson,
featuresConfigToJson,
gateConfigToJson,
parseFocusManifestContent,
repoDocGenerationConfigToJson,
reviewConfigToJson,
reviewRecapConfigToJson,
settingsOverrideToJson,
type FocusManifest,
type FocusManifestSource,
} from "../signals/focus-manifest";

export type FocusManifestValidationStatus = "ok" | "warn" | "error";

export type FocusManifestValidationResult = {
present: boolean;
warnings: string[];
normalized: Record<string, unknown>;
status: FocusManifestValidationStatus;
};

const PARSE_FAILURE_PATTERN = /not valid (JSON|YAML)|must be a mapping|exceeded \d+ bytes/i;

export function buildFocusManifestValidation(input: {
content: string;
source?: FocusManifestSource | undefined;
}): FocusManifestValidationResult {
const manifest = parseFocusManifestContent(input.content, input.source ?? "repo_file");
const warnings = [...manifest.warnings];
const normalized = focusManifestToNormalizedJson(manifest);
return {
present: manifest.present,
warnings,
normalized,
status: resolveValidationStatus(manifest, warnings),
};
}

function resolveValidationStatus(manifest: FocusManifest, warnings: string[]): FocusManifestValidationStatus {
if (warnings.some((warning) => PARSE_FAILURE_PATTERN.test(warning))) return "error";
if (!manifest.present || warnings.length > 0) return "warn";
return "ok";
}

function focusManifestToNormalizedJson(manifest: FocusManifest): Record<string, unknown> {
const normalized: Record<string, unknown> = {
present: manifest.present,
source: manifest.source,
};
if (manifest.wantedPaths.length > 0) normalized.wantedPaths = manifest.wantedPaths;
if (manifest.preferredLabels.length > 0) normalized.preferredLabels = manifest.preferredLabels;
if (manifest.linkedIssuePolicy !== "optional") normalized.linkedIssuePolicy = manifest.linkedIssuePolicy;
if (manifest.testExpectations.length > 0) normalized.testExpectations = manifest.testExpectations;
if (manifest.issueDiscoveryPolicy !== "neutral") normalized.issueDiscoveryPolicy = manifest.issueDiscoveryPolicy;
if (manifest.publicNotes.length > 0) normalized.publicNotes = manifest.publicNotes;

const gate = gateConfigToJson(manifest.gate);
if (gate !== null) normalized.gate = gate;
const settings = settingsOverrideToJson(manifest.settings);
if (settings !== null) normalized.settings = settings;
const review = reviewConfigToJson(manifest.review);
if (review !== null) normalized.review = review;
const features = featuresConfigToJson(manifest.features);
if (features !== null) normalized.features = features;
const contentLane = contentLaneConfigToJson(manifest.contentLane);
if (contentLane !== null) normalized.contentLane = contentLane;
const repoDocGeneration = repoDocGenerationConfigToJson(manifest.repoDocGeneration);
if (repoDocGeneration !== null) normalized.repoDocGeneration = repoDocGeneration;
const reviewRecap = reviewRecapConfigToJson(manifest.reviewRecap);
if (reviewRecap !== null) normalized.reviewRecap = reviewRecap;

return normalized;
}
15 changes: 15 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,21 @@ describe("api routes", () => {
const invalidLintPrText = await app.request("/v1/lint/pr-text", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ linkedIssue: -1 }) }, env);
expect(invalidLintPrText.status).toBe(400);

const validateManifest = await app.request(
"/v1/validate/focus-manifest",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ content: "wantedPaths:\n - src/\n" }) },
env,
);
expect(validateManifest.status).toBe(200);
await expect(validateManifest.json()).resolves.toMatchObject({ status: "ok", present: true, warnings: [] });

const invalidValidateManifest = await app.request(
"/v1/validate/focus-manifest",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({ content: 123 }) },
env,
);
expect(invalidValidateManifest.status).toBe(400);

const invalidFindOpportunities = await app.request(
"/v1/opportunities/find",
{ method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) },
Expand Down
Loading