Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/iloom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,39 @@ Create an isolated loom workspace with complete AI-assisted context establishmen
```bash
il start <issue-number>
il start <pr-number>
il start <issue-or-pr-url>
il start <branch-name>
il start "<issue-description>"
```

**Arguments:**
- `<issue-number>` - GitHub or Linear issue number (e.g., `25`)
- `<pr-number>` - GitHub pull request number (e.g., `42`)
- `<issue-or-pr-url>` - A full URL to a tracker issue or GitHub PR (see "Supported URL Forms" below)
- `<branch-name>` - Existing git branch name
- `<issue-description>` - Free-form description to create a new issue (quoted string)

**Supported URL Forms:**

`il start` accepts a full tracker URL in addition to a bare identifier. The URL is parsed into a tracker identifier and dispatched normally.

| Tracker | URL shape |
|---------|-----------|
| GitHub issue | `https://github.com/<owner>/<repo>/issues/<n>` |
| GitHub PR | `https://github.com/<owner>/<repo>/pull/<n>` |
| Linear | `https://linear.app/<workspace>/issue/<TEAM-NUM>/...` |
| Jira (cloud) | `https://<host>.atlassian.net/browse/<KEY-NUM>` |
| Jira (self-hosted) | `https://<host>/browse/<KEY-NUM>` |

URL parsing is robust to trailing slashes, query strings, fragments (e.g., `#comment-123`), and mixed-case hosts.

**URL Policy for `il start`:**

- **Same-repo only:** Cross-repo URLs are rejected. `il start` operates on the current repository's worktree, so the URL must point to the same `<owner>/<repo>` as the configured remote.
- **Provider must match:** The URL's provider must match the configured issue tracker (e.g., a Linear URL on a GitHub-configured project is rejected).
- **GitHub PR carve-out:** A GitHub PR URL is always accepted by `il start`, even when the configured issue tracker is Linear or Jira. PRs are GitHub-native, so this is a deliberate exception to the provider-match rule.
- **PR URLs are valid here:** Both issue and PR URLs are accepted by `il start`. (PR URLs are NOT valid for `il plan` — see that section.)

**Options:**

| Flag | Values | Description |
Expand Down Expand Up @@ -135,6 +158,15 @@ il start 25
# Start work on Linear issue ILM-42
il start ILM-42

# Start from a tracker URL (GitHub issue)
il start https://github.com/iloom-ai/iloom-cli/issues/1009

# Start from a Linear issue URL
il start https://linear.app/my-team/issue/WEB-123/some-slug

# Start from a GitHub PR URL (allowed even on Linear/Jira-configured repos)
il start https://github.com/iloom-ai/iloom-cli/pull/1010

# Create a new issue and start work
il start "Add dark mode toggle to settings"

Expand Down Expand Up @@ -1391,18 +1423,41 @@ Launch an interactive planning session with an Architect persona to decompose fe
```bash
il plan [prompt] [options]
il plan <issue-number> [options]
il plan <issue-url> [options]
```

**Arguments:**
- `[prompt]` - Optional initial planning prompt or topic for fresh planning mode
- `<issue-number>` - Issue identifier to decompose (GitHub: `#123` or `123`, Linear: `ENG-123`)
- `<issue-url>` - Full URL to a tracker issue (see "Supported URL Forms" below)

**Operating Modes:**

| Mode | Trigger | Description |
|------|---------|-------------|
| Fresh Planning | `il plan` or `il plan "topic"` | Start a new planning session for a feature or epic |
| Decomposition | `il plan 123` or `il plan #123` | Break down an existing issue into child issues |
| Decomposition (URL) | `il plan https://github.com/owner/repo/issues/123` | Same as above, but pasted as a URL |

**Supported URL Forms:**

`il plan` accepts a full tracker issue URL in addition to a bare identifier. The URL is parsed into a tracker identifier before fetching.

| Tracker | URL shape |
|---------|-----------|
| GitHub issue | `https://github.com/<owner>/<repo>/issues/<n>` |
| Linear | `https://linear.app/<workspace>/issue/<TEAM-NUM>/...` |
| Jira (cloud) | `https://<host>.atlassian.net/browse/<KEY-NUM>` |
| Jira (self-hosted) | `https://<host>/browse/<KEY-NUM>` |

