From 48fe03697eef26e29e156b755b8df14bf79ebd28 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 17 Mar 2026 19:04:32 -0400 Subject: [PATCH 1/6] feat(issue-939): add monorepo packagesToRun/packagesToValidate metadata fields and MCP tools - Add optional packagesToRun and packagesToValidate string array fields to MetadataFile, WriteMetadataInput, and LoomMetadata (with empty-array defaults) - Register set_package_to_run MCP tool (single string stored as single-element array) - Register set_packages_to_validate MCP tool (string array stored directly) - Both tools warn but don't fail when package paths don't exist on disk - Use !== undefined guard in writeMetadata to allow clearing arrays to empty - Fix specific error handling (SyntaxError vs ENOENT) in MCP tool catch blocks - Also fix missing swarmTeamName conditional spread in writeMetadata --- src/lib/MetadataManager.test.ts | 10 +++ src/lib/MetadataManager.ts | 11 +++ src/mcp/recap-server.test.ts | 143 ++++++++++++++++++++++++++++++++ src/mcp/recap-server.ts | 110 ++++++++++++++++++++++++ 4 files changed, 274 insertions(+) diff --git a/src/lib/MetadataManager.test.ts b/src/lib/MetadataManager.test.ts index 3fb24b63..d358a095 100644 --- a/src/lib/MetadataManager.test.ts +++ b/src/lib/MetadataManager.test.ts @@ -383,6 +383,8 @@ describe('MetadataManager', () => { dependencyMap: {}, mcpConfigPath: null, swarmTeamName: null, + packagesToRun: [], + packagesToValidate: [], }) }) @@ -517,6 +519,8 @@ describe('MetadataManager', () => { dependencyMap: {}, mcpConfigPath: null, swarmTeamName: null, + packagesToRun: [], + packagesToValidate: [], }) }) @@ -889,6 +893,8 @@ describe('MetadataManager', () => { dependencyMap: {}, mcpConfigPath: null, swarmTeamName: null, + packagesToRun: [], + packagesToValidate: [], }) expect(result[1]).toEqual({ description: 'Project 2 loom', @@ -917,6 +923,8 @@ describe('MetadataManager', () => { dependencyMap: {}, mcpConfigPath: null, swarmTeamName: null, + packagesToRun: [], + packagesToValidate: [], }) }) @@ -1029,6 +1037,8 @@ describe('MetadataManager', () => { dependencyMap: {}, mcpConfigPath: null, swarmTeamName: null, + packagesToRun: [], + packagesToValidate: [], }) }) diff --git a/src/lib/MetadataManager.ts b/src/lib/MetadataManager.ts index ac5a59d7..6ae9f8d5 100644 --- a/src/lib/MetadataManager.ts +++ b/src/lib/MetadataManager.ts @@ -52,6 +52,8 @@ export interface MetadataFile { dependencyMap?: Record // issueNumber -> array of blocking issueNumbers mcpConfigPath?: string // Path to per-loom MCP config file (for swarm claude -p commands) swarmTeamName?: string // Unique team name for swarm orchestrator (persisted for re-runs) + packagesToRun?: string[] // Monorepo packages to run dev server for (relative paths from repo root) + packagesToValidate?: string[] // Monorepo packages to scope validation to (relative paths from repo root) } /** @@ -97,6 +99,8 @@ export interface WriteMetadataInput { dependencyMap?: Record // issueNumber -> array of blocking issueNumbers mcpConfigPath?: string // Path to per-loom MCP config file (for swarm claude -p commands) swarmTeamName?: string // Unique team name for swarm orchestrator (persisted for re-runs) + packagesToRun?: string[] // Monorepo packages to run dev server for (relative paths from repo root) + packagesToValidate?: string[] // Monorepo packages to scope validation to (relative paths from repo root) } /** @@ -143,6 +147,8 @@ export interface LoomMetadata { dependencyMap: Record mcpConfigPath: string | null // Path to per-loom MCP config file (null for non-swarm looms) swarmTeamName: string | null // Unique team name for swarm orchestrator (null for non-swarm looms) + packagesToRun: string[] // Monorepo packages to run dev server for (empty for non-monorepo looms) + packagesToValidate: string[] // Monorepo packages to scope validation to (empty for non-monorepo looms) } /** @@ -196,6 +202,8 @@ export class MetadataManager { dependencyMap: data.dependencyMap ?? {}, mcpConfigPath: data.mcpConfigPath ?? null, swarmTeamName: data.swarmTeamName ?? null, + packagesToRun: data.packagesToRun ?? [], + packagesToValidate: data.packagesToValidate ?? [], } } @@ -282,6 +290,9 @@ export class MetadataManager { ...(input.childIssues && input.childIssues.length > 0 && { childIssues: input.childIssues }), ...(input.dependencyMap && Object.keys(input.dependencyMap).length > 0 && { dependencyMap: input.dependencyMap }), ...(input.mcpConfigPath && { mcpConfigPath: input.mcpConfigPath }), + ...(input.swarmTeamName && { swarmTeamName: input.swarmTeamName }), + ...(input.packagesToRun !== undefined && { packagesToRun: input.packagesToRun }), + ...(input.packagesToValidate !== undefined && { packagesToValidate: input.packagesToValidate }), } // 3. Write to slugified filename diff --git a/src/mcp/recap-server.test.ts b/src/mcp/recap-server.test.ts index 9a2a1be8..9a20ce4b 100644 --- a/src/mcp/recap-server.test.ts +++ b/src/mcp/recap-server.test.ts @@ -547,3 +547,146 @@ describe('recap-server worktreePath resolution', () => { }) }) }) + +/** + * Simulates the set_package_to_run logic from recap-server.ts + * Reads metadata file, updates packagesToRun as single-element array, writes back + */ +async function setPackageToRun( + readMetadata: () => Promise, + writeMetadata: (metadata: MetadataFile) => Promise, + pkg: string +): Promise<{ success: true; packagesToRun: string[] }> { + const metadata = await readMetadata() + metadata.packagesToRun = [pkg] + await writeMetadata(metadata) + return { success: true, packagesToRun: [pkg] } +} + +/** + * Simulates the set_packages_to_validate logic from recap-server.ts + * Reads metadata file, updates packagesToValidate, writes back + */ +async function setPackagesToValidate( + readMetadata: () => Promise, + writeMetadata: (metadata: MetadataFile) => Promise, + packages: string[] +): Promise<{ success: true; packagesToValidate: string[] }> { + const metadata = await readMetadata() + metadata.packagesToValidate = packages + await writeMetadata(metadata) + return { success: true, packagesToValidate: packages } +} + +describe('recap-server monorepo package tools', () => { + let mockMetadataFile: MetadataFile + let readMetadataMock: () => Promise + let writeMetadataMock: (metadata: MetadataFile) => Promise + + beforeEach(() => { + mockMetadataFile = { + description: 'Test loom', + version: 1, + branchName: 'issue-42__test', + worktreePath: '/Users/test/dev/repo', + projectPath: '/Users/test/dev/repo', + } + + readMetadataMock = vi.fn().mockImplementation(async () => ({ ...mockMetadataFile })) + writeMetadataMock = vi.fn().mockImplementation(async (metadata: MetadataFile) => { + mockMetadataFile = { ...metadata } + }) + }) + + describe('set_package_to_run', () => { + it('should store single package as array', async () => { + const result = await setPackageToRun(readMetadataMock, writeMetadataMock, 'services/admin-api') + + expect(result.success).toBe(true) + expect(result.packagesToRun).toEqual(['services/admin-api']) + expect(mockMetadataFile.packagesToRun).toEqual(['services/admin-api']) + }) + + it('should overwrite existing packagesToRun', async () => { + mockMetadataFile.packagesToRun = ['services/old-api'] + + const result = await setPackageToRun(readMetadataMock, writeMetadataMock, 'services/new-api') + + expect(result.packagesToRun).toEqual(['services/new-api']) + expect(mockMetadataFile.packagesToRun).toEqual(['services/new-api']) + }) + + it('should always store as single-element array', async () => { + await setPackageToRun(readMetadataMock, writeMetadataMock, 'packages/ui') + + expect(mockMetadataFile.packagesToRun).toHaveLength(1) + }) + + it('should preserve other metadata fields when setting packagesToRun', async () => { + mockMetadataFile.description = 'Important loom' + mockMetadataFile.branchName = 'issue-99__feature' + + await setPackageToRun(readMetadataMock, writeMetadataMock, 'apps/frontend') + + expect(mockMetadataFile.description).toBe('Important loom') + expect(mockMetadataFile.branchName).toBe('issue-99__feature') + expect(mockMetadataFile.packagesToRun).toEqual(['apps/frontend']) + }) + + it('should call writeMetadata with updated metadata', async () => { + await setPackageToRun(readMetadataMock, writeMetadataMock, 'services/api') + + expect(writeMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ packagesToRun: ['services/api'] }) + ) + }) + }) + + describe('set_packages_to_validate', () => { + it('should store array of packages', async () => { + const packages = ['services/admin-api', 'packages/shared'] + const result = await setPackagesToValidate(readMetadataMock, writeMetadataMock, packages) + + expect(result.success).toBe(true) + expect(result.packagesToValidate).toEqual(packages) + expect(mockMetadataFile.packagesToValidate).toEqual(packages) + }) + + it('should accept empty array', async () => { + const result = await setPackagesToValidate(readMetadataMock, writeMetadataMock, []) + + expect(result.packagesToValidate).toEqual([]) + expect(mockMetadataFile.packagesToValidate).toEqual([]) + }) + + it('should overwrite existing packagesToValidate', async () => { + mockMetadataFile.packagesToValidate = ['services/old-api'] + const newPackages = ['services/new-api', 'packages/ui'] + + const result = await setPackagesToValidate(readMetadataMock, writeMetadataMock, newPackages) + + expect(result.packagesToValidate).toEqual(newPackages) + expect(mockMetadataFile.packagesToValidate).toEqual(newPackages) + }) + + it('should preserve other metadata fields when setting packagesToValidate', async () => { + mockMetadataFile.description = 'Important loom' + mockMetadataFile.branchName = 'issue-99__feature' + + await setPackagesToValidate(readMetadataMock, writeMetadataMock, ['services/api']) + + expect(mockMetadataFile.description).toBe('Important loom') + expect(mockMetadataFile.branchName).toBe('issue-99__feature') + expect(mockMetadataFile.packagesToValidate).toEqual(['services/api']) + }) + + it('should call writeMetadata with updated metadata', async () => { + const packages = ['services/api', 'apps/web'] + await setPackagesToValidate(readMetadataMock, writeMetadataMock, packages) + + expect(writeMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ packagesToValidate: packages }) + ) + }) + }) +}) diff --git a/src/mcp/recap-server.ts b/src/mcp/recap-server.ts index 729d2f4f..017559de 100644 --- a/src/mcp/recap-server.ts +++ b/src/mcp/recap-server.ts @@ -487,6 +487,116 @@ server.registerTool( } ) +// Register set_package_to_run tool +server.registerTool( + 'set_package_to_run', + { + title: 'Set Package to Run', + description: 'Set which monorepo package to run a dev server for. Stores as single-element array in loom metadata.', + inputSchema: { + package: z.string().describe('Relative directory path from repo root (e.g., "services/admin-api")'), + worktreePath: z.string().optional().describe('Optional worktree path to scope to a specific loom'), + }, + outputSchema: { + success: z.literal(true), + packagesToRun: z.array(z.string()), + }, + }, + async ({ package: pkg, worktreePath }) => { + const metadataFilePath = resolveMetadataFilePath(worktreePath) + + // Read existing metadata + let metadata: MetadataFile + try { + const content = await fs.readFile(metadataFilePath, 'utf8') + metadata = JSON.parse(content) as MetadataFile + } catch (error: unknown) { + if (error instanceof SyntaxError) { + throw new Error(`Metadata file contains invalid JSON at ${metadataFilePath}`) + } + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Metadata file not found at ${metadataFilePath}`) + } + throw error + } + + // Warn if package path doesn't exist on disk (relative to project path or worktree path) + const basePath = metadata.projectPath ?? (worktreePath ?? process.cwd()) + const pkgAbsPath = path.join(basePath, pkg) + if (!(await fs.pathExists(pkgAbsPath))) { + console.error(`Warning: Package path does not exist on disk: ${pkgAbsPath}`) + } + + // Update packagesToRun as single-element array + metadata.packagesToRun = [pkg] + + // Write back + await fs.writeFile(metadataFilePath, JSON.stringify(metadata, null, 2), { mode: 0o644 }) + + const result = { success: true as const, packagesToRun: [pkg] } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + } + } +) + +// Register set_packages_to_validate tool +server.registerTool( + 'set_packages_to_validate', + { + title: 'Set Packages to Validate', + description: 'Set which monorepo packages to scope validation to. Stores array in loom metadata.', + inputSchema: { + packages: z.array(z.string()).describe('Array of relative directory paths from repo root (e.g., ["services/admin-api", "packages/shared"])'), + worktreePath: z.string().optional().describe('Optional worktree path to scope to a specific loom'), + }, + outputSchema: { + success: z.literal(true), + packagesToValidate: z.array(z.string()), + }, + }, + async ({ packages, worktreePath }) => { + const metadataFilePath = resolveMetadataFilePath(worktreePath) + + // Read existing metadata + let metadata: MetadataFile + try { + const content = await fs.readFile(metadataFilePath, 'utf8') + metadata = JSON.parse(content) as MetadataFile + } catch (error: unknown) { + if (error instanceof SyntaxError) { + throw new Error(`Metadata file contains invalid JSON at ${metadataFilePath}`) + } + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Metadata file not found at ${metadataFilePath}`) + } + throw error + } + + // Warn for each package path that doesn't exist on disk + const basePath = metadata.projectPath ?? (worktreePath ?? process.cwd()) + for (const pkg of packages) { + const pkgAbsPath = path.join(basePath, pkg) + if (!(await fs.pathExists(pkgAbsPath))) { + console.error(`Warning: Package path does not exist on disk: ${pkgAbsPath}`) + } + } + + // Update packagesToValidate + metadata.packagesToValidate = packages + + // Write back + await fs.writeFile(metadataFilePath, JSON.stringify(metadata, null, 2), { mode: 0o644 }) + + const result = { success: true as const, packagesToValidate: packages } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + structuredContent: result, + } + } +) + // Main server startup async function main(): Promise { console.error('=== Loom Recap MCP Server Starting ===') From 49452b75db2a8a68bbb17829e171123c6ff53d6e Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 17 Mar 2026 19:04:34 -0400 Subject: [PATCH 2/6] feat(issue-940): add monorepo capability with init-time detection - Add 'monorepo' to ProjectCapability union type and Zod schema - Auto-detect monorepo from pnpm-workspace.yaml and package.json workspaces field in ProjectCapabilityDetector - Update iloom-framework-detector agent template to recognize monorepo markers - Update init-prompt.txt to include monorepo as a project type option - Add helpful error message in open command for monorepo-only projects - Add docs for project capabilities including monorepo in iloom-commands.md - Fix type-safe mock casts in ProjectCapabilityDetector tests --- docs/iloom-commands.md | 23 +++- src/commands/open.ts | 4 + src/lib/ProjectCapabilityDetector.test.ts | 110 +++++++++++++++++++ src/lib/ProjectCapabilityDetector.ts | 10 ++ src/types/loom.ts | 2 +- src/utils/package-json.ts | 7 +- templates/agents/iloom-framework-detector.md | 1 + templates/prompts/init-prompt.txt | 2 + 8 files changed, 154 insertions(+), 5 deletions(-) diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index f8603228..9fd7e5ab 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -2162,10 +2162,31 @@ il init "configure neon database with project ID abc-123" - IDE preference (VS Code, Cursor, Windsurf, etc.) - Merge behavior (local, pr, draft-pr) - Permission modes -- Project type (web app, CLI tool, etc.) +- Project type (web app, CLI tool, monorepo, etc.) - Base port for development servers - Environment variable names +**Project Capabilities:** + +iloom detects and persists project capabilities in `.iloom/package.iloom.json` under the `capabilities` field. Valid values are: + +| Capability | Description | Auto-detected from | +|------------|-------------|-------------------| +| `"web"` | Web application with a dev server | React, Next.js, Vite, and other web framework dependencies | +| `"cli"` | Command-line tool with a `bin` entry | `bin` field in `package.json` | +| `"monorepo"` | Monorepo with multiple workspace packages | `pnpm-workspace.yaml` file OR `workspaces` field in `package.json` | + +Capabilities can also be set manually in `.iloom/package.iloom.json`: +```json +{ + "capabilities": ["monorepo"] +} +``` + +The `monorepo` capability is auto-detected during `il init` when either of these workspace configuration files are present: +- `pnpm-workspace.yaml` — used by pnpm workspaces +- `workspaces` field in `package.json` — used by yarn and npm workspaces + **Jira Advanced Settings:** The following Jira settings can be configured in `.iloom/settings.json` under `issueManagement.jira`: diff --git a/src/commands/open.ts b/src/commands/open.ts index 3e3c15be..03ba9d57 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -66,6 +66,10 @@ export class OpenCommand { await this.openWebBrowser(worktree, input.env) } else if (capabilities.includes('cli')) { await this.runCLITool(worktree.path, binEntries, input.args ?? []) + } else if (capabilities.includes('monorepo')) { + throw new Error( + `This is a monorepo root package. Use 'il open' from a specific package worktree, or run the desired package's dev server or CLI directly.` + ) } else { throw new Error( `No web or CLI capabilities detected for workspace at ${worktree.path}` diff --git a/src/lib/ProjectCapabilityDetector.test.ts b/src/lib/ProjectCapabilityDetector.test.ts index bc21c00c..87901150 100644 --- a/src/lib/ProjectCapabilityDetector.test.ts +++ b/src/lib/ProjectCapabilityDetector.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ProjectCapabilityDetector } from './ProjectCapabilityDetector.js' import * as packageJsonUtils from '../utils/package-json.js' import type { PackageJson } from '../utils/package-json.js' +import fs from 'fs-extra' vi.mock('../utils/package-json.js', () => ({ getPackageConfig: vi.fn(), @@ -10,6 +11,12 @@ vi.mock('../utils/package-json.js', () => ({ getExplicitCapabilities: vi.fn() })) +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn() + } +})) + describe('ProjectCapabilityDetector', () => { let detector: ProjectCapabilityDetector @@ -18,6 +25,8 @@ describe('ProjectCapabilityDetector', () => { detector = new ProjectCapabilityDetector() // Default: no explicit capabilities (fallback to package.json detection) vi.mocked(packageJsonUtils.getExplicitCapabilities).mockReturnValue([]) + // Default: no pnpm-workspace.yaml + vi.mocked(fs.pathExists as (path: string) => Promise).mockResolvedValue(false) }) describe('detectCapabilities', () => { @@ -329,4 +338,105 @@ describe('ProjectCapabilityDetector', () => { expect(result.binEntries).toEqual({}) }) }) + + describe('monorepo detection', () => { + it('should detect monorepo from pnpm-workspace.yaml', async () => { + const mockPackageJson: PackageJson = { + name: 'my-monorepo', + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(fs.pathExists as (path: string) => Promise).mockResolvedValue(true) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('monorepo') + expect(result.capabilities).not.toContain('cli') + expect(result.capabilities).not.toContain('web') + }) + + it('should detect monorepo from package.json workspaces field (array)', async () => { + const mockPackageJson: PackageJson = { + name: 'my-monorepo', + workspaces: ['packages/*'], + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + // pathExists returns false (no pnpm-workspace.yaml), falls through to workspaces check + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('monorepo') + }) + + it('should detect monorepo from package.json workspaces field (object form)', async () => { + const mockPackageJson: PackageJson = { + name: 'my-monorepo', + workspaces: { packages: ['packages/*'] }, + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('monorepo') + }) + + it('should combine monorepo with cli capability', async () => { + const mockPackageJson: PackageJson = { + name: 'my-monorepo-cli', + bin: { 'my-cli': './dist/cli.js' }, + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({ 'my-cli': './dist/cli.js' }) + vi.mocked(fs.pathExists as (path: string) => Promise).mockResolvedValue(true) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toContain('cli') + expect(result.capabilities).toContain('monorepo') + }) + + it('should detect monorepo from explicit capabilities in package.iloom.json', async () => { + const mockIloomPackage: PackageJson = { + name: 'my-monorepo', + capabilities: ['monorepo', 'web'] + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockIloomPackage) + vi.mocked(packageJsonUtils.getExplicitCapabilities).mockReturnValueOnce(['monorepo', 'web']) + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toEqual(['monorepo', 'web']) + expect(result.binEntries).toEqual({}) + }) + + it('should not set monorepo capability when no workspace markers exist', async () => { + const mockPackageJson: PackageJson = { + name: 'regular-cli', + bin: { 'regular-cli': './dist/cli.js' }, + dependencies: {} + } + + vi.mocked(packageJsonUtils.getPackageConfig).mockResolvedValueOnce(mockPackageJson) + vi.mocked(packageJsonUtils.hasWebDependencies).mockReturnValueOnce(false) + vi.mocked(packageJsonUtils.parseBinField).mockReturnValueOnce({ 'regular-cli': './dist/cli.js' }) + // pathExists returns false (default) and no workspaces field + + const result = await detector.detectCapabilities('/test/path') + + expect(result.capabilities).toEqual(['cli']) + expect(result.capabilities).not.toContain('monorepo') + }) + }) }) diff --git a/src/lib/ProjectCapabilityDetector.ts b/src/lib/ProjectCapabilityDetector.ts index 7c9a7b03..5c9f6ad4 100644 --- a/src/lib/ProjectCapabilityDetector.ts +++ b/src/lib/ProjectCapabilityDetector.ts @@ -1,3 +1,5 @@ +import fs from 'fs-extra' +import path from 'path' import { getPackageConfig, parseBinField, hasWebDependencies, getExplicitCapabilities } from '../utils/package-json.js' import type { ProjectCapability } from '../types/loom.js' @@ -42,6 +44,14 @@ export class ProjectCapabilityDetector { capabilities.push('web') } + // Monorepo detection: has pnpm-workspace.yaml or package.json workspaces field + const pnpmWorkspacePath = path.join(worktreePath, 'pnpm-workspace.yaml') + if (await fs.pathExists(pnpmWorkspacePath)) { + capabilities.push('monorepo') + } else if (pkgJson.workspaces) { + capabilities.push('monorepo') + } + // Parse bin entries for CLI projects const binEntries = pkgJson.bin ? parseBinField(pkgJson.bin, pkgJson.name) : {} diff --git a/src/types/loom.ts b/src/types/loom.ts index 7b74f655..bcc4deea 100644 --- a/src/types/loom.ts +++ b/src/types/loom.ts @@ -1,4 +1,4 @@ -export type ProjectCapability = 'cli' | 'web' +export type ProjectCapability = 'cli' | 'web' | 'monorepo' export type Capability = ProjectCapability export interface Loom { diff --git a/src/utils/package-json.ts b/src/utils/package-json.ts index e3730869..5c7a1d7d 100644 --- a/src/utils/package-json.ts +++ b/src/utils/package-json.ts @@ -16,8 +16,8 @@ export const ILOOM_PACKAGE_LOCAL_PATH = '.iloom/package.iloom.local.json' * Defines project capabilities and custom shell commands for non-Node.js projects */ export const PackageIloomSchema = z.object({ - capabilities: z.array(z.enum(['cli', 'web'])).optional() - .describe('Project capabilities - "cli" for command-line tools (enables CLI isolation), "web" for web applications (enables port assignment and dev server)'), + capabilities: z.array(z.enum(['cli', 'web', 'monorepo'])).optional() + .describe('Project capabilities - "cli" for command-line tools (enables CLI isolation), "web" for web applications (enables port assignment and dev server), "monorepo" for monorepo projects with workspace packages (enables package-aware commands)'), scripts: z.object({ install: z.string().optional().describe('Install command (e.g., "bundle install", "poetry install")'), build: z.string().optional().describe('Build/compile command'), @@ -37,6 +37,7 @@ export interface PackageJson { devDependencies?: Record scripts?: Record capabilities?: ProjectCapability[] + workspaces?: string[] | { packages: string[] } [key: string]: unknown } @@ -263,7 +264,7 @@ export async function getPackageScripts(dir: string): Promise Date: Tue, 17 Mar 2026 19:35:11 -0400 Subject: [PATCH 3/6] feat(issue-942): scope il test/lint/compile/build to monorepo packages from metadata When packagesToValidate is set in loom metadata, il test, il lint, il compile, and il build now scope commands to only those packages using the detected package manager's filter syntax (pnpm --filter, npm --workspace, yarn workspaces foreach). - Add packages option to RunScriptOptions and buildMonorepoFilterArgs helper - ScriptCommandBase reads packagesToValidate from MetadataManager and passes to runScript - CompileCommand.execute override updated with same scoping logic - ValidationRunner accepts packagesToValidate in ValidationOptions and passes to all steps - Auto-fix Claude agents receive --append-system-prompt instructing them to use il commands - finish.ts reads packagesToValidate from metadata and passes to runValidations - Packages missing the target script are gracefully skipped via --if-present flag - Tests updated and new monorepo scoping tests added to ValidationRunner.test.ts --- src/commands/build.test.ts | 11 ++- src/commands/compile.test.ts | 12 ++- src/commands/compile.ts | 11 ++- src/commands/finish.ts | 17 ++-- src/commands/lint.test.ts | 11 ++- src/commands/script-command-base.ts | 31 +++++++- src/commands/test.test.ts | 11 ++- src/lib/ValidationRunner.test.ts | 118 ++++++++++++++++++++++++++-- src/lib/ValidationRunner.ts | 80 +++++++++++++------ src/types/index.ts | 6 ++ src/utils/package-manager.ts | 70 ++++++++++++++++- 11 files changed, 329 insertions(+), 49 deletions(-) diff --git a/src/commands/build.test.ts b/src/commands/build.test.ts index 3f5434ac..fa592a07 100644 --- a/src/commands/build.test.ts +++ b/src/commands/build.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { BuildCommand } from './build.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import type { GitWorktree } from '../types/worktree.js' import * as packageJson from '../utils/package-json.js' import * as packageManager from '../utils/package-manager.js' @@ -12,6 +13,7 @@ vi.mock('../utils/IdentifierParser.js', () => ({ parseForPatternDetection: vi.fn(), })), })) +vi.mock('../lib/MetadataManager.js') // Mock package utilities vi.mock('../utils/package-json.js', () => ({ @@ -47,6 +49,10 @@ describe('BuildCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() command = new BuildCommand(mockGitWorktreeManager) + // Set up MetadataManager mock to return no packagesToValidate by default + vi.mocked(MetadataManager).mockImplementation(() => ({ + readMetadata: vi.fn().mockResolvedValue(null), + } as unknown as MetadataManager)) }) describe('identifier parsing', () => { @@ -138,7 +144,7 @@ describe('BuildCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('build', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('build', mockWorktree.path, [], { packages: [] }) }) it('should pass worktree path to runScript()', async () => { @@ -152,7 +158,8 @@ describe('BuildCommand', () => { expect(packageManager.runScript).toHaveBeenCalledWith( 'build', mockWorktree.path, - [] + [], + { packages: [] } ) }) }) diff --git a/src/commands/compile.test.ts b/src/commands/compile.test.ts index 6c3b106d..c9c4291c 100644 --- a/src/commands/compile.test.ts +++ b/src/commands/compile.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { CompileCommand } from './compile.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import type { GitWorktree } from '../types/worktree.js' import * as packageJson from '../utils/package-json.js' import * as packageManager from '../utils/package-manager.js' @@ -12,6 +13,7 @@ vi.mock('../utils/IdentifierParser.js', () => ({ parseForPatternDetection: vi.fn(), })), })) +vi.mock('../lib/MetadataManager.js') // Mock package utilities vi.mock('../utils/package-json.js', () => ({ @@ -47,6 +49,10 @@ describe('CompileCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() command = new CompileCommand(mockGitWorktreeManager) + // Set up MetadataManager mock to return no packagesToValidate by default + vi.mocked(MetadataManager).mockImplementation(() => ({ + readMetadata: vi.fn().mockResolvedValue(null), + } as unknown as MetadataManager)) }) describe('identifier parsing', () => { @@ -116,7 +122,7 @@ describe('CompileCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('compile', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('compile', mockWorktree.path, [], { packages: [] }) }) it('should fallback to typecheck if compile does not exist', async () => { @@ -127,7 +133,7 @@ describe('CompileCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('typecheck', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('typecheck', mockWorktree.path, [], { packages: [] }) }) it('should skip silently if neither compile nor typecheck exist', async () => { @@ -152,7 +158,7 @@ describe('CompileCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('compile', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('compile', mockWorktree.path, [], { packages: [] }) expect(packageManager.runScript).toHaveBeenCalledTimes(1) }) }) diff --git a/src/commands/compile.ts b/src/commands/compile.ts index a2a29de4..4459d6c6 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -56,10 +56,17 @@ export class CompileCommand extends ScriptCommandBase { return } - // 5. Run the found script + // 5. Read packagesToValidate from loom metadata for monorepo scoping + const packages = await this.readPackagesToValidate(worktree.path) + + if (packages.length > 0) { + logger.info(`Scoping Compile/Typecheck to packages: ${packages.join(', ')}`) + } + + // 6. Run the found script const displayName = scriptToRun === 'compile' ? 'Compile' : 'Typecheck' logger.info(`Running ${displayName}...`) - await runScript(scriptToRun, worktree.path, []) + await runScript(scriptToRun, worktree.path, [], { packages }) logger.success(`${displayName} completed successfully`) } } diff --git a/src/commands/finish.ts b/src/commands/finish.ts index 320a945b..2fcaf78d 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -260,12 +260,16 @@ export class FinishCommand { // Read metadata BEFORE workflow execution (cleanup may delete the worktree) let preFinishCreatedAt: string | undefined + let preFinishPackagesToValidate: string[] = [] try { const metadataManager = new MetadataManager() const metadata = await metadataManager.readMetadata(worktree.path) preFinishCreatedAt = metadata?.created_at ?? undefined + preFinishPackagesToValidate = metadata?.packagesToValidate ?? [] } catch (error: unknown) { - getLogger().debug(`Failed to read metadata for telemetry: ${error instanceof Error ? error.message : String(error)}`) + // Non-fatal: metadata is used for telemetry and validation scoping. + // If unreadable, validation will run un-scoped (full project). + getLogger().debug(`Failed to read metadata: ${error instanceof Error ? error.message : String(error)}`) } // Step 4: Branch based on input type @@ -279,10 +283,10 @@ export class FinishCommand { throw new Error('Issue tracker does not support pull requests') } const pr = await this.issueTracker.fetchPR(parsed.number, repo) - await this.executePRWorkflow(parsed, input.options, worktree, pr, result) + await this.executePRWorkflow(parsed, input.options, worktree, pr, result, preFinishPackagesToValidate) } else { // Execute traditional issue/branch workflow - await this.executeIssueWorkflow(parsed, input.options, worktree, result) + await this.executeIssueWorkflow(parsed, input.options, worktree, result, preFinishPackagesToValidate) } // Mark overall success if we got here without throwing @@ -657,7 +661,8 @@ export class FinishCommand { parsed: ParsedFinishInput, options: FinishOptions, worktree: GitWorktree, - result: FinishResult + result: FinishResult, + packagesToValidate: string[] = [] ): Promise { // Define merge options early so they're available for all code paths const mergeOptions: MergeOptions = { @@ -705,6 +710,7 @@ export class FinishCommand { await this.validationRunner.runValidations(worktree.path, { dryRun: options.dryRun ?? false, jsonStream: options.jsonStream ?? false, + packagesToValidate, }) getLogger().success('All validations passed') result.operations.push({ @@ -985,7 +991,8 @@ export class FinishCommand { options: FinishOptions, worktree: GitWorktree, pr: PullRequest, - result: FinishResult + result: FinishResult, + _packagesToValidate: string[] = [] ): Promise { // Branch based on PR state if (pr.state === 'closed' || pr.state === 'merged') { diff --git a/src/commands/lint.test.ts b/src/commands/lint.test.ts index 0dbba24b..2b8ca1dc 100644 --- a/src/commands/lint.test.ts +++ b/src/commands/lint.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { LintCommand } from './lint.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import type { GitWorktree } from '../types/worktree.js' import * as packageJson from '../utils/package-json.js' import * as packageManager from '../utils/package-manager.js' @@ -12,6 +13,7 @@ vi.mock('../utils/IdentifierParser.js', () => ({ parseForPatternDetection: vi.fn(), })), })) +vi.mock('../lib/MetadataManager.js') // Mock package utilities vi.mock('../utils/package-json.js', () => ({ @@ -47,6 +49,10 @@ describe('LintCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() command = new LintCommand(mockGitWorktreeManager) + // Set up MetadataManager mock to return no packagesToValidate by default + vi.mocked(MetadataManager).mockImplementation(() => ({ + readMetadata: vi.fn().mockResolvedValue(null), + } as unknown as MetadataManager)) }) describe('identifier parsing', () => { @@ -115,7 +121,7 @@ describe('LintCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('lint', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('lint', mockWorktree.path, [], { packages: [] }) }) it('should pass worktree path to runScript()', async () => { @@ -129,7 +135,8 @@ describe('LintCommand', () => { expect(packageManager.runScript).toHaveBeenCalledWith( 'lint', mockWorktree.path, - [] + [], + { packages: [] } ) }) }) diff --git a/src/commands/script-command-base.ts b/src/commands/script-command-base.ts index 2c9b12d7..9b5e9c3d 100644 --- a/src/commands/script-command-base.ts +++ b/src/commands/script-command-base.ts @@ -1,5 +1,6 @@ import path from 'path' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { runScript } from '../utils/package-manager.js' import { getPackageScripts } from '../utils/package-json.js' @@ -65,12 +66,38 @@ export abstract class ScriptCommandBase { throw new Error(`No ${scriptName} script defined in package.json or package.iloom.json`) } - // 4. Run the script + // 4. Read packagesToValidate from loom metadata for monorepo scoping + const packages = await this.readPackagesToValidate(worktree.path) + + if (packages.length > 0) { + logger.info(`Scoping ${this.getScriptDisplayName()} to packages: ${packages.join(', ')}`) + } + + // 5. Run the script logger.info(`Running ${this.getScriptDisplayName()}...`) - await runScript(scriptName, worktree.path, []) + await runScript(scriptName, worktree.path, [], { packages }) logger.success(`${this.getScriptDisplayName()} completed successfully`) } + /** + * Read packagesToValidate from loom metadata. + * Returns empty array if the metadata file does not exist (graceful degradation for non-loom worktrees). + * Rethrows unexpected errors. + */ + protected async readPackagesToValidate(worktreePath: string): Promise { + try { + const metadataManager = new MetadataManager() + const metadata = await metadataManager.readMetadata(worktreePath) + return metadata?.packagesToValidate ?? [] + } catch (error: unknown) { + // Only suppress ENOENT (metadata file not found) — all other errors propagate + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } + } + /** * Parse explicit identifier input */ diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 097dd2b8..20839682 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { TestCommand } from './test.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import type { GitWorktree } from '../types/worktree.js' import * as packageJson from '../utils/package-json.js' import * as packageManager from '../utils/package-manager.js' @@ -12,6 +13,7 @@ vi.mock('../utils/IdentifierParser.js', () => ({ parseForPatternDetection: vi.fn(), })), })) +vi.mock('../lib/MetadataManager.js') // Mock package utilities vi.mock('../utils/package-json.js', () => ({ @@ -47,6 +49,10 @@ describe('TestCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() command = new TestCommand(mockGitWorktreeManager) + // Set up MetadataManager mock to return no packagesToValidate by default + vi.mocked(MetadataManager).mockImplementation(() => ({ + readMetadata: vi.fn().mockResolvedValue(null), + } as unknown as MetadataManager)) }) describe('identifier parsing', () => { @@ -115,7 +121,7 @@ describe('TestCommand', () => { await command.execute({}) - expect(packageManager.runScript).toHaveBeenCalledWith('test', mockWorktree.path, []) + expect(packageManager.runScript).toHaveBeenCalledWith('test', mockWorktree.path, [], { packages: [] }) }) it('should pass worktree path to runScript()', async () => { @@ -129,7 +135,8 @@ describe('TestCommand', () => { expect(packageManager.runScript).toHaveBeenCalledWith( 'test', mockWorktree.path, - [] + [], + { packages: [] } ) }) }) diff --git a/src/lib/ValidationRunner.test.ts b/src/lib/ValidationRunner.test.ts index de5a5d96..4b661150 100644 --- a/src/lib/ValidationRunner.test.ts +++ b/src/lib/ValidationRunner.test.ts @@ -208,7 +208,7 @@ describe('ValidationRunner', () => { 'compile', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) expect(packageManager.runScript).not.toHaveBeenCalledWith( 'typecheck', @@ -238,7 +238,7 @@ describe('ValidationRunner', () => { 'typecheck', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) }) @@ -262,7 +262,7 @@ describe('ValidationRunner', () => { 'compile', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) }) @@ -396,7 +396,7 @@ describe('ValidationRunner', () => { 'typecheck', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) }) @@ -489,7 +489,7 @@ describe('ValidationRunner', () => { 'lint', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) }) @@ -561,7 +561,7 @@ describe('ValidationRunner', () => { 'test', '/test/worktree', [], - { quiet: true } + { quiet: true, packages: [] } ) }) @@ -1360,4 +1360,110 @@ describe('ValidationRunner', () => { }) }) }) + + describe('Monorepo Package Scoping', () => { + it('should pass packages to runScript when packagesToValidate is set for lint', async () => { + vi.mocked(packageJson.getPackageConfig).mockResolvedValue({ + name: 'test', + scripts: { lint: 'eslint .' }, + }) + vi.mocked(packageJson.hasScript).mockImplementation( + (_, script) => script === 'lint' + ) + vi.mocked(packageManager.detectPackageManager).mockResolvedValue('pnpm') + vi.mocked(packageManager.runScript).mockResolvedValue() + + const result = await runner.runValidations('/test/worktree', { + skipTypecheck: true, + skipTests: true, + packagesToValidate: ['packages/core', 'packages/api'], + }) + + expect(result.success).toBe(true) + expect(packageManager.runScript).toHaveBeenCalledWith( + 'lint', + '/test/worktree', + [], + { quiet: true, packages: ['packages/core', 'packages/api'] } + ) + }) + + it('should pass packages to runScript when packagesToValidate is set for tests', async () => { + vi.mocked(packageJson.getPackageConfig).mockResolvedValue({ + name: 'test', + scripts: { test: 'vitest run' }, + }) + vi.mocked(packageJson.hasScript).mockImplementation( + (_, script) => script === 'test' + ) + vi.mocked(packageManager.detectPackageManager).mockResolvedValue('pnpm') + vi.mocked(packageManager.runScript).mockResolvedValue() + + const result = await runner.runValidations('/test/worktree', { + skipTypecheck: true, + skipLint: true, + packagesToValidate: ['services/auth'], + }) + + expect(result.success).toBe(true) + expect(packageManager.runScript).toHaveBeenCalledWith( + 'test', + '/test/worktree', + [], + { quiet: true, packages: ['services/auth'] } + ) + }) + + it('should pass packages to runScript when packagesToValidate is set for typecheck', async () => { + vi.mocked(packageJson.getPackageConfig).mockResolvedValue({ + name: 'test', + scripts: { compile: 'tsc --build' }, + }) + vi.mocked(packageJson.hasScript).mockImplementation( + (_, script) => script === 'compile' + ) + vi.mocked(packageManager.detectPackageManager).mockResolvedValue('pnpm') + vi.mocked(packageManager.runScript).mockResolvedValue() + + const result = await runner.runValidations('/test/worktree', { + skipLint: true, + skipTests: true, + packagesToValidate: ['packages/core'], + }) + + expect(result.success).toBe(true) + expect(packageManager.runScript).toHaveBeenCalledWith( + 'compile', + '/test/worktree', + [], + { quiet: true, packages: ['packages/core'] } + ) + }) + + it('should run against full project when packagesToValidate is empty', async () => { + vi.mocked(packageJson.getPackageConfig).mockResolvedValue({ + name: 'test', + scripts: { lint: 'eslint .' }, + }) + vi.mocked(packageJson.hasScript).mockImplementation( + (_, script) => script === 'lint' + ) + vi.mocked(packageManager.detectPackageManager).mockResolvedValue('pnpm') + vi.mocked(packageManager.runScript).mockResolvedValue() + + const result = await runner.runValidations('/test/worktree', { + skipTypecheck: true, + skipTests: true, + packagesToValidate: [], + }) + + expect(result.success).toBe(true) + expect(packageManager.runScript).toHaveBeenCalledWith( + 'lint', + '/test/worktree', + [], + { quiet: true, packages: [] } + ) + }) + }) }) diff --git a/src/lib/ValidationRunner.ts b/src/lib/ValidationRunner.ts index 64aaa578..eb32f033 100644 --- a/src/lib/ValidationRunner.ts +++ b/src/lib/ValidationRunner.ts @@ -28,14 +28,15 @@ export class ValidationRunner { const startTime = Date.now() const steps: ValidationStepResult[] = [] - const { jsonStream } = options + const { jsonStream, packagesToValidate } = options + const stepOptions = { jsonStream, packagesToValidate } // Run typecheck if (!options.skipTypecheck) { const typecheckResult = await this.runTypecheck( worktreePath, options.dryRun ?? false, - { jsonStream } + stepOptions ) steps.push(typecheckResult) @@ -50,7 +51,7 @@ export class ValidationRunner { // Run lint if (!options.skipLint) { - const lintResult = await this.runLint(worktreePath, options.dryRun ?? false, { jsonStream }) + const lintResult = await this.runLint(worktreePath, options.dryRun ?? false, stepOptions) steps.push(lintResult) if (!lintResult.passed && !lintResult.skipped) { @@ -63,7 +64,7 @@ export class ValidationRunner { const testResult = await this.runTests( worktreePath, options.dryRun ?? false, - { jsonStream } + stepOptions ) steps.push(testResult) @@ -82,7 +83,7 @@ export class ValidationRunner { private async runTypecheck( worktreePath: string, dryRun: boolean, - options: { jsonStream?: boolean | undefined } = {} + options: { jsonStream?: boolean | undefined; packagesToValidate?: string[] | undefined } = {} ): Promise { const stepStartTime = Date.now() @@ -125,13 +126,15 @@ export class ValidationRunner { } const packageManager = await detectPackageManager(worktreePath) + const packages = options.packagesToValidate ?? [] if (dryRun) { const command = packageManager === 'npm' ? `npm run ${scriptToRun}` : `${packageManager} ${scriptToRun}` - getLogger().info(`[DRY RUN] Would run: ${command}`) + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`[DRY RUN] Would run: ${command}${scopeNote}`) return { step: scriptToRun, passed: true, @@ -140,10 +143,11 @@ export class ValidationRunner { } } - getLogger().info(`Running ${scriptToRun}...`) + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`Running ${scriptToRun}...${scopeNote}`) try { - await runScript(scriptToRun, worktreePath, [], { quiet: true }) + await runScript(scriptToRun, worktreePath, [], { quiet: true, packages }) getLogger().success(`${scriptToRun.charAt(0).toUpperCase() + scriptToRun.slice(1)} passed`) return { @@ -158,7 +162,7 @@ export class ValidationRunner { scriptToRun, worktreePath, packageManager, - { jsonStream: options.jsonStream } + { jsonStream: options.jsonStream, packagesToValidate: packages } ) if (fixed) { @@ -191,7 +195,7 @@ export class ValidationRunner { private async runLint( worktreePath: string, dryRun: boolean, - options: { jsonStream?: boolean | undefined } = {} + options: { jsonStream?: boolean | undefined; packagesToValidate?: string[] | undefined } = {} ): Promise { const stepStartTime = Date.now() @@ -225,11 +229,13 @@ export class ValidationRunner { } const packageManager = await detectPackageManager(worktreePath) + const packages = options.packagesToValidate ?? [] if (dryRun) { const command = packageManager === 'npm' ? 'npm run lint' : `${packageManager} lint` - getLogger().info(`[DRY RUN] Would run: ${command}`) + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`[DRY RUN] Would run: ${command}${scopeNote}`) return { step: 'lint', passed: true, @@ -238,10 +244,11 @@ export class ValidationRunner { } } - getLogger().info('Running lint...') + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`Running lint...${scopeNote}`) try { - await runScript('lint', worktreePath, [], { quiet: true }) + await runScript('lint', worktreePath, [], { quiet: true, packages }) getLogger().success('Linting passed') return { @@ -256,11 +263,10 @@ export class ValidationRunner { 'lint', worktreePath, packageManager, - { jsonStream: options.jsonStream } + { jsonStream: options.jsonStream, packagesToValidate: packages } ) if (fixed) { - // logger.success('Linting passed after Claude auto-fix') return { step: 'lint', passed: true, @@ -287,7 +293,7 @@ export class ValidationRunner { private async runTests( worktreePath: string, dryRun: boolean, - options: { jsonStream?: boolean | undefined } = {} + options: { jsonStream?: boolean | undefined; packagesToValidate?: string[] | undefined } = {} ): Promise { const stepStartTime = Date.now() @@ -321,11 +327,13 @@ export class ValidationRunner { } const packageManager = await detectPackageManager(worktreePath) + const packages = options.packagesToValidate ?? [] if (dryRun) { const command = packageManager === 'npm' ? 'npm run test' : `${packageManager} test` - getLogger().info(`[DRY RUN] Would run: ${command}`) + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`[DRY RUN] Would run: ${command}${scopeNote}`) return { step: 'test', passed: true, @@ -334,10 +342,11 @@ export class ValidationRunner { } } - getLogger().info('Running tests...') + const scopeNote = packages.length > 0 ? ` (scoped to: ${packages.join(', ')})` : '' + getLogger().info(`Running tests...${scopeNote}`) try { - await runScript('test', worktreePath, [], { quiet: true }) + await runScript('test', worktreePath, [], { quiet: true, packages }) getLogger().success('Tests passed') return { @@ -352,11 +361,10 @@ export class ValidationRunner { 'test', worktreePath, packageManager, - { jsonStream: options.jsonStream } + { jsonStream: options.jsonStream, packagesToValidate: packages } ) if (fixed) { - // logger.success('Tests passed after Claude auto-fix') return { step: 'test', passed: true, @@ -390,7 +398,7 @@ export class ValidationRunner { validationType: 'compile' | 'typecheck' | 'lint' | 'test', worktreePath: string, packageManager: string, - options: { jsonStream?: boolean | undefined } = {} + options: { jsonStream?: boolean | undefined; packagesToValidate?: string[] | undefined } = {} ): Promise { // Check if Claude CLI is available const isClaudeAvailable = await detectClaudeCli() @@ -405,6 +413,10 @@ export class ValidationRunner { // Build prompt based on validation type (matching bash script prompts) const prompt = this.getClaudePrompt(validationType, validationCommand) + // Build system prompt appendage to instruct auto-fix agents to use il commands for re-validation + const packages = options.packagesToValidate ?? [] + const appendSystemPrompt = this.getAutoFixSystemPrompt(validationType, packages) + const validationTypeCapitalized = validationType.charAt(0).toUpperCase() + validationType.slice(1) getLogger().info(`Launching Claude to help fix ${validationTypeCapitalized} errors...`) @@ -417,6 +429,7 @@ export class ValidationRunner { permissionMode: options.jsonStream ? 'bypassPermissions' : 'acceptEdits', model: 'sonnet', noSessionPersistence: true, + appendSystemPrompt, ...(options.jsonStream && { passthroughStdout: true }), }) @@ -424,7 +437,7 @@ export class ValidationRunner { getLogger().info(`Re-running ${validationTypeCapitalized} after Claude's fixes...`) try { - await runScript(validationType, worktreePath, [], { quiet: true }) + await runScript(validationType, worktreePath, [], { quiet: true, packages }) // Validation passed after Claude fix getLogger().success(`${validationTypeCapitalized} passed after Claude auto-fix`) return true @@ -442,6 +455,27 @@ export class ValidationRunner { } } + /** + * Get the system prompt appendage for auto-fix agents. + * Instructs agents to use il commands for re-validation (so they benefit from monorepo scoping). + * Note: 'typecheck' maps to 'il compile' since 'il typecheck' is not a CLI command. + */ + private getAutoFixSystemPrompt( + validationType: 'compile' | 'typecheck' | 'lint' | 'test', + packages: string[] + ): string { + // 'il typecheck' does not exist — il compile handles both compile and typecheck fallback + const commandName = validationType === 'typecheck' ? 'compile' : validationType + const ilCommand = `il ${commandName}` + let prompt = `IMPORTANT: When re-running validation to verify your fixes, always use '${ilCommand}' instead of raw package manager commands. The il command automatically applies the correct monorepo scoping and configuration.` + + if (packages.length > 0) { + prompt += ` This validation is scoped to the following packages: ${packages.join(', ')}.` + } + + return prompt + } + /** * Get validation command string for prompts * Uses il commands for multi-language project support diff --git a/src/types/index.ts b/src/types/index.ts index 126a8e4e..76fda748 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -339,6 +339,12 @@ export interface ValidationOptions { skipLint?: boolean skipTests?: boolean jsonStream?: boolean + /** + * Monorepo packages to scope validation to (relative paths from repo root). + * When non-empty, validation commands run only for these packages. + * When empty or undefined, runs against the entire project (default behavior). + */ + packagesToValidate?: string[] } export interface ValidationStepResult { diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index c597aa56..e892a8bf 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -166,6 +166,50 @@ export interface RunScriptOptions { noCi?: boolean /** Callback for server output when using pipe mode (for TUI). When provided, stdio is piped instead of inherited. */ onOutput?: (data: Buffer) => void + /** + * Monorepo packages to scope the script to (relative paths from repo root). + * When non-empty, uses the package manager's filter syntax to run only for these packages. + * Packages missing the target script are gracefully skipped. + * When empty or undefined, runs against the entire project. + */ + packages?: string[] +} + +/** + * Build package-manager-specific filter args for monorepo scoping. + * Returns args to prepend/append to the command for filtering to specific packages. + */ +export function buildMonorepoFilterArgs( + packageManager: PackageManager, + scriptName: string, + packages: string[] +): { prefix: string[]; suffix: string[] } { + if (packages.length === 0) { + return { prefix: [], suffix: [] } + } + + switch (packageManager) { + case 'pnpm': { + // pnpm uses directory paths with ./ prefix for path-based filtering, or package names otherwise. + // Always normalize to path form to match the "relative paths from repo root" contract. + const filterArgs = packages.flatMap(pkg => ['--filter', pkg.startsWith('./') ? pkg : `./${pkg}`]) + return { prefix: filterArgs, suffix: ['--if-present'] } + } + case 'npm': { + // npm run