// ============================================================================= // workflows/new.ts — `soly new /` handler // ============================================================================= // // Direct workflow (NOT a transform): creates a git branch of the form // `/`, makes `.agents/plans//` on that branch, and writes // a stub PLAN.md with TBD sections that the user fills in later via // `soly plan /`. Plain `soly new ...` from chat just works. // // `` is a Conventional Commits type (`feat`, `fix`, `chore`, ...). // `` is kebab-case. Branch name = `/`, plan directory is // `.agents/plans//` (the type prefix is part of branch identity only). // Returns `{ handled: false }` if the user typed something other than the // prefix (e.g. `soly something-else`) so the regular handler can run. // ============================================================================= import * as fs from "node:fs"; import { execFileSync } from "node:child_process"; import { parsePlanName, type SolyCommand } from "./parser.ts"; import type { SolyState } from "../core.js"; import { emit } from "../visual/event-sink.ts"; export interface NewResult { handled: boolean; transformedText?: string; /** On success: branch created, plan dir + stub PLAN.md written, committed. */ scaffolded?: { branch: string; planPath: string }; } /** We only call `notify` on the UI. Structural type so tests can fake it. */ export type Notifier = { notify: (text: string, level?: "info" | "warning" | "error") => void; }; /** Run `git ` and capture stdout. Throws with stderr context on error. */ function git(args: string[], opts: { cwd: string }): string { try { return execFileSync("git", args, { cwd: opts.cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).trim(); } catch (err) { const e = err as { stderr?: Buffer | string; stdout?: Buffer | string; message?: string }; const stderr = e.stderr ? e.stderr.toString().trim() : ""; throw new Error(`git ${args.join(" ")} failed: ${stderr || e.message || "unknown error"}`); } } /** Stub PLAN.md body with TBD sections; `soly plan` fills these in later. */ function stubPlanMarkdown(branchName: string): string { return `# Plan: ${branchName} _Stub — fill in via \`soly plan ${branchName}\` (uses ask_pro to gather goal / steps / acceptance criteria)._ ## Goal ## Steps ## Acceptance `; } /** * Build an error/notification text (also returned as `handled: true, * transformedText` so the caller shows it; for direct execution the * workflow also calls `ui.notify` with the same string). */ function reply(text: string): NewResult { return { handled: true, transformedText: text }; } export function buildNewTransform( cmd: SolyCommand, state: SolyState, ui: Notifier, projectRoot: string, defaultBranchPrefix = "", ): NewResult { if (!state.exists) { return reply(`soly new: no .agents/ directory in cwd — run /soly init first.`); } const parsed = parsePlanName(cmd.args.join(" ")); if ("error" in parsed) return reply(`soly new: ${parsed.error}`); const { name, prefix, autoSlugified, originalInput } = parsed; // Branch can be one of three shapes: // - "feature/statistic-preparation" (user typed /) // - "feature/statistic-preparation" (config has defaultBranchPrefix "feature", user typed "statistic-preparation") // - "statistic-preparation" (no prefix anywhere) // The plan dir is always flattened to keep the on-disk layout one-deep: // `.agents/plans/-` or `.agents/plans/`. const effectivePrefix = prefix ?? defaultBranchPrefix; const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name; const dirSlug = effectivePrefix ? `${effectivePrefix}-${name}` : name; const planDirRel = `.agents/plans/${dirSlug}`; const planDirAbs = `${state.solyDir}/plans/${dirSlug}`; const planFile = `${planDirAbs}/PLAN.md`; // 1. Preconditions try { git(["rev-parse", "--is-inside-work-tree"], { cwd: projectRoot }); } catch { return reply(`soly new: not in a git repository (cwd: ${projectRoot}). Run \`git init\` first.`); } const statusOut = git(["status", "--short"], { cwd: projectRoot }); // Ignore our own planned files if they're present but untracked — that // can happen if a previous `soly new` died mid-flight. Anything else // (untracked or modified tracked files) is the user's responsibility. if (statusOut) { return reply( `soly new: working tree has uncommitted changes:\n\n${statusOut}\n\n` + `Commit or stash them first.`, ); } const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)"; // A plan branch is a kebab-case slug (no `/` prefix after 1.15.x). // The base can also be the long-lived integration branches `master`/`main`. // Anything else (release tags, weird suffixes, feature branches from // previous work) — we auto-checkout the base branch instead of // blocking the user. The LLM driving soly shouldn't have to manage // git state. if ( currentBranch !== "master" && currentBranch !== "main" && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch) ) { // Detect which base branch the repo uses: try `main` first, fall // back to `master`. If neither exists, give up — the repo is in // a state the user needs to resolve by hand. const baseBranch = ["main", "master"].find((b) => { try { git(["rev-parse", "--verify", b], { cwd: projectRoot }); return true; } catch { return false; } }); if (!baseBranch) { return reply( `soly new: cannot find a base branch to base off — repo has neither "main" nor "master". ` + `Run \`git checkout \` first, or create a default branch.`, ); } try { git(["checkout", baseBranch], { cwd: projectRoot }); emit( `auto-checkout ${baseBranch} (was on ${currentBranch})`, "info", ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return reply( `soly new: failed to checkout ${baseBranch} (was on ${currentBranch}): ${msg}`, ); } } let branchExisted = false; try { git(["rev-parse", "--verify", branchName], { cwd: projectRoot }); // Branch already exists — switch to it instead of creating branchExisted = true; git(["checkout", branchName], { cwd: projectRoot }); } catch { // Branch doesn't exist — create it git(["checkout", "-b", branchName], { cwd: projectRoot }); } try { // 2. Scaffold plan dir + stub PLAN.md fs.mkdirSync(planDirAbs, { recursive: true }); fs.writeFileSync(planFile, stubPlanMarkdown(branchName), "utf-8"); // 3. Commit (separate from working-tree check so the new files show) git(["add", planDirRel], { cwd: projectRoot }); git(["commit", "-m", `plan: scaffold ${branchName}`], { cwd: projectRoot }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return reply(`soly new: scaffold failed — ${msg}\nYou may need to manually clean up branch ${branchName}.`); } const notice = branchExisted ? `Plan '${name}' reused on existing branch ${branchName}.\nPLAN.md was ${planFile}.\nNext: \`soly plan ${branchName}\`` : `Plan '${name}' scaffolded on new branch ${branchName}.\nPLAN.md: ${planFile}\nNext: \`soly plan ${branchName}\``; return { handled: true, transformedText: notice, scaffolded: { branch: branchName, planPath: planFile }, }; }