diff --git a/packages/gittensory-mcp/CHANGELOG.md b/packages/gittensory-mcp/CHANGELOG.md index 383c1146af..24ca8163b6 100644 --- a/packages/gittensory-mcp/CHANGELOG.md +++ b/packages/gittensory-mcp/CHANGELOG.md @@ -5,7 +5,9 @@ ### Features -- Detect stale installs and API compatibility in doctor and status +- Detect stale installs and API compatibility in doctor and status (#28) + +- Generate public-safe pr packets ## mcp-v0.2.0 - 2026-05-28 diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 0163f00d00..affa10cb7a 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -552,6 +552,11 @@ function outputAgentPayload(payload, options, summary) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return; } + const packetMarkdown = payload?.prPacket?.markdown ?? payload?.actions?.find((action) => action?.actionType === "prepare_pr_packet")?.payload?.prPacket?.markdown; + if (typeof packetMarkdown === "string" && packetMarkdown.trim()) { + const safeMarkdown = requirePublicSafePacketMarkdown(packetMarkdown); + return process.stdout.write(safeMarkdown.endsWith("\n") ? safeMarkdown : `${safeMarkdown}\n`); + } process.stdout.write(`${summary}\n`); const actions = payload.actions ?? []; for (const action of actions.slice(0, 3)) { @@ -560,6 +565,16 @@ function outputAgentPayload(payload, options, summary) { } } +function requirePublicSafePacketMarkdown(markdown) { + const unsafeLine = markdown.split(/\r?\n/).find((line) => isUnsafePublicPacketText(line)); + if (unsafeLine) throw new Error("Refusing to print unsafe public packet markdown from the server."); + return markdown; +} + +function isUnsafePublicPacketText(value) { + return /\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-\s]?trust|trust score|private[-\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Z]:\\Users\\/i.test(value); +} + function printHelp() { process.stdout.write(`Usage: gittensory-mcp --stdio diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 94f047e90f..12b5235c25 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1262,6 +1262,7 @@ export const LocalBranchAnalysisSchema = z }), prPacket: z.object({ titleSuggestion: z.string(), + markdown: z.string(), bodySections: z.array(z.object({ heading: z.string(), lines: z.array(z.string()) })), reviewerNotes: z.array(z.string()), validationSummary: z.object({ diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 4123d5f972..1a0bf7f8d8 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -120,6 +120,7 @@ export type LocalBranchAnalysis = { }; prPacket: { titleSuggestion: string; + markdown: string; bodySections: Array<{ heading: string; lines: string[] }>; reviewerNotes: string[]; validationSummary: { @@ -238,6 +239,8 @@ export function buildLocalBranchAnalysis(args: { roleContext, laneSummary: lane.summary, localFindings, + baseFreshness, + recommendedRerunCondition, }); const scoreBlockers = [ ...rewardRisk.scoreBlockers, @@ -499,8 +502,10 @@ function buildPublicSafePrPacket(args: { roleContext: RoleContext; laneSummary: string; localFindings: LocalBranchAnalysis["localFindings"]; + baseFreshness: LocalBranchAnalysis["baseFreshness"]; + recommendedRerunCondition: string; }): LocalBranchAnalysis["prPacket"] { - const topPaths = args.changedFiles.slice(0, 8).map((file) => file.path); + const topPaths = args.changedFiles.slice(0, 8).map(changedFileSummary); const publicSafeWarnings = [ ...(args.roleContext.maintainerLane ? ["This is maintainer-lane context; present it as repo stewardship work."] : []), ...args.preflight.findings @@ -510,13 +515,14 @@ function buildPublicSafePrPacket(args: { .filter((finding) => finding.code !== "score_preview_warning" && finding.severity === "warning") .flatMap((finding) => (finding.action ? [finding.action] : [finding.title])), ].filter(isPublicSafeText); + const nextSteps = [...publicSafeWarnings, args.baseFreshness.recommendation, args.recommendedRerunCondition, "Keep source upload disabled; this packet is based on local git metadata only."].filter( + (line): line is string => Boolean(line && isPublicSafeText(line)), + ); const validationLines = args.validationSummary.commands.length > 0 ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.summary ? ` (${entry.summary})` : ""}`) : ["- Not supplied yet."]; - return { - titleSuggestion: args.title, - bodySections: [ + const bodySections = [ { heading: "Summary", lines: ["Describe the user-visible problem or maintainer-facing improvement this branch addresses."], @@ -525,6 +531,8 @@ function buildPublicSafePrPacket(args: { heading: "Linked Context", lines: args.preflight.linkedIssues.length > 0 ? args.preflight.linkedIssues.map((issue) => `- Closes #${issue}`) : ["- No linked issue detected; explain why this is a no-issue PR."], }, + { heading: "Branch Freshness", lines: branchFreshnessLines(args.baseFreshness) }, + { heading: "Overlap/WIP Check", lines: overlapCautionLines(args.preflight.collisions) }, { heading: "Changed Paths", lines: topPaths.length > 0 ? topPaths.map((path) => `- ${path}`) : ["- No changed paths were detected from local metadata."], @@ -533,7 +541,12 @@ function buildPublicSafePrPacket(args: { heading: "Validation", lines: validationLines, }, - ], + { heading: "Next Steps", lines: [...new Set(nextSteps)].slice(0, 6).map((line) => `- ${line.replace(/^- /, "")}`) }, + ]; + return { + titleSuggestion: args.title, + markdown: renderPrPacketMarkdown(args.title, bodySections), + bodySections, reviewerNotes: [ `Lane context: ${args.laneSummary}`, `Review burden: ${args.preflight.reviewBurden}`, @@ -544,6 +557,26 @@ function buildPublicSafePrPacket(args: { }; } +function branchFreshnessLines(freshness: LocalBranchAnalysis["baseFreshness"]): string[] { + return [`- Base freshness: ${freshness.status}.`, ...freshness.warnings.filter(isPublicSafeText).map((warning) => `- ${warning}`), freshness.passedValidationCount > 0 ? `- Validation evidence supplied: ${freshness.passedValidationCount} passed command(s).` : "- No passed validation evidence was supplied."]; +} + +function overlapCautionLines(collisions: LocalDiffPreflightResult["collisions"]): string[] { + if (collisions.length === 0) return ["- No active overlap or WIP was detected from cached issue/PR metadata."]; + return collisions + .slice(0, 3) + .map((cluster) => `- Possible overlap or WIP (${cluster.risk}): ${cluster.reason} Check ${cluster.items.slice(0, 3).map((item) => `${item.type === "pull_request" ? "PR" : item.type === "issue" ? "issue" : "merged PR"} #${item.number}`).join(", ")} before posting.`) + .filter(isPublicSafeText); +} + +function changedFileSummary(file: LocalBranchChangedFile): string { + return `${file.previousPath ? `${safeRepoPath(file.previousPath)} -> ${safeRepoPath(file.path)}` : safeRepoPath(file.path)} (${file.status ?? "modified"}, ${file.binary ? "binary" : `+${nonNegative(file.additions)}/-${nonNegative(file.deletions)}`})`; +} + +function renderPrPacketMarkdown(title: string, sections: Array<{ heading: string; lines: string[] }>): string { + return `${[`# ${title}`, ...sections.flatMap((section) => ["", `## ${section.heading}`, ...section.lines])].filter(isPublicSafeText).join("\n").trim()}\n`; +} + function summarizeValidation(validation: LocalBranchValidation[]): LocalBranchAnalysis["prPacket"]["validationSummary"] { return { passed: validation.filter((entry) => entry.status === "passed").length, @@ -569,7 +602,11 @@ function firstCommitTitle(messages: string[] | undefined): string | undefined { } function isPublicSafeText(text: string): boolean { - return !/\b(reward|score|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|trust score)\b/i.test(text); + return !/\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-\s]?trust|trust score|private[-\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Z]:\\Users\\/i.test(text); +} + +function safeRepoPath(path: string): string { + return /^(\/Users\/|\/home\/|\/tmp\/|[A-Z]:\/Users\/)/i.test(String(path).replace(/\\/g, "/")) ? "[local path hidden]" : String(path || "(unknown path)").replace(/\\/g, "/"); } function isTestFile(file: string): boolean { diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 4ba6a7b9bd..970bf0fc46 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -47,6 +47,11 @@ describe("local branch analysis", () => { expect(analysis.rewardRisk.rewardUpside.relevantLane).toBe("direct_pr"); expect(analysis.nextActions.map((action) => action.actionKind)).toContain("open_new_direct_pr"); expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "source_upload_disabled" })])); + expect(analysis.prPacket.markdown).toContain("## Branch Freshness"); + expect(analysis.prPacket.markdown).toContain("## Overlap/WIP Check"); + expect(analysis.prPacket.markdown).toContain("- Closes #7"); + expect(analysis.prPacket.markdown).toContain("- passed: npm test -- cache"); + expect(analysis.prPacket.markdown).toContain("metadata only"); expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); }); @@ -135,11 +140,55 @@ describe("local branch analysis", () => { expect(analysis.baseFreshness.status).toBe("stale"); expect(analysis.baseFreshness.warnings.join(" ")).toMatch(/behind remote tracking SHA/i); expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "stale_base_ref" })])); + expect(analysis.prPacket.markdown).toContain("## Branch Freshness"); + expect(analysis.prPacket.markdown).toMatch(/Base freshness: stale|git fetch origin/i); expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("missing_test_evidence"); expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("local_diff_missing_tests"); expect(analysis.recommendedRerunCondition).toMatch(/git fetch origin/i); }); + it("includes public-safe overlap caution and hides local absolute paths", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [ + { path: "/Users/example/work/src/cache.ts", previousPath: "src/cache-old.ts", additions: 12, deletions: 2, status: "renamed" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed" }], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [12] }], + pullRequests: [ + { + repoFullName: repo.fullName, + number: 12, + title: "Fix cache refresh", + state: "open", + authorLogin: "someone-else", + authorAssociation: "CONTRIBUTOR", + labels: ["bug"], + linkedIssues: [7], + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + ], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.preflight.status).toBe("needs_work"); + expect(analysis.prPacket.markdown).toContain("Possible overlap or WIP"); + expect(analysis.prPacket.markdown).toContain("PR #12"); + expect(analysis.prPacket.markdown).toContain("[local path hidden]"); + expect(analysis.prPacket.markdown).not.toContain("/Users/example"); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score|\/Users\/example/i); + }); + it("distinguishes fresh, merge-base-stale, and large unverified base states", () => { const fresh = buildLocalBranchAnalysis({ input: { @@ -314,6 +363,7 @@ describe("local branch analysis", () => { expect.arrayContaining([ expect.objectContaining({ heading: "Changed Paths", lines: ["- No changed paths were detected from local metadata."] }), expect.objectContaining({ heading: "Validation", lines: ["- Not supplied yet."] }), + expect.objectContaining({ heading: "Next Steps", lines: expect.arrayContaining([expect.stringContaining("metadata only")]) }), ]), ); expect(analysis.summary).toContain("is the top private next action"); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 91955bdc09..f632ba6071 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1,6 +1,6 @@ import { execFile, execFileSync } from "node:child_process"; import { createServer, type Server } from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -273,6 +273,64 @@ describe("gittensory-mcp CLI", () => { expect(explain.topAction.actionType).toBe("choose_next_work"); }); + it("prints copy-paste public-safe markdown for agent packet output", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + git(tempDir, "checkout", "-b", "codex/public-safe-pr-packets"); + mkdirSync(join(tempDir, "src")); + writeFileSync(join(tempDir, "src/packet.ts"), "export const packet = true;\n"); + const url = await startFixtureServer(); + const output = await runAsync( + ["agent", "packet", "--login", "oktofeesh1", "--cwd", tempDir, "--base", "HEAD", "--body", "Closes #39", "--validation", "passed|npm test|packet tests passed"], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + expect(output).toContain("# Public-safe PR packet"); + expect(output).toContain("## Validation"); + expect(output).toContain("Closes #39"); + expect(output).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|raw[-\s]?trust|private[-\s]?reviewability|reviewability|export const packet/i); + }); + + it("rejects unsafe server-provided packet markdown before non-json output", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + git(tempDir, "checkout", "-b", "codex/public-safe-pr-packets"); + + for (const unsafePhrase of ["score: 1.15", "reward estimate", "wallet address", "hotkey id", "raw-trust: 0.7", "private-reviewability: ready"]) { + if (server) await new Promise((resolve) => server?.close(() => resolve())); + server = null; + const url = await startFixtureServer({ packetMarkdown: `# Public-safe PR packet\n\n- ${unsafePhrase}\n` }); + await expect( + runAsync( + ["agent", "packet", "--login", "oktofeesh1", "--cwd", tempDir, "--base", "HEAD"], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ), + ).rejects.toThrow("Refusing to print unsafe public packet markdown from the server."); + } + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); @@ -316,7 +374,11 @@ function runAsync(args: string[], env: Record = {}) { }); } -async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number } = {}) { +function git(cwd: string, ...args: string[]) { + execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string } = {}) { server = createServer((request, response) => { response.setHeader("content-type", "application/json"); if (request.url && request.url.includes("gittensory-mcp/latest")) { @@ -344,6 +406,10 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi response.end(JSON.stringify(agentFixture())); return; } + if (request.url === "/v1/agent/prepare-pr-packet" && request.method === "POST") { + response.end(JSON.stringify(agentPacketFixture(options.packetMarkdown))); + return; + } response.statusCode = 404; response.end(JSON.stringify({ error: "not_found" })); }); @@ -353,6 +419,31 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi return `http://127.0.0.1:${address.port}`; } +function agentPacketFixture(markdown = "# Public-safe PR packet\n\n## Linked Context\n- Closes #39\n\n## Validation\n- passed: npm test (packet tests passed)\n") { + return { + ...agentFixture(), + actions: [ + { + id: "action-packet", + runId: "run-1", + actionType: "prepare_pr_packet", + status: "ready", + recommendation: "Use this public-safe packet.", + why: ["Fixture"], + blockedBy: [], + publicSafeSummary: "Packet ready.", + approvalRequired: false, + safetyClass: "public_safe", + payload: { + prPacket: { + markdown, + }, + }, + }, + ], + }; +} + function agentFixture() { return { run: {