diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index 363e25b8..ba646f12 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -1249,6 +1249,20 @@ il plan --yolo 42 **Warning:** Autonomous mode will create issues and dependencies without confirmation. Use with caution - it can make irreversible changes to your issue tracker. +**Read-Only Mode (No Write Access):** + +When you run `il plan` on a GitHub repository where you lack write or triage permissions (e.g., contributing to an open-source project you don't maintain), `il plan` automatically detects this and switches to read-only mode. + +In read-only mode, `il plan` produces the full decomposition plan but posts it as a detailed comment on the parent issue (or as a single standalone issue in fresh planning mode) instead of creating child issues and dependencies. This avoids leaving orphaned issues that cannot be linked. + +To check your permission level on a repository: + +```bash +gh repo view --json viewerPermission +``` + +Permission levels `ADMIN`, `MAINTAIN`, and `WRITE` grant full `il plan` functionality. `TRIAGE` and `READ` (or no access) trigger read-only mode. Non-GitHub providers (Linear, Jira) are unaffected. + **Available MCP Tools in Session:** | Category | Tools | diff --git a/src/commands/ignite.ts b/src/commands/ignite.ts index 2bc317a3..5dee1ad8 100644 --- a/src/commands/ignite.ts +++ b/src/commands/ignite.ts @@ -358,7 +358,8 @@ export class IgniteCommand { try { const provider = this.settings ? IssueTrackerFactory.getProviderName(this.settings) : 'github' // Pass draftPrNumber to route comments to PR when in github-draft-pr mode - mcpConfig = await generateIssueManagementMcpConfig(context.type, undefined, provider, this.settings, draftPrNumber) + const mcpResult = await generateIssueManagementMcpConfig(context.type, undefined, provider, this.settings, draftPrNumber) + mcpConfig = mcpResult.configs logger.debug('Generated MCP configuration for issue management', { provider, draftPrNumber }) // Configure tool filtering for issue/PR workflows @@ -886,13 +887,13 @@ export class IgniteCommand { // Issue management MCP try { - const issueMcpConfigs = await generateIssueManagementMcpConfig( + const issueMcpResult = await generateIssueManagementMcpConfig( 'issue', undefined, providerName as 'github' | 'linear' | 'jira', settings, ) - mcpConfigs.push(...issueMcpConfigs) + mcpConfigs.push(...issueMcpResult.configs) } catch (error) { logger.warn(`Failed to generate issue management MCP config: ${error instanceof Error ? error.message : 'Unknown error'}`) } diff --git a/src/commands/plan.test.ts b/src/commands/plan.test.ts index 38e6d53d..3022ceaa 100644 --- a/src/commands/plan.test.ts +++ b/src/commands/plan.test.ts @@ -8,12 +8,15 @@ import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFa import { TelemetryService } from '../lib/TelemetryService.js' import * as identifierParser from '../utils/IdentifierParser.js' import { IssueTrackerFactory } from '../lib/IssueTrackerFactory.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import * as githubUtils from '../utils/github.js' // Mock dependencies vi.mock('../utils/claude.js') vi.mock('../utils/mcp.js') vi.mock('../utils/first-run-setup.js') vi.mock('../utils/IdentifierParser.js') +vi.mock('../utils/github.js') vi.mock('../mcp/IssueManagementProviderFactory.js') vi.mock('../lib/TelemetryService.js', () => ({ TelemetryService: { @@ -61,14 +64,17 @@ describe('PlanCommand', () => { // Setup default mocks vi.mocked(claudeUtils.detectClaudeCli).mockResolvedValue(true) vi.mocked(claudeUtils.launchClaude).mockResolvedValue(undefined) - vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue([ - { mcpServers: { issue_management: {} } }, - ]) + vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue({ + configs: [{ mcpServers: { issue_management: {} } }], + repo: 'test-owner/test-repo', + }) // Default: project is already configured (no first-run setup needed) vi.mocked(firstRunSetup.needsFirstRunSetup).mockResolvedValue(false) vi.mocked(firstRunSetup.launchFirstRunSetup).mockResolvedValue(undefined) // Default: input is not an issue identifier (non-decomposition mode) vi.mocked(identifierParser.matchIssueIdentifier).mockReturnValue({ isIssueIdentifier: false }) + // Default: user has write access (prevents read-only mode in existing tests) + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE') // Default: TelemetryService mock const mockTrack = vi.fn() vi.mocked(TelemetryService.getInstance).mockReturnValue({ track: mockTrack } as unknown as TelemetryService) @@ -517,6 +523,7 @@ describe('PlanCommand', () => { expect(mockTrack).toHaveBeenCalledWith('epic.planned', { child_count: 3, tracker: 'github', + read_only_mode: false, }) }) @@ -537,4 +544,208 @@ describe('PlanCommand', () => { await expect(command.execute('42')).resolves.toBeUndefined() }) }) + + describe('read-only mode (no write access)', () => { + beforeEach(() => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('READ') + }) + + it('should pass READ_ONLY_MODE: true when user lacks write access', async () => { + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: true }) + ) + }) + + it('should pass READ_ONLY_MODE: true for TRIAGE permission', async () => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('TRIAGE') + + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: true }) + ) + }) + + it('should exclude write tools from allowedTools when READ_ONLY_MODE', async () => { + await command.execute() + + const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0] + const allowedTools = launchCall[1].allowedTools as string[] + expect(allowedTools).not.toContain('mcp__issue_management__create_child_issue') + expect(allowedTools).not.toContain('mcp__issue_management__create_dependency') + expect(allowedTools).not.toContain('mcp__issue_management__remove_dependency') + // These should still be present + expect(allowedTools).toContain('mcp__issue_management__create_issue') + expect(allowedTools).toContain('mcp__issue_management__create_comment') + expect(allowedTools).toContain('mcp__issue_management__get_issue') + expect(allowedTools).toContain('mcp__issue_management__get_dependencies') + }) + + it('should pass resolved repo from MCP config to getRepoPermission', async () => { + vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue({ + configs: [{ mcpServers: { issue_management: {} } }], + repo: 'upstream-owner/upstream-repo', + }) + + await command.execute() + + expect(githubUtils.getRepoPermission).toHaveBeenCalledWith('upstream-owner/upstream-repo') + }) + + it('should log warning about read-only mode', async () => { + const { logger } = await import('../utils/logger.js') + + await command.execute() + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('read-only mode') + ) + }) + + it('should pass read_only_mode: true in telemetry for decomposition sessions', async () => { + const mockTrack = vi.fn() + vi.mocked(TelemetryService.getInstance).mockReturnValue({ track: mockTrack } as unknown as TelemetryService) + + const mockGetChildIssues = vi.fn().mockResolvedValue([ + { id: '100', title: 'Child 1', state: 'open' }, + ]) + vi.mocked(IssueManagementProviderFactory.create).mockReturnValue({ + getChildIssues: mockGetChildIssues, + } as never) + + vi.mocked(identifierParser.matchIssueIdentifier).mockReturnValue({ + isIssueIdentifier: true, + type: 'numeric', + identifier: '42', + }) + const mockIssueTracker = { + detectInputType: vi.fn().mockResolvedValue({ type: 'issue', identifier: '42' }), + fetchIssue: vi.fn().mockResolvedValue({ number: 42, title: 'Test epic', body: 'Epic body' }), + } + vi.mocked(IssueTrackerFactory.create).mockReturnValue(mockIssueTracker as never) + + await command.execute('42') + + expect(mockTrack).toHaveBeenCalledWith('epic.planned', expect.objectContaining({ + read_only_mode: true, + })) + }) + }) + + describe('write access mode', () => { + it('should pass READ_ONLY_MODE: false when user has WRITE permission', async () => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE') + + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + + it('should pass READ_ONLY_MODE: false when user has ADMIN permission', async () => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('ADMIN') + + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + + it('should pass READ_ONLY_MODE: false when user has MAINTAIN permission', async () => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('MAINTAIN') + + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + + it('should include all tools when user has write access', async () => { + vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE') + + await command.execute() + + const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0] + const allowedTools = launchCall[1].allowedTools as string[] + expect(allowedTools).toContain('mcp__issue_management__create_child_issue') + expect(allowedTools).toContain('mcp__issue_management__create_dependency') + expect(allowedTools).toContain('mcp__issue_management__remove_dependency') + }) + }) + + describe('permission check failure (fail-open)', () => { + it('should default to write access if permission check fails', async () => { + vi.mocked(githubUtils.getRepoPermission).mockRejectedValue(new Error('API error')) + + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + + it('should include all tools when permission check fails', async () => { + vi.mocked(githubUtils.getRepoPermission).mockRejectedValue(new Error('API error')) + + await command.execute() + + const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0] + const allowedTools = launchCall[1].allowedTools as string[] + expect(allowedTools).toContain('mcp__issue_management__create_child_issue') + expect(allowedTools).toContain('mcp__issue_management__create_dependency') + expect(allowedTools).toContain('mcp__issue_management__remove_dependency') + }) + }) + + describe('non-GitHub provider', () => { + beforeEach(() => { + // loadSettings must return a non-null value so getProviderName is actually called + // (when settings is null, provider defaults to 'github' via the ternary) + vi.mocked(SettingsManager).mockImplementation(() => ({ + loadSettings: vi.fn().mockResolvedValue({}), + getPlanModel: vi.fn().mockReturnValue('opus'), + getPlanPlanner: vi.fn().mockReturnValue('claude'), + getPlanReviewer: vi.fn().mockReturnValue('none'), + }) as unknown as SettingsManager) + }) + + it('should skip permission check for Linear provider', async () => { + vi.mocked(IssueTrackerFactory.getProviderName).mockReturnValue('linear') + + // Need a fresh command since we changed the SettingsManager mock + command = new PlanCommand(mockTemplateManager) + await command.execute() + + expect(githubUtils.getRepoPermission).not.toHaveBeenCalled() + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + + it('should skip permission check for Jira provider', async () => { + vi.mocked(IssueTrackerFactory.getProviderName).mockReturnValue('jira') + + // Need a fresh command since we changed the SettingsManager mock + command = new PlanCommand(mockTemplateManager) + await command.execute() + + expect(githubUtils.getRepoPermission).not.toHaveBeenCalled() + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'plan', + expect.objectContaining({ READ_ONLY_MODE: false }) + ) + }) + }) }) diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 3fa27b3b..484a905e 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -12,6 +12,7 @@ import { needsFirstRunSetup, launchFirstRunSetup } from '../utils/first-run-setu import type { IssueProvider, ChildIssueResult, DependenciesResult } from '../mcp/types.js' import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js' import { TelemetryService } from '../lib/TelemetryService.js' +import { getRepoPermission } from '../utils/github.js' // Define provider arrays for validation and dynamic flag generation const PLANNER_PROVIDERS = ['claude', 'gemini', 'codex'] as const @@ -268,8 +269,11 @@ export class PlanCommand { // This will throw if no git remote is configured - offer to run 'il init' as fallback logger.debug('Generating MCP config for issue management') let mcpConfig: Record[] + let resolvedRepo: string | undefined try { - mcpConfig = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined) + const mcpResult = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined) + mcpConfig = mcpResult.configs + resolvedRepo = mcpResult.repo } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' @@ -291,7 +295,9 @@ export class PlanCommand { // Retry MCP config generation after init logger.info(chalk.bold('Retrying planning session setup...')) try { - mcpConfig = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined) + const retryResult = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined) + mcpConfig = retryResult.configs + resolvedRepo = retryResult.repo } catch (retryError) { const retryMessage = retryError instanceof Error ? retryError.message : 'Unknown error' logger.error(`Failed to generate MCP config: ${retryMessage}`) @@ -355,6 +361,22 @@ export class PlanCommand { serverCount: mcpConfig.length, }) + // Check repository permission level for GitHub repos + let hasWriteAccess = true // default for non-GitHub or if check fails + if (provider === 'github') { + try { + const permission = await getRepoPermission(resolvedRepo) + hasWriteAccess = ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission) + if (!hasWriteAccess) { + logger.warn('You do not have write access to this repository. Running in read-only mode — the plan will be posted as a comment instead of creating child issues.') + } + } catch (error) { + logger.debug('Permission check failed, defaulting to write access', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + // Detect VS Code mode const isVscodeMode = process.env.ILOOM_VSCODE === '1' logger.debug('VS Code mode detection', { isVscodeMode }) @@ -389,6 +411,7 @@ export class PlanCommand { PLANNER: effectivePlanner, REVIEWER: effectiveReviewer, HAS_REVIEWER: effectiveReviewer !== 'none', + READ_ONLY_MODE: !hasWriteAccess, ...providerFlags, } const architectPrompt = await this.templateManager.getPrompt('plan', templateVariables) @@ -401,15 +424,15 @@ export class PlanCommand { const allowedTools = [ // Issue management tools 'mcp__issue_management__create_issue', - 'mcp__issue_management__create_child_issue', + ...(hasWriteAccess ? ['mcp__issue_management__create_child_issue'] : []), 'mcp__issue_management__get_issue', 'mcp__issue_management__get_child_issues', 'mcp__issue_management__get_comment', 'mcp__issue_management__create_comment', // Dependency management tools - 'mcp__issue_management__create_dependency', + ...(hasWriteAccess ? ['mcp__issue_management__create_dependency'] : []), 'mcp__issue_management__get_dependencies', - 'mcp__issue_management__remove_dependency', + ...(hasWriteAccess ? ['mcp__issue_management__remove_dependency'] : []), // Codebase exploration tools (read-only) 'Read', 'Glob', @@ -516,6 +539,7 @@ ${initialMessage}` TelemetryService.getInstance().track('epic.planned', { child_count: children.length, tracker: provider, + read_only_mode: !hasWriteAccess, }) } catch (error) { logger.debug(`Telemetry epic.planned tracking failed: ${error instanceof Error ? error.message : error}`) diff --git a/src/lib/IssueEnhancementService.test.ts b/src/lib/IssueEnhancementService.test.ts index 7541a1f2..8a8522f6 100644 --- a/src/lib/IssueEnhancementService.test.ts +++ b/src/lib/IssueEnhancementService.test.ts @@ -7,6 +7,7 @@ import { openBrowser } from '../utils/browser.js' import { launchClaude } from '../utils/claude.js' import { logger } from '../utils/logger.js' import { waitForKeypress } from '../utils/prompt.js' +import { generateIssueManagementMcpConfig } from '../utils/mcp.js' // Mock dependencies vi.mock('./GitHubService.js') @@ -37,7 +38,7 @@ vi.mock('../utils/logger.js', () => ({ })) vi.mock('../utils/mcp.js', () => ({ - generateIssueManagementMcpConfig: vi.fn().mockResolvedValue([]), + generateIssueManagementMcpConfig: vi.fn().mockResolvedValue({ configs: [], repo: undefined }), })) describe('IssueEnhancementService', () => { @@ -564,6 +565,8 @@ describe('IssueEnhancementService', () => { }, }) vi.mocked(mockAgentManager.formatForCli).mockReturnValue('agent1=path1') + // Re-seed after mockReset wipes the vi.mock factory return value + vi.mocked(generateIssueManagementMcpConfig).mockResolvedValue({ configs: [], repo: undefined }) // Mock providerName Object.defineProperty(mockGitHubService, 'providerName', { value: 'github', configurable: true }) }) diff --git a/src/lib/IssueEnhancementService.ts b/src/lib/IssueEnhancementService.ts index 1f73ec3f..a1189028 100644 --- a/src/lib/IssueEnhancementService.ts +++ b/src/lib/IssueEnhancementService.ts @@ -228,7 +228,8 @@ Press any key to open issue for editing...` try { const provider = this.issueTrackerService.providerName as 'github' | 'linear' - mcpConfig = await generateIssueManagementMcpConfig('issue', repo, provider, settings) + const mcpResult = await generateIssueManagementMcpConfig('issue', repo, provider, settings) + mcpConfig = mcpResult.configs getLogger().debug('Generated MCP configuration for issue management:', { mcpConfig }) // Configure tool filtering for issue workflows diff --git a/src/lib/PromptTemplateManager.ts b/src/lib/PromptTemplateManager.ts index 07500681..c7b49237 100644 --- a/src/lib/PromptTemplateManager.ts +++ b/src/lib/PromptTemplateManager.ts @@ -114,6 +114,7 @@ export interface TemplateVariables { SWARM_AGENT_METADATA?: string // JSON string mapping agent names to { model, tools } for claude -p commands NO_CLEANUP?: boolean // True when child loom cleanup should be skipped (e.g., manual cleanup later) ISSUE_PREFIX?: string // "#" for GitHub, "" for Linear/Jira — used in commit message templates + READ_ONLY_MODE?: boolean // True when user lacks write access to GitHub repo (plan command only) } /** diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts index a7371188..707eab4e 100644 --- a/src/types/telemetry.ts +++ b/src/types/telemetry.ts @@ -42,6 +42,7 @@ export interface LoomAbandonedProperties { export interface EpicPlannedProperties { child_count: number tracker: string + read_only_mode: boolean } export interface SwarmStartedProperties { diff --git a/src/utils/github.test.ts b/src/utils/github.test.ts index 73ad5af0..22085698 100644 --- a/src/utils/github.test.ts +++ b/src/utils/github.test.ts @@ -15,6 +15,7 @@ import { updateIssueComment, createPRComment, getRepoInfo, + getRepoPermission, } from './github.js' vi.mock('execa') @@ -871,4 +872,74 @@ with multiple lines. await expect(updateIssueComment(99999, 'Test')).rejects.toThrow('Failed to update comment') }) }) + + describe('getRepoPermission', () => { + it('should return permission level for current repo', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify({ viewerPermission: 'WRITE' }), + stderr: '', + exitCode: 0, + } as MockExecaReturn) + + const result = await getRepoPermission() + + expect(result).toBe('WRITE') + expect(execa).toHaveBeenCalledWith( + 'gh', + ['repo', 'view', '--json', 'viewerPermission'], + expect.any(Object) + ) + }) + + it('should return permission level for specified repo', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify({ viewerPermission: 'READ' }), + stderr: '', + exitCode: 0, + } as MockExecaReturn) + + const result = await getRepoPermission('owner/repo') + + expect(result).toBe('READ') + expect(execa).toHaveBeenCalledWith( + 'gh', + ['repo', 'view', 'owner/repo', '--json', 'viewerPermission'], + expect.any(Object) + ) + }) + + it('should return ADMIN permission level', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify({ viewerPermission: 'ADMIN' }), + stderr: '', + exitCode: 0, + } as MockExecaReturn) + + const result = await getRepoPermission() + + expect(result).toBe('ADMIN') + }) + + it('should return TRIAGE permission level', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify({ viewerPermission: 'TRIAGE' }), + stderr: '', + exitCode: 0, + } as MockExecaReturn) + + const result = await getRepoPermission() + + expect(result).toBe('TRIAGE') + }) + + it('should propagate errors from gh CLI', async () => { + vi.mocked(execa).mockRejectedValueOnce({ + stderr: 'Could not resolve to a Repository', + message: 'Command failed', + exitCode: 1, + }) + + await expect(getRepoPermission()).rejects.toThrow('Command failed') + }) + }) }) diff --git a/src/utils/github.ts b/src/utils/github.ts index bd5e3c1e..f63742fd 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -438,6 +438,20 @@ export async function getRepoInfo(): Promise { } } +/** + * Get the authenticated user's permission level on a repository + * @param repo - Optional repo in "owner/repo" format (uses current repo if not provided) + * @returns Permission level string: 'ADMIN', 'MAINTAIN', 'WRITE', 'TRIAGE', 'READ', or '' (empty for no access) + */ +export async function getRepoPermission(repo?: string): Promise { + logger.debug('Fetching repository permission', { repo }) + + const args = ['repo', 'view', ...(repo ? [repo] : []), '--json', 'viewerPermission'] + + const result = await executeGhCommand<{ viewerPermission: string }>(args) + return result.viewerPermission +} + // GitHub Sub-Issue Operations /** diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 8296cdca..222f41fa 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -16,13 +16,19 @@ import type { LoomMetadata } from '../lib/MetadataManager.js' * @param settings - Optional settings to extract Linear API token from * @param draftPrNumber - Optional draft PR number for github-draft-pr mode (routes comments to PR) */ +export interface IssueMcpConfigResult { + configs: Record[] + /** Resolved "owner/repo" string when provider is GitHub */ + repo?: string +} + export async function generateIssueManagementMcpConfig( contextType?: 'issue' | 'pr', repo?: string, provider: 'github' | 'linear' | 'jira' = 'github', settings?: IloomSettings, draftPrNumber?: number -): Promise[]> { +): Promise { // When draftPrNumber is provided (github-draft-pr mode), force contextType to 'pr' // This ensures agents route comments to the draft PR instead of the issue const effectiveContextType = draftPrNumber ? 'pr' : contextType @@ -37,6 +43,9 @@ export async function generateIssueManagementMcpConfig( envVars.DRAFT_PR_NUMBER = String(draftPrNumber) } + // Track the resolved repo string for the caller + let resolvedRepo: string | undefined + if (provider === 'github') { // Get repository information for GitHub - either from provided repo string or auto-detect let owner: string @@ -55,6 +64,8 @@ export async function generateIssueManagementMcpConfig( name = repoInfo.name } + resolvedRepo = `${owner}/${name}` + // Map logical types to GitHub's webhook event names (handle GitHub's naming quirk here) // Use effectiveContextType which may be overridden by draftPrNumber const githubEventName = effectiveContextType === 'issue' ? 'issues' : effectiveContextType === 'pr' ? 'pull_request' : undefined @@ -146,7 +157,7 @@ export async function generateIssueManagementMcpConfig( }, } - return [mcpServerConfig] + return { configs: [mcpServerConfig], ...(resolvedRepo !== undefined && { repo: resolvedRepo }) } } /** @@ -260,13 +271,13 @@ export async function generateAndWriteMcpConfigFile( // Generate issue management MCP config try { - const issueMcpConfigs = await generateIssueManagementMcpConfig( + const issueMcpResult = await generateIssueManagementMcpConfig( 'issue', undefined, provider, settings, ) - mcpConfigs.push(...issueMcpConfigs) + mcpConfigs.push(...issueMcpResult.configs) } catch (error) { logger.warn(`Failed to generate issue management MCP config for loom: ${error instanceof Error ? error.message : 'Unknown error'}`) } diff --git a/templates/prompts/plan-prompt.txt b/templates/prompts/plan-prompt.txt index 8c5513ad..6be76881 100644 --- a/templates/prompts/plan-prompt.txt +++ b/templates/prompts/plan-prompt.txt @@ -270,6 +270,147 @@ Each child issue should include: --- +{{#if READ_ONLY_MODE}} +## Plan Output (Read-Only Mode) + +**You do not have write access to this repository.** You MUST NOT create separate issues for each child task. Do NOT use `create_issue` or `create_child_issue` to create individual child issues — you do not have permission to set up sub-issue links or dependencies, and creating orphaned issues is not acceptable. + +Instead, your output is a **single detailed artifact** — either a comment on an existing parent issue, or (in fresh planning mode only) a single epic/parent issue with the full breakdown embedded in its body. + +**CRITICAL CONSTRAINT: Do NOT create multiple issues.** All child task details (descriptions, acceptance criteria, dependencies, scope boundaries) MUST be included as a structured breakdown within a single comment or single issue body. Never create standalone issues for individual child tasks. + +**Available Tools:** +{{#if FRESH_PLANNING_MODE}} +- `mcp__issue_management__create_issue`: Create ONE epic/parent issue only (do NOT use this to create individual child issues) +{{/if}} +- `mcp__issue_management__create_comment`: Post comments on existing issues + - Parameters: `number` (issue number), `body` (comment text), `type` ("issue") +- `mcp__issue_management__get_issue`: Fetch existing issue details for context +- `mcp__issue_management__get_child_issues`: Check for existing child issues +- `mcp__issue_management__get_dependencies`: Query existing dependencies + - Parameters: `number` (issue number), `direction` ('blocking' | 'blocked_by' | 'both') + +{{#if EXISTING_ISSUE_MODE}} +**Issue Decomposition Mode (Read-Only):** + +You are creating a plan for an existing issue: +- **Parent Issue:** #{{PARENT_ISSUE_NUMBER}} +- **Title:** {{PARENT_ISSUE_TITLE}} +- **Body:** +{{PARENT_ISSUE_BODY}} + +**IMPORTANT - Check for existing work first:** +Before proposing any new plan, use the MCP tools to check what already exists: +1. Use `mcp__issue_management__get_child_issues` with `number: "{{PARENT_ISSUE_NUMBER}}"` to fetch existing child issues +2. For each child issue returned, use `mcp__issue_management__get_dependencies` with `number: `, `direction: "both"` to build the full dependency graph between children + +If child issues or dependencies already exist: +- Review them with the user before proposing changes +- Incorporate existing work into your plan +- Avoid proposing duplicates of existing work + +**Dependency Analysis with Subagents:** + +**IMPORTANT: You MUST use a Task subagent to analyze existing child issues and dependencies.** Do NOT perform this analysis yourself - delegate to a subagent. This keeps the main conversation focused on planning decisions with the user while the subagent does the research work. + +The subagent should: +1. Fetch all child issues of the epic +2. Analyze their current status and descriptions +3. Map out the dependency graph between children +4. Identify gaps, missing issues, or incomplete coverage + +**Required Task subagent call:** +``` +Task( + subagent_type: "general-purpose", + prompt: "Analyze issue #{{PARENT_ISSUE_NUMBER}} and its children. Fetch the parent issue, all child issues, and their dependencies. Return a summary including: 1) List of existing child issues with status, 2) Current dependency graph, 3) Any gaps or areas not yet covered by child issues." +) +``` + +Wait for the subagent to complete, then present its summary to the user for planning decisions. + +**Output: Post a single comprehensive breakdown comment on the parent issue.** + +Do NOT create separate issues for each child task. Instead, post ALL child task details as a structured breakdown within a single comment on the parent issue. This comment should contain the same level of detail that would normally go into individual child issues (descriptions, acceptance criteria, dependencies, scope boundaries) — just formatted as sections within one comment. + +Use `create_comment` with `number: "{{PARENT_ISSUE_NUMBER}}"`, `type: "issue"` to post a comment containing: + +1. **Executive summary** of the decomposition plan +2. **Detailed task breakdown** — for each suggested child task, include: + - Title and one-sentence summary + - Acceptance criteria (clear, testable conditions for "done") + - Dependencies on other tasks in the breakdown + - Scope boundaries (what is explicitly NOT included) + - Complexity estimate (Simple/Trivial) + - Shared contracts (interfaces/APIs consumed or produced, if applicable) + + Format as a structured list or table: + | # | Title | Description | Dependencies | Complexity | + |---|-------|-------------|--------------|------------| + | 1 | [Title] | [One-sentence description] | None | Simple | + | 2 | [Title] | [One-sentence description] | Task 1 | Simple | + + Follow each table with expanded details per task (acceptance criteria, scope boundaries, etc.) using collapsible sections if needed. +3. **Dependency diagram** (Mermaid for GitHub) +4. **Architectural Decision Record**: Design rationale, key decisions, trade-offs +5. **Suggested execution order** for whoever will create the actual issues + +**Before Posting:** +1. Summarize the proposed plan and issue breakdown to the user +2. Ask for explicit confirmation: "Ready to post this plan as a comment?" +3. Post the comment only after user approval + +**Next steps message:** "This plan has been posted as a comment on issue #{{PARENT_ISSUE_NUMBER}}. A maintainer with write access can create the child issues and dependencies from this breakdown." + +{{else}} +**Fresh Planning Mode (Read-Only):** + +Follow the same planning process (Phase 1: Understanding, Phase 2: Design Exploration, Phase 3: Issue Decomposition) to produce a complete plan. + +**Output: Create ONE epic/parent issue with a high-level overview, then post the detailed breakdown as a comment.** + +Use `create_issue` to create exactly ONE issue. Do NOT create additional issues for individual child tasks. + +- Title: "Plan: [Feature Name]" +- Body: A high-level overview including: + 1. **Problem statement**: What problem this feature solves + 2. **Goals**: What success looks like + 3. **Scope**: What is and isn't included + 4. **Summary of approach**: Brief description of the implementation strategy + 5. **Task overview table** — a concise summary of the child tasks: + + | # | Title | Description | Dependencies | Complexity | + |---|-------|-------------|--------------|------------| + | 1 | [Title] | [One-sentence description] | None | Simple | + | 2 | [Title] | [One-sentence description] | Task 1 | Simple | + +After creating the epic issue, post the detailed task breakdown as a comment on that issue using `create_comment`. The comment should contain: + 1. **Detailed task breakdown** — for each suggested child task, include: + - Title and one-sentence summary + - Acceptance criteria (clear, testable conditions for "done") + - Dependencies on other tasks in the breakdown + - Scope boundaries (what is explicitly NOT included) + - Complexity estimate (Simple/Trivial) + - Shared contracts (interfaces/APIs consumed or produced, if applicable) + + Use collapsible sections if needed for per-task details. + 2. **Dependency diagram** (Mermaid for GitHub) + 3. **Architectural Decision Record**: Design rationale, key decisions, trade-offs + 4. **Suggested execution order** for whoever will create the actual issues + +This keeps the issue body as a concise overview while the comment contains the full implementation details. + +**Before Creating:** +1. Summarize the proposed plan to the user +2. Ask for explicit confirmation: "Ready to create this plan issue?" +3. Create the issue only after user approval + +**REMINDER: Create exactly ONE issue. Do NOT create separate issues for child tasks. All task details go into the single issue body and/or a comment on that issue.** + +**Next steps message:** "This plan has been created as issue #X. A maintainer with write access can decompose it into child issues and dependencies." + +{{/if}} +{{else}} ## Issue Creation At the end of the planning session, you have MCP tools to create issues: @@ -466,6 +607,7 @@ PROJ-102 [Add dependency management] ──┘ ``` Use `mcp__issue_management__create_comment` to post this summary to the parent epic after all issues and dependencies are created. +{{/if}} --- @@ -476,12 +618,26 @@ Use `mcp__issue_management__create_comment` to post this summary to the parent e - Create issues without user confirmation - Over-engineer the decomposition (keep it pragmatic) - Assume requirements that weren't explicitly stated +{{#if READ_ONLY_MODE}} +- Create separate issues for individual child tasks (post the breakdown as a comment or embed it in a single issue instead) +- Use `create_issue` more than once (fresh planning mode) or at all (existing issue mode) +- Use `create_child_issue` — this tool is not available in read-only mode +{{/if}} +{{#if READ_ONLY_MODE}} +**Do:** +- Use the conversation to refine understanding iteratively +- Post the plan as a comment or single issue (your primary output artifact) +- Include full child task details (acceptance criteria, dependencies, scope) in the breakdown comment or issue body +- Ask for clarification rather than making assumptions +- Keep the user informed about your reasoning +{{else}} **Do:** - Use the conversation to refine understanding iteratively - Create issues as the primary output artifact - Ask for clarification rather than making assumptions - Keep the user informed about your reasoning +{{/if}} --- @@ -490,15 +646,39 @@ Use `mcp__issue_management__create_comment` to post this summary to the parent e 1. **Greet** and understand the user's planning goal 2. **Ask** clarifying questions (one at a time) 3. **Explore** design approaches with trade-offs +{{#if READ_ONLY_MODE}} +4. **Draft** the complete plan with suggested issue breakdown +5. **Confirm** the plan with the user +6. **Post** the plan as a comment on the parent issue or create a single plan issue +7. **Summarize** next steps for a maintainer with write access +{{else}} 4. **Decompose** work into child issues 5. **Confirm** the issue structure with the user 6. **Create** issues using MCP tools (with permission) 7. **Summarize** what was created and next steps +{{/if}} --- ## Completion Message +{{#if READ_ONLY_MODE}} +After posting the plan successfully, you MUST end with a clear next steps message. + +{{#if EXISTING_ISSUE_MODE}} +Direct the user to **review the plan comment** on issue #{{PARENT_ISSUE_NUMBER}}. + +**Next Steps Message:** +End your summary with: "The plan has been posted as a comment on issue #{{PARENT_ISSUE_NUMBER}}. A maintainer with write access can create the child issues and dependencies from this breakdown." +{{else}} +Direct the user to **review the plan issue** that was created. + +**Next Steps Message:** +End your summary with: "The plan has been created as issue #[ISSUE_NUMBER] ([ISSUE_URL]). A maintainer with write access can decompose it into child issues and dependencies." + +Replace `[ISSUE_NUMBER]` and `[ISSUE_URL]` with actual values from the plan issue you created. +{{/if}} +{{else}} After creating issues successfully, you MUST end with a clear next steps message. Direct the user to **open the epic issue** - this is the parent issue that contains all the child issues you just created. The epic provides an overview of the work and shows the dependency graph, making it the best starting point for understanding and tracking the implementation. @@ -512,3 +692,4 @@ End your summary with: "Review the epic and child issues at [EPIC_URL]. When rea {{/if}} Replace `[EPIC_URL]` and `[EPIC_NUMBER]` with actual values from the epic issue you created. +{{/if}}