diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 4d2d28e96e..23e6a689a7 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -129,7 +129,7 @@ const CLI_COMMAND_SPEC = { profile: ["list", "create", "switch", "remove"], cache: ["status", "clear", "list"], agent: ["plan", "status", "explain", "packet"], - maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"], + maintain: ["status", "queue", "propose", "approve", "reject", "pause", "resume", "set-level", "precision", "selftune-audit", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs", "generate-issue-drafts", "plan-issues"], }; const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"]; const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"]; @@ -943,6 +943,13 @@ const gatePrecisionShape = { windowDays: z.number().int().positive().optional(), }; +// #7798: mirrors remote loopover_get_selftune_override_audit — optional positive limit for ?limit=. +const selftuneOverrideAuditShape = { + owner: z.string().min(1), + repo: z.string().min(1), + limit: z.number().int().positive().optional(), +}; + // #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape), // minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose -- // forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer @@ -1385,6 +1392,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "maintainer", description: "Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged counts and false-positive rates with low-sample guards. Optionally bounded by windowDays. Maintainer-authenticated; measurement only.", }, + { + name: "loopover_get_selftune_override_audit", + category: "maintainer", + description: + "Return the self-tune override audit trail for a repo: why LOOPOVER_REVIEW_SELFTUNE promoted or applied a live override. Optionally capped by limit. Same as `loopover-mcp maintain selftune-audit`. Maintainer-authenticated; read-only.", + }, { name: "loopover_plan_repo_issues", category: "maintainer", @@ -2809,7 +2822,22 @@ registerStdioTool( const payload = await apiGet(`${toolRepoBase(owner, repo)}/gate-precision${query}`); return toolResult(`Gate precision for ${owner}/${repo}.`, payload); }, - ); +); + +registerStdioTool( + "loopover_get_selftune_override_audit", + { + description: stdioToolDescription("loopover_get_selftune_override_audit"), + inputSchema: selftuneOverrideAuditShape, + }, + async ({ owner, repo, limit }: any) => { + // #7798: proxies GET {repoBase}/selftune/overrides/audit. Schema rejects non-positive limit; omit ?limit + // when absent so the route applies its own default (service default 50). + const query = limit ? `?limit=${encodeURIComponent(limit)}` : ""; + const payload = await apiGet(`${toolRepoBase(owner, repo)}/selftune/overrides/audit${query}`); + return toolResult(`Self-tune override audit for ${owner}/${repo}.`, payload); + }, +); registerStdioTool( "loopover_plan_repo_issues", @@ -3426,6 +3454,7 @@ function printMaintainHelp() { ` actions: ${MAINTAIN_ACTION_CLASSES.join(", ")}`, ` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`, " precision [--window-days N] Show gate false-positive telemetry (blocked-then-merged per gate type).", + " selftune-audit [--limit N] Show the self-tune override audit trail (promotions/applies).", " outcome-calibration Show slop-band merge rates and recommendation-outcome calibration.", " [--window-days N] Bound the recommendation window (default: full history).", " onboarding-pack [--refresh] Preview the repo's contributor onboarding pack.", @@ -3566,6 +3595,23 @@ export async function maintainCli(args: any) { emit(payload, lines.join("\n")); return; } + if (subcommand === "selftune-audit") { + // #7798: read-only mirror of GET {repoBase}/selftune/overrides/audit (same surface as the remote + // loopover_get_selftune_override_audit tool). Optional --limit mirrors the route's ?limit (a non-positive + // value falls through to the service default server-side). Same emit/--json handling as precision. + const limit = Number(options.limit); + const query = limit > 0 ? `?limit=${encodeURIComponent(limit)}` : ""; + const payload = await apiGet(`${repoBase}/selftune/overrides/audit${query}`); + const audit = payload.audit ?? []; + const lines = [ + `Self-tune override audit for ${repoFullName}: ${audit.length} event(s).`, + ...audit.map((entry: any) => + sanitizePlainTextTerminalOutput([entry.createdAt, entry.eventType, entry.detail].filter(Boolean).join(" ")), + ), + ]; + emit(payload, lines.join("\n")); + return; + } if (subcommand === "outcome-calibration") { // #6735 outcome calibration: read-only measurement of whether higher-slop bands merge less often and how // agent recommendations panned out. Same --window-days handling the sibling precision command uses (a @@ -3702,7 +3748,7 @@ export async function maintainCli(args: any) { return; } throw new Error( - `Unknown maintain subcommand: ${subcommand}. Use status | queue | propose | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`, + `Unknown maintain subcommand: ${subcommand}. Use status | queue | propose | approve | reject | pause | resume | set-level | precision | selftune-audit | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.`, ); } @@ -4904,7 +4950,7 @@ function printHelp() { loopover-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] loopover-mcp cache status|list|clear [--json] loopover-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] - loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`) + loopover-mcp maintain status|queue|approve|reject|pause|resume|set-level|precision|selftune-audit|outcome-calibration|onboarding-pack|audit-feed|automation-state|refresh-docs|generate-issue-drafts --repo owner/repo [--json] (see \`loopover-mcp maintain --help\`) loopover-mcp decision-pack --login [--json] loopover-mcp repo-decision --login --repo owner/repo [--json] loopover-mcp contributor-profile [--login ] [--json] diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 074e749481..4b13c77f00 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -118,6 +118,7 @@ import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/mai import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { buildRegistrationReadinessResponse, buildGittensorConfigRecommendationResponse } from "../api/routes"; import { loadGatePrecisionReport } from "../services/gate-precision"; +import { listOverrideAudit, type StorageEnv as AutoApplyStorageEnv } from "../review/auto-apply"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { applyMcpPlanningChoices, @@ -230,6 +231,13 @@ const ownerRepoWindowShape = { windowDays: z.number().int().positive().optional(), }; +// #7798 - optional `limit` matches GET .../selftune/overrides/audit?limit= (positive only; omitted → service default). +const ownerRepoLimitShape = { + owner: z.string().min(1), + repo: z.string().min(1), + limit: z.number().int().positive().optional(), +}; + const windowOnlyShape = { windowDays: z.number().int().positive().optional(), }; @@ -1052,6 +1060,12 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #7798 - self-tune override audit trail (mirrors GET .../selftune/overrides/audit). +const selftuneOverrideAuditOutputSchema = { + repoFullName: z.string().optional(), + audit: z.array(z.unknown()).optional(), +}; + // #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's // filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape // here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller @@ -1897,6 +1911,7 @@ export const MCP_TOOL_CATEGORIES: Record = { loopover_get_repo_outcome_patterns: "maintainer", loopover_get_outcome_calibration: "maintainer", loopover_get_gate_precision: "maintainer", + loopover_get_selftune_override_audit: "maintainer", loopover_get_skipped_pr_audit: "maintainer", loopover_get_fleet_analytics: "maintainer", loopover_get_recommendation_quality: "maintainer", @@ -2152,6 +2167,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + register( + "loopover_get_selftune_override_audit", + { + description: + "Return the self-tune override audit trail for a repo: why LOOPOVER_REVIEW_SELFTUNE promoted or applied a live override (event type, detail, timestamp). Optionally capped by limit. Maintainer-authenticated; read-only measurement.", + inputSchema: ownerRepoLimitShape, + outputSchema: selftuneOverrideAuditOutputSchema, + }, + async (input) => this.toolResult(await this.getSelftuneOverrideAudit(input)), + ); + register( "loopover_get_skipped_pr_audit", { @@ -3834,6 +3860,20 @@ export class LoopoverMcp { }; } + // #7798 - surface GET .../selftune/overrides/audit over MCP. Same per-repo read gate as getGatePrecision + // (requireRepoAccess); listOverrideAudit is the same service the REST route calls. Optional limit is + // forwarded when present; when omitted, listOverrideAudit's default (50) applies — matching the route + // when ?limit is missing. + private async getSelftuneOverrideAudit(input: { owner: string; repo: string; limit?: number | undefined }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const audit = await listOverrideAudit(this.env as unknown as AutoApplyStorageEnv, fullName, input.limit); + return { + summary: `LoopOver self-tune override audit for ${fullName}: ${audit.length} event(s).`, + data: { repoFullName: fullName, audit }, + }; + } + // #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in // src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls, // same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback), diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index dbc26aa2d4..a074490dd0 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5532,6 +5532,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_gate_config_effective"); expect(toolNames).toContain("loopover_get_ams_miner_cohort"); expect(toolNames).toContain("loopover_get_repo_focus_manifest"); + expect(toolNames).toContain("loopover_get_selftune_override_audit"); expect(toolNames).toContain("loopover_get_pr_maintainer_packet"); expect(toolNames).toContain("loopover_explain_review_risk"); expect(toolNames).toContain("loopover_compare_pr_variants"); @@ -5811,6 +5812,7 @@ describe("api routes", () => { ["loopover_get_gate_config_effective", { owner: "entrius", repo: "allways-ui" }], ["loopover_get_ams_miner_cohort", { owner: "entrius", repo: "allways-ui" }], ["loopover_get_repo_focus_manifest", { owner: "entrius", repo: "allways-ui" }], + ["loopover_get_selftune_override_audit", { owner: "entrius", repo: "allways-ui" }], ["loopover_get_pr_maintainer_packet", { owner: "entrius", repo: "allways-ui", number: 12 }], [ "loopover_preview_local_pr_score", diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 49dfa93de3..db78195ab3 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => { expect(ps).toContain("[System.Management.Automation.CompletionResult]::new"); expect(ps).toContain("$commands = @('login', 'logout'"); expect(ps).toContain( - "'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts', 'plan-issues')", + "'maintain' = @('status', 'queue', 'propose', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'selftune-audit', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs', 'generate-issue-drafts', 'plan-issues')", ); }); diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index a9b3656415..7bdc95f0ff 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -95,6 +95,20 @@ describe("loopover-mcp CLI — maintain (#784)", () => { expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/); }); + it("selftune-audit reports the override audit trail (plain + json), passing --limit through (#7798)", async () => { + const e = await env(); + const out = await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo"], e); + expect(out).toMatch(/Self-tune override audit for owner\/repo: 3 event\(s\)\./); + expect(out).toMatch(/override_promoted/); + expect(out).toMatch(/override_shadowed/); + const json = JSON.parse(await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--json"], e)) as { + audit: Array<{ eventType: string }>; + }; + expect(json.audit).toHaveLength(3); + const limited = await runAsync(["maintain", "selftune-audit", "--repo", "owner/repo", "--limit", "1"], e); + expect(limited).toMatch(/Self-tune override audit for owner\/repo: 1 event\(s\)\./); + }); + it("generate-issue-drafts dry-runs by default and never forwards create (#6757)", async () => { const bodies: Array<{ dryRun?: boolean; create?: boolean; limit?: number }> = []; const e = await env({ onIssueDraftRequest: (b) => bodies.push(b) }); diff --git a/test/unit/mcp-cli-selftune-override-audit.test.ts b/test/unit/mcp-cli-selftune-override-audit.test.ts new file mode 100644 index 0000000000..c2971afaa5 --- /dev/null +++ b/test/unit/mcp-cli-selftune-override-audit.test.ts @@ -0,0 +1,87 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +// #7798: in-process coverage for the loopover_get_selftune_override_audit stdio tool. +const MODULES = ["../../packages/loopover-mcp/bin/loopover-mcp.ts"] as const; + +type BinModule = { + server: { connect: (transport: unknown) => Promise }; +}; + +let tempDir = ""; +const capturedRequests: Array<{ url: string; method: string }> = []; +const loaded = new Map(); + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-selftune-audit-")); + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/selftune/overrides/audit")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + process.env.LOOPOVER_API_URL = apiUrl; + process.env.LOOPOVER_API_TOKEN = "in-process-token"; + process.env.LOOPOVER_API_TIMEOUT_MS = "2000"; + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + for (const specifier of MODULES) { + loaded.set(specifier, (await import(specifier)) as unknown as BinModule); + } +}, 120_000); + +afterAll(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_API_URL; + delete process.env.LOOPOVER_API_TOKEN; + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +describe("bin loopover_get_selftune_override_audit stdio tool (in-process, #7798)", () => { + it.each(MODULES)("registers and proxies GET .../selftune/overrides/audit — %s", async (specifier) => { + capturedRequests.length = 0; + const mod = loaded.get(specifier)!; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mod.server.connect(serverTransport); + const client = new Client({ name: "selftune-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + try { + const { tools } = await client.listTools(); + const tool = tools.find((entry) => entry.name === "loopover_get_selftune_override_audit"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/self-tune override audit/i); + + const unlimited = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + expect(capturedRequests[0]!.url).toContain("/v1/repos/owner/repo/selftune/overrides/audit"); + expect(capturedRequests[0]!.url).not.toContain("limit="); + expect(capturedRequests[0]!.method).toBe("GET"); + expect(unlimited.isError).toBeFalsy(); + expect(JSON.stringify(unlimited)).toContain("override_promoted"); + + capturedRequests.length = 0; + const limited = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "repo", limit: 1 }, + }); + expect(capturedRequests.length).toBe(1); + expect(capturedRequests[0]!.url).toContain("limit=1"); + expect(limited.isError).toBeFalsy(); + const data = limited.structuredContent as { audit: unknown[] }; + expect(data.audit).toHaveLength(1); + } finally { + await client.close().catch(() => undefined); + } + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index c7aaf7cd9b..611144aed3 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -48,6 +48,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_eligibility_plan", "loopover_simulate_open_pr_pressure", "loopover_get_gate_precision", + "loopover_get_selftune_override_audit", "loopover_get_skipped_pr_audit", ]; diff --git a/test/unit/mcp-selftune-override-audit.test.ts b/test/unit/mcp-selftune-override-audit.test.ts new file mode 100644 index 0000000000..c6c4cb33f2 --- /dev/null +++ b/test/unit/mcp-selftune-override-audit.test.ts @@ -0,0 +1,71 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { recordOverrideAudit, type StorageEnv } from "../../src/review/auto-apply"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; + +async function connect(env: Env) { + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "loopover-selftune-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedAudit(env: Env) { + const storage = env as unknown as StorageEnv; + await recordOverrideAudit(storage, REPO, "override_applied", { force: true }); + await recordOverrideAudit(storage, REPO, "override_shadowed", { reason: "queued" }); + await recordOverrideAudit(storage, REPO, "override_promoted", { reason: "soak_passed" }); +} + +describe("MCP loopover_get_selftune_override_audit (#7798)", () => { + it("returns the override audit trail for an authorized caller and passes limit through", async () => { + const env = createTestEnv(); + await seedAudit(env); + const client = await connect(env); + const result = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "widgets", limit: 2 }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { + repoFullName: string; + audit: Array<{ eventType: string; detail: string | null; createdAt: string }>; + }; + expect(data.repoFullName).toBe(REPO); + expect(data.audit).toHaveLength(2); + expect(data.audit.every((entry) => typeof entry.eventType === "string")).toBe(true); + expect(JSON.stringify(result.content)).toContain("2 event(s)"); + }); + + it("returns an empty audit when no override events are recorded (no limit)", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "widgets" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; audit: unknown[] }; + expect(data.repoFullName).toBe(REPO); + expect(data.audit).toEqual([]); + expect(JSON.stringify(result.content)).toContain("0 event(s)"); + }); + + it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await seedAudit(env); + const client = await connect(env); + const result = await client.callTool({ + name: "loopover_get_selftune_override_audit", + arguments: { owner: "owner", repo: "widgets" }, + }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index cc89b6f112..5a553a00d6 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -31,6 +31,7 @@ // (#7800 registered the loopover_get_gate_config_effective remote+stdio tool, taking the count from 86 to 87.) // (#7797 registered the loopover_get_ams_miner_cohort remote+stdio tool, taking the count from 87 to 88.) // (#7808 registered the loopover_get_repo_focus_manifest remote+stdio tool, taking the count from 88 to 89.) +// (#7798 registered the loopover_get_selftune_override_audit remote+stdio tool, taking the count from 89 to 90.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -77,14 +78,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 89 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 90 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(89); + expect(primary.length).toBe(90); expect(legacy.length).toBe(0); - expect(names.length).toBe(89); + expect(names.length).toBe(90); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -96,14 +97,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 89-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 90-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(89); + expect(payload.count).toBe(90); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 1e5549dab8..0130d7772f 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -725,6 +725,18 @@ export async function startFixtureServer( ); return; } + // #7798 self-tune override audit trail (read-only). Echoes ?limit so the CLI/stdio pass-through is testable. + if (request.url?.startsWith("/v1/repos/owner/repo/selftune/overrides/audit") && request.method === "GET") { + const limitRaw = Number(new URL(request.url, "http://localhost").searchParams.get("limit")); + const all = [ + { eventType: "override_promoted", detail: "{\"reason\":\"soak_passed\"}", createdAt: "2026-06-02T00:00:00.000Z" }, + { eventType: "override_shadowed", detail: "{\"reason\":\"queued\"}", createdAt: "2026-06-01T00:00:00.000Z" }, + { eventType: "override_applied", detail: "{\"force\":true}", createdAt: "2026-05-30T00:00:00.000Z" }, + ]; + const audit = limitRaw > 0 ? all.slice(0, limitRaw) : all; + response.end(JSON.stringify({ repoFullName: "owner/repo", audit })); + return; + } if (request.url?.startsWith("/v1/repos/owner/repo/outcome-calibration") && request.method === "GET") { const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays"); response.end(