URL parsing is robust to trailing slashes, query strings, fragments, and mixed-case hosts.

**URL Policy for `il plan`:**

- **PR URLs are rejected:** `il plan` is for issue decomposition only. GitHub PR URLs (`/pull/<n>`) are not valid input — use `il start` for PRs.
- **Provider must match:** The URL's provider must match the configured issue tracker. There is no GitHub PR carve-out here (PR URLs are not accepted in the first place).
- **Cross-repo URLs are accepted:** Unlike `il start`, `il plan` accepts GitHub issue URLs that point to a different `<owner>/<repo>` than the cwd repo. This lets you decompose an issue in another repository.
- When the URL repo doesn't match the cwd repo, MCP-backed features that require write access to the issue tracker — specifically child-issue creation and dependency management — are skipped with a warning. Plan output is still generated, but you'll need to create the child issues manually (or run `il plan` from the target repo's worktree).

**Options:**

Expand Down Expand Up @@ -1467,6 +1522,12 @@ il plan "#42"

# Linear issue
il plan ENG-123

# Tracker URL (GitHub issue)
il plan https://github.com/iloom-ai/iloom-cli/issues/1009

# Cross-repo GitHub issue URL (MCP write features will be skipped with a warning)
il plan https://github.com/some-other-org/some-other-repo/issues/42
```

In decomposition mode, the Architect:
Expand Down
4 changes: 2 additions & 2 deletions src/commands/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Commands are registered in `src/cli.ts` using Commander.js. Each command class h

| Command | Aliases | File | Delegates To | Key Purpose |
|---------|---------|------|-------------|-------------|
| `start` | `new`, `create`, `up` | `start.ts` | LoomManager, GitWorktreeManager, DatabaseManager, AgentManager | Create isolated workspace for issue/PR/epic |
| `start` | `new`, `create`, `up` | `start.ts` | LoomManager, GitWorktreeManager, DatabaseManager, AgentManager | Create isolated workspace for issue/PR/epic. Accepts a tracker URL (GitHub issue/PR, Linear, Jira) in addition to a bare identifier; parsed via `src/utils/TrackerUrlParser.ts`. Cross-repo URLs are rejected; GitHub PR URLs are accepted regardless of configured tracker. |
| `finish` | `dn` | `finish.ts` | MergeManager, ValidationRunner, ResourceCleanup, PRManager | Merge branch, cleanup workspace |
| `cleanup` | `remove`, `clean` | `cleanup.ts` | GitWorktreeManager, ResourceCleanup, LoomManager | Remove worktree(s) |
| `list` | — | (inline in cli.ts) | LoomManager, GitWorktreeManager | Show active looms |
Expand All @@ -24,7 +24,7 @@ Commands are registered in `src/cli.ts` using Commander.js. Each command class h
| Command | Aliases | File | Delegates To | Key Purpose |
|---------|---------|------|-------------|-------------|
| `spin` | `ignite` | `ignite.ts` | PromptTemplateManager, SwarmSetupService, AgentManager | Launch Claude with workspace context; swarm orchestrator for epics |
| `plan` | — | `plan.ts` | PromptTemplateManager, IssueTrackerFactory, AgentManager | Architect-mode decomposition; optional auto-swarm |
| `plan` | — | `plan.ts` | PromptTemplateManager, IssueTrackerFactory, AgentManager | Architect-mode decomposition; optional auto-swarm. Accepts a tracker issue URL in addition to a bare identifier; parsed via `src/utils/TrackerUrlParser.ts`. PR URLs are rejected; cross-repo GitHub issue URLs are accepted but MCP-backed write features (child issues, dependencies) are skipped with a warning. |

### Git & Commit Commands

Expand Down
1 change: 1 addition & 0 deletions src/commands/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,7 @@ describe('PlanCommand', () => {
expect(mockTrack).toHaveBeenCalledWith('epic.planned', {
child_count: 3,
tracker: 'github',
identifier_source: 'identifier',
})
})

Expand Down
123 changes: 113 additions & 10 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { SettingsManager, PlanCommandSettingsSchema } from '../lib/SettingsManag
import type { EffortLevel } from '../types/index.js'
import { IssueTrackerFactory } from '../lib/IssueTrackerFactory.js'
import { matchIssueIdentifier } from '../utils/IdentifierParser.js'
import { parseTrackerUrl, TrackerUrlError, type TrackerUrlParseResult } from '../utils/TrackerUrlParser.js'
import { validateTrackerUrlAgainstSettings } from '../utils/tracker-url-validation.js'
import { getConfiguredRepoFromSettings } from '../utils/remote.js'
import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFactory.js'
import { needsFirstRunSetup, launchFirstRunSetup } from '../utils/first-run-setup.js'
import type { IssueProvider, ChildIssueResult, DependenciesResult } from '../mcp/types.js'
Expand Down Expand Up @@ -201,7 +204,63 @@ export class PlanCommand {
// Uses shared matchIssueIdentifier() utility to identify issue identifiers:
// - Numeric pattern: #123 or 123 (GitHub format)
// - Project key pattern: ENG-123, PROJ-456 (requires at least 2 letters before dash)
const identifierMatch = prompt ? matchIssueIdentifier(prompt) : { isIssueIdentifier: false }
// URL inputs (GitHub issue, Linear, Jira) are detected first via parseTrackerUrl().
const provider = settings ? IssueTrackerFactory.getProviderName(settings) : 'github'
const issuePrefix = provider === 'github' ? '#' : ''

// URL parsing: try parseTrackerUrl() before falling back to matchIssueIdentifier.
// On a recognized URL, we override the identifier (canonical form) and set
// `urlRepo` so downstream IssueTracker calls receive the correct repo.
let identifierForLookup: string | undefined = prompt
let urlRepo: string | undefined
let identifierSource: 'url' | 'identifier' = 'identifier'

if (prompt) {
let parsed
try {
parsed = parseTrackerUrl(prompt)
} catch (error) {
if (error instanceof TrackerUrlError) {
throw new Error(`Invalid tracker URL: ${error.message}`)
}
throw error
}

if (parsed) {
// Reject GitHub PR URLs — `il plan` is for issues only.
if (parsed.kind === 'pr') {
throw new Error(
`'il plan' decomposes issues, not pull requests. Paste the underlying issue URL or identifier instead.`
)
}

// Provider mismatch + Jira host mismatch (shared with `il start`).
// No PR carve-out for plan since PRs are rejected above.
const configuredJiraHost = settings?.issueManagement?.jira?.host
validateTrackerUrlAgainstSettings(parsed, {
configuredProvider: provider as TrackerUrlParseResult['provider'],
...(configuredJiraHost !== undefined && { configuredJiraHost }),
allowPrCarveOut: false,
})

// Normalize identifier defensively (handles legacy storage casing too).
const issueTracker = IssueTrackerFactory.create(settings)
identifierForLookup = issueTracker.normalizeIdentifier(parsed.identifier)
urlRepo = parsed.repo
identifierSource = 'url'
logger.debug('Parsed tracker URL', {
urlProvider: parsed.provider,
identifier: identifierForLookup,
hasRepo: !!urlRepo,
})
}
}

const identifierMatch = identifierForLookup
? (identifierSource === 'url'
? { isIssueIdentifier: true as const, type: 'numeric' as const, identifier: identifierForLookup }
: matchIssueIdentifier(identifierForLookup))
: { isIssueIdentifier: false }
const looksLikeIssueIdentifier = identifierMatch.isIssueIdentifier
let decompositionContext: {
identifier: string
Expand All @@ -211,21 +270,56 @@ export class PlanCommand {
dependencies?: DependenciesResult
} | null = null

const provider = settings ? IssueTrackerFactory.getProviderName(settings) : 'github'
const issuePrefix = provider === 'github' ? '#' : ''
// Determine if URL repo differs from cwd repo — affects MCP cross-repo handling.
// For non-GitHub providers or when no urlRepo, the comparison is moot (urlRepo is undefined for Linear/Jira).
let isCrossRepo = false
if (urlRepo && provider === 'github') {
try {
const cwdRepo = settings ? await getConfiguredRepoFromSettings(settings) : undefined
if (cwdRepo && cwdRepo.toLowerCase() !== urlRepo.toLowerCase()) {
isCrossRepo = true
}
} catch (error) {
// Narrow catch: only graceful-degrade when the cwd has no
// configured/known GitHub remote. The user supplied a tracker URL
// with an explicit repo, so we already have everything we need
// to identify the issue — assuming cross-repo here just disables
// MCP-backed write features (children/dependencies), which is
// the intended fallback. Any OTHER error (e.g. unrelated I/O,
// programming error) should surface, not be swallowed.
//
// `getConfiguredRepoFromSettings` and `validateConfiguredRemote`
// throw plain `Error` (no typed class), so we match on message
// substrings emitted by those helpers and by `git remote -v`
// when the cwd is not a git repo.
const message = error instanceof Error ? error.message : String(error)
const isMissingRemote =
/GitHub remote not configured/i.test(message) ||
/Configured remote ".*" not found/i.test(message) ||
/Remote ".*" does not exist/i.test(message) ||
/not a git repository/i.test(message)
if (!isMissingRemote) {
throw error
}
logger.debug(
`No configured cwd repo; treating as cross-repo so MCP write features are skipped. ${message}`
)
isCrossRepo = true
}
}

if (prompt && looksLikeIssueIdentifier) {
if (identifierForLookup && looksLikeIssueIdentifier) {
// Validate and fetch issue using issueTracker.detectInputType() pattern from StartCommand
const issueTracker = IssueTrackerFactory.create(settings)

logger.debug('Detected potential issue identifier, validating via issueTracker', { identifier: prompt })
logger.debug('Detected potential issue identifier, validating via issueTracker', { identifier: identifierForLookup, isCrossRepo })

// Use detectInputType to validate the identifier exists (same pattern as StartCommand)
const detection = await issueTracker.detectInputType(prompt)
const detection = await issueTracker.detectInputType(identifierForLookup, urlRepo)

if (detection.type === 'issue' && detection.identifier) {
// Valid issue found - fetch full details for decomposition context
const issue = await issueTracker.fetchIssue(detection.identifier)
const issue = await issueTracker.fetchIssue(detection.identifier, urlRepo)

// Construct the MCP provider once and reuse for body+comments and
// children/dependencies. If construction fails, all MCP fetches are
Expand All @@ -248,7 +342,14 @@ export class PlanCommand {
let bodyForPlan = issue.body
let bodyFromMcp = false
let commentsSection = ''
if (mcpProvider) {
if (mcpProvider && isCrossRepo) {
logger.warn(
`Cross-repo URL detected — skipping MCP getIssue/getChildIssues/getDependencies fetches. ` +
`Cross-repo MCP support is not yet implemented; falling back to issueTracker body. ` +
`(Tracked separately.)`
)
}
if (mcpProvider && !isCrossRepo) {
try {
const mcpIssue = await mcpProvider.getIssue({ number: detection.identifier, includeComments: true })
if (mcpIssue.body) {
Expand Down Expand Up @@ -297,7 +398,7 @@ export class PlanCommand {

// Fetch existing children and dependencies using MCP provider
// This allows users to resume planning where they left off
if (mcpProvider) {
if (mcpProvider && !isCrossRepo) {
try {
// Fetch child issues
logger.debug('Fetching child issues for decomposition context', { identifier: decompositionContext.identifier })
Expand Down Expand Up @@ -330,7 +431,7 @@ export class PlanCommand {
} else {
// Input matched issue pattern but issue not found - treat as regular prompt
logger.debug('Input matched issue pattern but issue not found, treating as planning topic', {
identifier: prompt,
identifier: identifierForLookup,
detectionType: detection.type
})
}
Expand Down Expand Up @@ -460,6 +561,7 @@ export class PlanCommand {
TelemetryService.getInstance().track('auto_swarm.started', {
source: autoSwarmSource,
planner: effectivePlanner,
identifier_source: identifierSource,
})
} catch (error) {
logger.debug(`Telemetry auto_swarm.started tracking failed: ${error instanceof Error ? error.message : error}`)
Expand Down Expand Up @@ -772,6 +874,7 @@ ${initialMessage}`
TelemetryService.getInstance().track('epic.planned', {
child_count: children.length,
tracker: provider,
identifier_source: identifierSource,
})
} catch (error) {
logger.debug(`Telemetry epic.planned tracking failed: ${error instanceof Error ? error.message : error}`)
Expand Down
Loading
Loading