diff --git a/README.md b/README.md index b3f9881..c3c86cd 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ The default is a 7-phase run that splits the defect scan into a mechanical early | **Architecture only** | 1 | Quick structural overview | | **Synthesis** | 4 | Turn a product vision and confirmed library specifications into a provenance-backed implementation plan | -Set the active pipeline by editing `workflow/status.yaml`'s `pipeline:` field, or pass it as the argument to `/codecarto-init`. +Switch the active pipeline with `/codecarto-switch-pipeline ` (Pi) or `codecarto_switch_pipeline` (MCP). This rewrites `status.yaml` in-place without deleting findings, handoffs, usage data, or closeouts. Phases that exist in both the old and new pipelines preserve their completion status. **On disk:** @@ -287,6 +287,7 @@ Beyond the slash commands, the Pi extension layers on: |---|---| | `/codecarto-init [variant]` | Copy `.codecarto/` into the current repository, select pipeline variant | | `/codecarto-open` | Activate an existing `.codecarto/` workspace in a new Pi session without resetting durable state | +| `/codecarto-switch-pipeline ` | Switch the active pipeline in-place without losing findings or progress | | `/codecarto-status` | Current phase, progress, open questions | | `/codecarto-next [--auto [--strict]] [--llm-steer \| --no-llm-steer]` | Spawn the next eligible phase as a sub-agent. After the sub-agent finishes, auto-validates and auto-completes the phase so `status.yaml` advances without manual steps. `--auto` walks the full pipeline end-to-end (same validate + complete + advance loop, repeated); `--strict` flips the `PASS WITH GAPS` rule from "advance" to "pause". | | `/codecarto-phase ` | Force a specific phase, even out of pipeline order | diff --git a/core/workspace.ts b/core/workspace.ts index 1f88ad6..ec7c51c 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -5,9 +5,9 @@ import { existsSync, readFileSync } from "node:fs"; import { appendFile, readFile, rename, writeFile } from "node:fs/promises"; -import { dirname, join, relative } from "node:path"; +import { basename, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { acquireLock, applyHandoff, normalizeStatus, parseHandoff } from "./status.ts"; +import { acquireLock, applyHandoff, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.ts"; import type { PhaseHandoff, PipelineFile, StatusFile, WorkspaceState } from "./types.ts"; import { pathExists } from "./utils.ts"; import { loadYamlFile, stringifySimpleYaml } from "./yaml.ts"; @@ -146,3 +146,74 @@ export async function updateStatusAtomically( await lock.release(); } } + +/** + * Switch the active pipeline in-place without deleting findings, handoffs, + * usage data, closeouts, or checkpoints. Phases that exist in both the old + * and new pipelines preserve their completion status, owner notes, open + * questions, and carry-forward entries. Phases unique to the new pipeline + * start as pending. Phases unique to the old pipeline are dropped from + * status.yaml (but their findings remain on disk under findings/). + */ +export async function switchPipeline( + cwd: string, + newPipelinePath: string, +): Promise<{ state: WorkspaceState; carried: string[]; dropped: string[]; newPhases: string[] }> { + const workspaceDir = join(cwd, ".codecarto"); + const statusPath = join(workspaceDir, "workflow", "status.yaml"); + const lockPath = `${statusPath}.lock`; + const lock = await acquireLock(lockPath); + + try { + const currentState = await getWorkspaceState(cwd); + if (!currentState) { + throw new Error("CodeCartographer workspace not found. Run /codecarto-init first."); + } + + const resolvedPipelinePath = join(workspaceDir, newPipelinePath); + if (!(await pathExists(resolvedPipelinePath))) { + throw new Error(`Pipeline not found: ${newPipelinePath}`); + } + + const newPipeline = await loadYamlFile(resolvedPipelinePath); + const freshStatus = createEmptyStatus(basename(cwd), newPipelinePath, newPipeline); + + // Preserve phase data for phases that exist in both old and new pipelines. + const carried: string[] = []; + const oldPhases = currentState.status.phases; + for (const phaseId of newPipeline.phase_order) { + if (oldPhases[phaseId]) { + freshStatus.phases[phaseId] = { ...oldPhases[phaseId] }; + if (oldPhases[phaseId].status === "complete") { + carried.push(phaseId); + } + } + } + + // Track phases that were in the old pipeline but not the new one. + const dropped = currentState.pipeline.phase_order.filter( + (phaseId) => !newPipeline.phase_order.includes(phaseId), + ); + const newPhases = newPipeline.phase_order.filter( + (phaseId) => !currentState.pipeline.phase_order.includes(phaseId), + ); + + // Preserve post_pipeline entries from the old status. + freshStatus.post_pipeline = currentState.status.post_pipeline; + + freshStatus.last_updated = new Date().toISOString(); + + assertCanonicalStatus(freshStatus); + const serialized = `${stringifySimpleYaml(freshStatus)}\n`; + const tempPath = `${statusPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tempPath, serialized, "utf8"); + await rename(tempPath, statusPath); + + const state = await getWorkspaceState(cwd); + if (!state) throw new Error("Failed to reload workspace state after pipeline switch."); + + return { state, carried, dropped, newPhases }; + } finally { + await lock.release(); + } +} diff --git a/extensions/codecarto/index.ts b/extensions/codecarto/index.ts index 423decd..7421364 100644 --- a/extensions/codecarto/index.ts +++ b/extensions/codecarto/index.ts @@ -44,6 +44,7 @@ import { runPhasePreflight, type StatusFile, stringifySimpleYaml, + switchPipeline, validatePhaseOutput, type WorkspaceState, } from "../../core/index.ts"; @@ -328,6 +329,58 @@ export default function codeCartographerExtension(pi: ExtensionAPI) { }, }); + pi.registerCommand("codecarto-switch-pipeline", { + description: "Switch the active pipeline without losing findings or progress: /codecarto-switch-pipeline ", + getArgumentCompletions: (prefix) => { + const items = Object.keys(PIPELINE_ALIASES) + .filter((value) => value.startsWith(prefix)) + .map((value) => ({ value, label: value })); + return items.length > 0 ? items : null; + }, + handler: async (args, ctx) => { + const trimmedArgs = args.trim(); + if (!trimmedArgs) { + ctx.ui.notify("Usage: /codecarto-switch-pipeline (e.g. lite, full, synthesis)", "warning"); + return; + } + + const pipelineChoice = resolvePipelineChoice(trimmedArgs); + if (!pipelineChoice) { + ctx.ui.notify(`Unknown pipeline: ${trimmedArgs}`, "error"); + return; + } + + const state = await ensureWorkspaceState(ctx); + if (!state) return; + + const currentPipeline = state.status.pipeline; + if (currentPipeline === pipelineChoice) { + ctx.ui.notify(`Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, "info"); + return; + } + + try { + const result = await switchPipeline(ctx.cwd, pipelineChoice); + const lines = [ + `Switched pipeline: ${getPipelineLabel(pipelineChoice)}`, + ]; + if (result.carried.length > 0) lines.push(`Phases preserved (completed): ${result.carried.join(", ")}`); + if (result.newPhases.length > 0) lines.push(`New phases: ${result.newPhases.join(", ")}`); + if (result.dropped.length > 0) lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`); + + lastFeedbackLines = lines; + await refreshWorkspaceUi(ctx, lastFeedbackLines); + ctx.ui.notify(`Switched to pipeline: ${getPipelineLabel(pipelineChoice)}`, "info"); + void writeDashboard(ctx.cwd, PACKAGE_VERSION); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + lastFeedbackLines = [message]; + setUiState(ctx, state, lastFeedbackLines); + ctx.ui.notify(message, "error"); + } + }, + }); + pi.registerCommand("codecarto-next", { description: "Run the next eligible CodeCartographer phase as a sub-agent. Flags: --llm-steer / --no-llm-steer / --auto [--strict]", getArgumentCompletions: (prefix) => { diff --git a/mcp-server/server.ts b/mcp-server/server.ts index 865754c..75d0ff5 100644 --- a/mcp-server/server.ts +++ b/mcp-server/server.ts @@ -61,6 +61,7 @@ import { resolvePipelineChoice, type StatusFile, stringifySimpleYaml, + switchPipeline, validatePhaseOutput, type WorkspaceState, @@ -222,6 +223,33 @@ export async function handleStatus(args: { cwd: string }) { }); } +export async function handleSwitchPipeline(args: { cwd: string; pipeline: string }) { + const cwd = await validateCwd(args.cwd); + const state = await requireWorkspace(cwd); + + const pipelineChoice = resolvePipelineChoice(args.pipeline); + if (!pipelineChoice) { + throw new McpError(ErrorCode.InvalidRequest, `Unknown pipeline: ${args.pipeline}`); + } + + if (state.status.pipeline === pipelineChoice) { + return textResult(`Already on pipeline: ${getPipelineLabel(pipelineChoice)}`, { pipeline: getPipelineLabel(pipelineChoice) }); + } + + const result = await switchPipeline(cwd, pipelineChoice); + const lines = [`Switched pipeline: ${getPipelineLabel(pipelineChoice)}`]; + if (result.carried.length > 0) lines.push(`Phases preserved (completed): ${result.carried.join(", ")}`); + if (result.newPhases.length > 0) lines.push(`New phases: ${result.newPhases.join(", ")}`); + if (result.dropped.length > 0) lines.push(`Phases not in new pipeline: ${result.dropped.join(", ")} (findings remain on disk)`); + + return textResult(lines.join("\n"), { + pipeline: getPipelineLabel(pipelineChoice), + carried: result.carried, + newPhases: result.newPhases, + dropped: result.dropped, + }); +} + export async function handleNext(args: { cwd: string }) { const cwd = await validateCwd(args.cwd); const state = await requireWorkspace(cwd); @@ -620,6 +648,22 @@ const TOOLS = [ required: ["cwd"], }, }, + { + name: "codecarto_switch_pipeline", + description: + "Switch the active pipeline in-place without losing findings, handoffs, usage data, or phase progress. Phases that exist in both the old and new pipelines preserve their completion status. Phases unique to the new pipeline start as pending. Pass a pipeline alias (e.g. lite, full, synthesis) or a workflow/*.yaml path.", + inputSchema: { + type: "object", + properties: { + cwd: { type: "string", description: "Absolute path to the target repository." }, + pipeline: { + type: "string", + description: "Pipeline alias (e.g. lite, full, synthesis, architecture-only, defect-scan) or workflow/*.yaml path.", + }, + }, + required: ["cwd", "pipeline"], + }, + }, { name: "codecarto_status", description: "Show the current CodeCartographer phase, active pipeline, and progress for a target repository.", @@ -763,6 +807,7 @@ const TOOLS = [ const HANDLERS: Record Promise> = { codecarto_init: handleInit, codecarto_status: handleStatus, + codecarto_switch_pipeline: handleSwitchPipeline, codecarto_next: handleNext, codecarto_phase: handlePhase, codecarto_validate: handleValidate, diff --git a/tests/default-pipeline.test.mjs b/tests/default-pipeline.test.mjs index 375583e..e0709a8 100644 --- a/tests/default-pipeline.test.mjs +++ b/tests/default-pipeline.test.mjs @@ -61,7 +61,7 @@ test("every PIPELINE_ALIASES target resolves to a real file", async () => { test("Pi extension registers a command for every CodeCartographer operation", async () => { // The set of operations the framework exposes; both wrappers must surface them. - const expected = ["init", "status", "next", "phase", "validate", "complete", "skill", "publish", "usage", "dashboard"]; + const expected = ["init", "open", "switch-pipeline", "status", "next", "phase", "validate", "complete", "skill", "publish", "usage", "dashboard"]; const indexSrc = await readFile(join(REPO_ROOT, "extensions", "codecarto", "index.ts"), "utf8"); const missing = expected.filter((op) => !indexSrc.includes(`pi.registerCommand("codecarto-${op}"`)); assert.deepEqual(