22// Phase 3 of #7291 / #7330 — design decision: single-file 1:1 JS→TS conversion (not a split).
33// Seams exist, but splitting would be a separate refactor; this PR only routes the CLI through tsc.
44import { createHash } from "node:crypto" ;
5- import { closeSync , constants as fsConstants , existsSync , fstatSync , mkdirSync , openSync , readdirSync , readFileSync , readSync , rmSync , statSync , writeFileSync } from "node:fs" ;
5+ import { closeSync , constants as fsConstants , existsSync , fstatSync , mkdirSync , openSync , readdirSync , readFileSync , readSync , realpathSync , rmSync , statSync , writeFileSync } from "node:fs" ;
66import { homedir } from "node:os" ;
77import { delimiter , dirname , join } from "node:path" ;
8+ import { fileURLToPath } from "node:url" ;
89import { McpServer , ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" ;
910import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
1011import { buildFeasibilityVerdict , buildPrTextLint , buildGateDispositions , buildPublicPrBodyDraft } from "@loopover/engine" ;
@@ -60,6 +61,28 @@ const decisionPackCacheMaxBytes = 512 * 1024;
6061const cliTextFileMaxBytes = 1024 * 1024 ;
6162const changelogPath = new URL ( "../CHANGELOG.md" , import . meta. url ) ;
6263const cliArgs = process . argv . slice ( 2 ) ;
64+ // #7764: true only when this file is the process entrypoint (`node .../loopover-mcp.js`, incl. via the npm
65+ // `.bin` symlink -- realpathSync resolves it), false when it is imported in-process (e.g. by a vitest unit
66+ // test that exercises the CLI dispatcher + stdio tools directly). The two top-level side effects below -- the
67+ // CLI dispatch and the stdio `server.connect()` -- are gated on it so an in-process importer neither hijacks
68+ // the test runner's argv into runCli() nor binds a StdioServerTransport to the shared stdin. This is the same
69+ // "testable-export refactor" path bin/loopover-miner-mcp.ts's createMinerMcpServer already took (see
70+ // codecov.yml's CLI-dispatcher note); subprocess invocation is unchanged (argv[1] is this file, so it stays
71+ // true) and every mcp-cli-*.test.ts harness run continues to hit the real dispatcher.
72+ function isProcessEntrypoint ( ) {
73+ const entry = process . argv [ 1 ] ;
74+ /* v8 ignore next -- argv[1] is always populated for a spawned Node process; the guard is belt-and-suspenders */
75+ if ( ! entry )
76+ return false ;
77+ try {
78+ return realpathSync ( entry ) === realpathSync ( fileURLToPath ( import . meta. url ) ) ;
79+ }
80+ catch {
81+ /* v8 ignore next -- defensive: a realpath failure (renamed/removed entry) just means "not the launched CLI" */
82+ return false ;
83+ }
84+ }
85+ const runAsCliEntrypoint = isProcessEntrypoint ( ) ;
6386const defaultProfileName = "default" ;
6487// Single source of truth for shell-completion: top-level command -> its subcommands (if any).
6588const CLI_COMMAND_SPEC = {
@@ -95,7 +118,7 @@ const CLI_COMMAND_SPEC = {
95118 profile : [ "list" , "create" , "switch" , "remove" ] ,
96119 cache : [ "status" , "clear" , "list" ] ,
97120 agent : [ "plan" , "status" , "explain" , "packet" ] ,
98- maintain : [ "status" , "queue" , "propose" , "approve" , "reject" , "pause" , "resume" , "set-level" , "precision" , "outcome-calibration" , "onboarding-pack" , "audit-feed" , "automation-state" , "refresh-docs" , "generate-issue-drafts" ] ,
121+ 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" ] ,
99122} ;
100123const COMPLETION_SHELLS = [ "bash" , "zsh" , "fish" , "powershell" ] ;
101124const AGENT_PROFILE_IDS = [ "miner-planner" , "miner-auto-dev" , "maintainer-triage" , "repo-owner-intake" ] ;
@@ -861,6 +884,19 @@ const gatePrecisionShape = {
861884 repo : z . string ( ) . min ( 1 ) ,
862885 windowDays : z . number ( ) . int ( ) . positive ( ) . optional ( ) ,
863886} ;
887+ // #7764: mirrors the remote loopover_plan_repo_issues tool's input (src/mcp/server.ts's planRepoIssuesShape),
888+ // minus the create-only `milestone` which this proxy (and the `maintain plan-issues` CLI) does not expose --
889+ // forwarded to POST /v1/repos/:owner/:repo/issue-plan-drafts/generate. `goal` is the required maintainer
890+ // planning goal; dryRun/create carry the route's create-safety (create alone is rejected there). `limit` is
891+ // capped at 10, matching the route, because every draft costs real LLM spend.
892+ const planRepoIssuesShape = {
893+ owner : z . string ( ) . min ( 1 ) ,
894+ repo : z . string ( ) . min ( 1 ) ,
895+ goal : z . string ( ) . min ( 1 ) . max ( 2000 ) ,
896+ dryRun : z . boolean ( ) . optional ( ) . default ( true ) ,
897+ create : z . boolean ( ) . optional ( ) . default ( false ) ,
898+ limit : z . number ( ) . int ( ) . min ( 1 ) . max ( 10 ) . optional ( ) . default ( 5 ) ,
899+ } ;
864900// Single source of truth for stdio tool name + one-line description (#2233).
865901// Registration and `loopover-mcp tools` both read this list.
866902const STDIO_TOOL_DESCRIPTORS = [
@@ -1219,6 +1255,11 @@ const STDIO_TOOL_DESCRIPTORS = [
12191255 category : "maintainer" ,
12201256 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." ,
12211257 } ,
1258+ {
1259+ name : "loopover_plan_repo_issues" ,
1260+ category : "maintainer" ,
1261+ description : "AI-plan a small set of concrete GitHub issue drafts for a repo from a maintainer-supplied free-form goal, same as `loopover-mcp maintain plan-issues --goal ...`. Dry-run BY DEFAULT: only previews the drafted title/body/labels unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues. Maintainer access required." ,
1262+ } ,
12221263 {
12231264 name : "loopover_open_pr" ,
12241265 category : "agent" ,
@@ -1282,7 +1323,9 @@ function stdioToolDescription(name) {
12821323 throw new Error ( `Unknown stdio tool descriptor: ${ name } ` ) ;
12831324 return tool . description ;
12841325}
1285- if ( cliArgs [ 0 ] && cliArgs [ 0 ] !== "--stdio" ) {
1326+ /* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
1327+ unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
1328+ if ( runAsCliEntrypoint && cliArgs [ 0 ] && cliArgs [ 0 ] !== "--stdio" ) {
12861329 try {
12871330 const exitCode = await runCli ( cliArgs ) ;
12881331 process . exit ( typeof exitCode === "number" ? exitCode : 0 ) ;
@@ -1291,7 +1334,7 @@ if (cliArgs[0] && cliArgs[0] !== "--stdio") {
12911334 process . exit ( reportCliFailure ( argsWantJson ( cliArgs ) , describeCliError ( error ) , 1 ) ) ;
12921335 }
12931336}
1294- const server = new McpServer ( {
1337+ export const server = new McpServer ( {
12951338 name : "loopover-local" ,
12961339 version : packageVersion ,
12971340} ) ;
@@ -2143,6 +2186,17 @@ registerStdioTool("loopover_get_gate_precision", {
21432186 const payload = await apiGet ( `${ toolRepoBase ( owner , repo ) } /gate-precision${ query } ` ) ;
21442187 return toolResult ( `Gate precision for ${ owner } /${ repo } .` , payload ) ;
21452188} ) ;
2189+ registerStdioTool ( "loopover_plan_repo_issues" , {
2190+ description : stdioToolDescription ( "loopover_plan_repo_issues" ) ,
2191+ inputSchema : planRepoIssuesShape ,
2192+ } , async ( { owner, repo, goal, dryRun, create, limit } ) => {
2193+ // #7764: proxies POST {repoBase}/issue-plan-drafts/generate (the REST mirror of this same tool id). The
2194+ // route re-applies its own explicit_create_requires_dry_run_false guard, so forwarding the schema-defaulted
2195+ // dryRun/create verbatim keeps the create-safety exact: `create` alone (dryRun still true) is rejected;
2196+ // only an explicit {create:true, dryRun:false} reaches the write path.
2197+ const payload = await apiPost ( `${ toolRepoBase ( owner , repo ) } /issue-plan-drafts/generate` , { goal, dryRun, create, limit } ) ;
2198+ return toolResult ( `Issue plan for ${ owner } /${ repo } (status=${ payload . status } , dryRun=${ payload . dryRun } ): ${ payload . proposed ?? 0 } proposed, ${ payload . created ?? 0 } created.` , payload ) ;
2199+ } ) ;
21462200// ── Write-tools (#6149): pure LOCAL-execution spec builders. loopover NEVER performs the write -- each tool
21472201// returns a spec the caller runs with its OWN gh creds. Brings the local stdio server to parity with the
21482202// miner-auto-dev profile's recommendedTools, using the same @loopover/engine builders as the remote server.
@@ -2562,7 +2616,11 @@ server.registerPrompt("loopover_repo_owner_onboarding_pack", {
25622616 } ,
25632617 ] ,
25642618} ) ) ;
2565- await server . connect ( new StdioServerTransport ( ) ) ;
2619+ // #7764: only bind the shared stdin/stdout transport when actually launched as the CLI/stdio process. An
2620+ // in-process unit-test importer holds the exported `server` and connects it to an in-memory transport instead.
2621+ /* v8 ignore next -- only the launched stdio process binds the real transport; unit tests connect in-memory. */
2622+ if ( runAsCliEntrypoint )
2623+ await server . connect ( new StdioServerTransport ( ) ) ;
25662624async function withClientWorkspaceRoots ( input ) {
25672625 return withWorkspaceRoots ( input , await clientWorkspaceRoots ( ) ) ;
25682626}
@@ -2619,13 +2677,16 @@ function printMaintainHelp() {
26192677 " generate-issue-drafts Preview contributor issue drafts (dry-run). Never creates without --create." ,
26202678 " [--create] Actually open the drafted issues (requires repo write access)." ,
26212679 " [--limit N] Cap the drafts generated (1-20, default 5)." ,
2680+ ' plan-issues --goal "..." AI-plan issue drafts from a free-form goal (dry-run). Never creates without --create.' ,
2681+ " [--create] Actually open the drafted issues (requires repo write access)." ,
2682+ " [--limit N] Cap the drafts generated (1-10, default 5)." ,
26222683 "" ,
26232684 "Pass --json for machine-readable output." ,
26242685 ] . join ( "\n" ) + "\n" ) ;
26252686}
26262687// #784 maintainer CLI controls — thin proxies over the agent approval-queue API (#779) and the maintainer
26272688// settings kill-switch (#130). The API enforces maintainer authorization; the CLI never decides locally.
2628- async function maintainCli ( args ) {
2689+ export async function maintainCli ( args ) {
26292690 const subcommand = args [ 0 ] ;
26302691 if ( ! subcommand || subcommand === "--help" || subcommand === "help" )
26312692 return printMaintainHelp ( ) ;
@@ -2837,7 +2898,32 @@ async function maintainCli(args) {
28372898 emit ( payload , lines . join ( "\n" ) ) ;
28382899 return ;
28392900 }
2840- throw new Error ( `Unknown maintain subcommand: ${ subcommand } . Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts.` ) ;
2901+ if ( subcommand === "plan-issues" ) {
2902+ // #7764: session-authenticated mirror of POST {repoBase}/issue-plan-drafts/generate (and the remote
2903+ // loopover_plan_repo_issues tool). Requires --goal (the maintainer's free-form planning goal). Dry-run BY
2904+ // DEFAULT — only a bare `--create` opts into the write path, forwarded as {create:true, dryRun:false}, the
2905+ // exact shape the route's explicit_create_requires_dry_run_false guard demands. A plain `plan-issues` can
2906+ // never create.
2907+ const goal = typeof options . goal === "string" ? options . goal . trim ( ) : "" ;
2908+ if ( ! goal )
2909+ throw new Error ( 'Pass the planning goal: loopover-mcp maintain plan-issues --repo owner/repo --goal "...".' ) ;
2910+ const create = options . create === true ;
2911+ const parsedLimit = Number ( options . limit ) ;
2912+ const body = { goal, create, dryRun : ! create , ...( Number . isFinite ( parsedLimit ) ? { limit : parsedLimit } : { } ) } ;
2913+ const payload = await apiPost ( `${ repoBase } /issue-plan-drafts/generate` , body ) ;
2914+ const mode = payload . dryRun ? "dry-run" : "create" ;
2915+ const lines = [
2916+ `Issue plan for ${ repoFullName } (${ mode } , status=${ sanitizePlainTextTerminalOutput ( payload . status ) } ): ${ payload . proposed ?? 0 } proposed, ${ payload . created ?? 0 } created, ${ payload . skippedDuplicate ?? 0 } duplicate, ${ payload . skippedDeclined ?? 0 } declined, ${ payload . skippedUnsafe ?? 0 } unsafe, ${ payload . skippedCreateFailed ?? 0 } create-failed.` ,
2917+ // draft.title/body are AI-generated free text, so the plain-text path is sanitized (#6261).
2918+ ...( payload . drafts ?? [ ] ) . map ( ( draft ) => {
2919+ const ref = draft . issue ? ` -> #${ draft . issue . number } ${ draft . issue . url } ` : "" ;
2920+ return `- [${ sanitizePlainTextTerminalOutput ( draft . status ) } ] ${ sanitizePlainTextTerminalOutput ( draft . title ) } ${ sanitizePlainTextTerminalOutput ( ref ) } ` ;
2921+ } ) ,
2922+ ] ;
2923+ emit ( payload , lines . join ( "\n" ) ) ;
2924+ return ;
2925+ }
2926+ throw new Error ( `Unknown maintain subcommand: ${ subcommand } . Use status | queue | propose <action-class> <pull-number> | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs | generate-issue-drafts | plan-issues.` ) ;
28412927}
28422928async function runCli ( args ) {
28432929 const command = args [ 0 ] ;
0 commit comments