pi-soly 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,14 +78,29 @@ Tracked upstream — fix is expected on the pi side, not here.
78
78
  ### Workflow — plain-text verbs (type `soly <verb>`, not slash)
79
79
 
80
80
  ```bash
81
- soly discuss 3 # talk through decisions before planning phase 3
82
- soly plan 3 # generate PLAN.md for phase 3
83
- soly execute 3 # execute phase 3 (or `soly execute 3.02` for one plan)
84
- soly verify # self-review loop until "No issues found." (`soly verify stop` to exit)
85
- soly pause # save a handoff; `soly resume` to pick it back up
86
- soly status # current position + progress (no LLM round-trip)
81
+ # === Plan mode (recommended for new work each plan is a git branch) ===
82
+ soly new feat/auth-jwt # create branch + .agents/plans/<name>/ + stub PLAN.md
83
+ soly discuss feat/auth-jwt # interactive discussion of the plan
84
+ soly plan feat/auth-jwt # flesh out PLAN.md via ask_pro
85
+ soly execute feat/auth-jwt # execute the plan in a subagent
86
+ soly done feat/auth-jwt # commit, push, open draft PR via gh
87
+ soly verify # self-review loop until "No issues found." (soly verify stop to exit)
88
+ soly pause # save a handoff; soly resume to pick it back up
89
+ soly status # current position + progress (no LLM round-trip)
90
+
91
+ # === Phase mode (legacy — still works for existing projects) ===
92
+ soly plan 3 # generate PLAN.md for phase 3 (numeric form)
93
+ soly execute 3 # execute phase 3 (or `soly execute 3.02` for one plan)
94
+ soly migrate phases-to-plans # one-shot: import each .agents/phases/<NN>-slug/plans/PLAN.md
95
+ # as a `migrate/legacy-<NN>-slug` branch with .agents/plans/legacy-<NN>-slug/PLAN.md
87
96
  ```
88
97
 
98
+ > **Why plans instead of phases?** A global phase counter (1, 2, 3, ...) means two
99
+ > developers each starting a "phase 11" write to the same path. With plans, each
100
+ > is a git branch (`feat/auth-jwt`, `fix/login-redirect`, …) with its own
101
+ > `.agents/plans/<name>/PLAN.md` — no collisions, clean isolation, and the branch
102
+ > list itself is the registry of what's in flight.
103
+
89
104
  ### State inspection (`/soly`)
90
105
 
91
106
  ```bash
package/iteration.ts CHANGED
@@ -39,6 +39,8 @@ export interface IterationInput {
39
39
  planNumber?: number;
40
40
  /** Task id (only for task-mode execution / planning). */
41
41
  taskId?: string;
42
+ /** Plan name (only for plan-mode execution / planning — `.agents/plans/<name>/`). */
43
+ planName?: string;
42
44
  /** Feature name (only for task-mode). Backfilled from disk if omitted. */
43
45
  feature?: string;
44
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives execution and delegates to a worker subagent when available.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -0,0 +1,203 @@
1
+ // =============================================================================
2
+ // workflows/done.ts — `soly done <type>/<name>` handler
3
+ // =============================================================================
4
+ //
5
+ // Direct workflow (no LLM transform): wraps up a plan on its branch.
6
+ // 1. Validates we're on the plan's branch (or any soly plan branch if
7
+ // `<type>/<name>` is omitted — see "implicit mode" below).
8
+ // 2. Commits any uncommitted changes (with a sensible Conventional
9
+ // Commits message if the user hasn't staged anything).
10
+ // 3. `git push -u origin <branch>` (or warn if no remote).
11
+ // 4. Tries `gh pr create --draft --fill` (or warns if `gh` not on PATH).
12
+ //
13
+ // STATE.md global sync (in main) is deferred to W4 — that's a separate
14
+ // concern about merge coordination.
15
+ // =============================================================================
16
+
17
+ import * as fs from "node:fs";
18
+ import { execFileSync } from "node:child_process";
19
+ import { parsePlanName, type SolyCommand } from "./parser.ts";
20
+ import type { SolyState } from "../core.js";
21
+
22
+ export interface DoneResult {
23
+ handled: boolean;
24
+ transformedText?: string;
25
+ completed?: {
26
+ branch: string;
27
+ commit: string;
28
+ pushed: boolean;
29
+ prUrl: string | null;
30
+ };
31
+ }
32
+
33
+ type Notifier = {
34
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
35
+ };
36
+
37
+ function git(args: string[], opts: { cwd: string }): string {
38
+ try {
39
+ return execFileSync("git", args, {
40
+ cwd: opts.cwd,
41
+ encoding: "utf-8",
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ }).trim();
44
+ } catch (err) {
45
+ const e = err as { stderr?: Buffer | string; stdout?: Buffer | string; message?: string };
46
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
47
+ throw new Error(`git ${args.join(" ")} failed: ${stderr || e.message || "unknown error"}`);
48
+ }
49
+ }
50
+
51
+ /** Try `gh` and return stdout. Throws on failure. */
52
+ function gh(args: string[], opts: { cwd: string; ghPath?: string }): string {
53
+ const cmd = opts.ghPath ?? "gh";
54
+ try {
55
+ return execFileSync(cmd, args, {
56
+ cwd: opts.cwd,
57
+ encoding: "utf-8",
58
+ stdio: ["ignore", "pipe", "pipe"],
59
+ }).trim();
60
+ } catch (err) {
61
+ const e = err as { stderr?: Buffer | string; message?: string };
62
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
63
+ throw new Error(`gh ${args[0]} failed: ${stderr || e.message || "unknown error"}`);
64
+ }
65
+ }
66
+
67
+ /** ENOENT-style "command not found" -> throws a specific error. */
68
+ function ghAvailable(ghPath?: string): boolean {
69
+ const cmd = ghPath ?? "gh";
70
+ try {
71
+ execFileSync(cmd, ["--version"], { stdio: "ignore" });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function reply(text: string): DoneResult {
79
+ return { handled: true, transformedText: text };
80
+ }
81
+
82
+ export function buildDoneTransform(
83
+ cmd: SolyCommand,
84
+ state: SolyState,
85
+ ui: Notifier,
86
+ projectRoot: string,
87
+ opts: { ghPath?: string } = {},
88
+ ): DoneResult {
89
+ if (!state.exists) {
90
+ return reply(`soly done: no .agents/ directory in cwd — run /soly init first.`);
91
+ }
92
+
93
+ const raw = cmd.args.join(" ").trim();
94
+ const parsed = parsePlanName(raw);
95
+ if ("error" in parsed) {
96
+ return reply(`soly done: ${parsed.error}\n\nUsage: soly done <type>/<name>`);
97
+ }
98
+ const { type, name } = parsed;
99
+ const branchName = `${type}/${name}`;
100
+
101
+ // 1. Preconditions
102
+ const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot });
103
+ if (currentBranch !== branchName) {
104
+ return reply(
105
+ `soly done: currently on "${currentBranch}", not the plan branch "${branchName}".\n` +
106
+ `Switch first with \`git checkout ${branchName}\`.`,
107
+ );
108
+ }
109
+
110
+ // Check there's a remote
111
+ let hasRemote = false;
112
+ try {
113
+ git(["remote", "get-url", "origin"], { cwd: projectRoot });
114
+ hasRemote = true;
115
+ } catch {
116
+ // No remote — we'll skip push but still commit
117
+ }
118
+
119
+ // 2. Commit any pending changes (if anything to commit)
120
+ const statusBefore = git(["status", "--short"], { cwd: projectRoot });
121
+ let commitHash = git(["rev-parse", "HEAD"], { cwd: projectRoot });
122
+ if (statusBefore) {
123
+ // Auto-stage everything not under .agents/ (project files only)
124
+ try {
125
+ git(["add", "-A", "--", ":!.agents/"], { cwd: projectRoot });
126
+ // Re-check after add
127
+ const statusAfter = git(["status", "--short"], { cwd: projectRoot });
128
+ if (statusAfter) {
129
+ git(["commit", "-m", `${type}(${name}): wip`], { cwd: projectRoot });
130
+ }
131
+ } catch (err) {
132
+ const msg = err instanceof Error ? err.message : String(err);
133
+ return reply(`soly done: commit failed — ${msg}`);
134
+ }
135
+ commitHash = git(["rev-parse", "HEAD"], { cwd: projectRoot });
136
+ }
137
+
138
+ // 3. Push
139
+ let pushed = false;
140
+ if (hasRemote) {
141
+ try {
142
+ git(["push", "-u", "origin", branchName], { cwd: projectRoot });
143
+ pushed = true;
144
+ } catch (err) {
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ ui.notify(`soly done: push failed — ${msg}\nPlan was committed but not pushed. Push manually with \`git push -u origin ${branchName}\`.`, "warning");
147
+ return {
148
+ handled: true,
149
+ transformedText: `Plan ${branchName} committed locally (${commitHash.slice(0, 7)}), but push failed.\n${msg}`,
150
+ completed: { branch: branchName, commit: commitHash, pushed: false, prUrl: null },
151
+ };
152
+ }
153
+ } else {
154
+ ui.notify(`soly done: no 'origin' remote — committed locally only. Add a remote and \`git push\` when ready.`, "warning");
155
+ }
156
+
157
+ // 4. Draft PR via gh
158
+ let prUrl: string | null = null;
159
+ if (pushed && ghAvailable(opts.ghPath)) {
160
+ try {
161
+ prUrl = gh(
162
+ [
163
+ "pr",
164
+ "create",
165
+ "--draft",
166
+ "--fill",
167
+ "--head",
168
+ branchName,
169
+ ],
170
+ { cwd: projectRoot, ghPath: opts.ghPath },
171
+ );
172
+ } catch (err) {
173
+ // gh failed (maybe not authenticated, or PR already exists, etc.)
174
+ // Don't fail the whole workflow — just report.
175
+ const msg = err instanceof Error ? err.message : String(err);
176
+ ui.notify(
177
+ `soly done: pushed OK, but draft PR creation failed — ${msg}\n` +
178
+ `Run \`gh pr create --draft --fill\` manually.`,
179
+ "warning",
180
+ );
181
+ }
182
+ } else if (pushed) {
183
+ ui.notify(
184
+ `soly done: pushed OK, but \`gh\` CLI not found — draft PR not created.\n` +
185
+ `Install \`gh\` (https://cli.github.com) and run \`gh pr create --draft --fill\` manually.`,
186
+ "info",
187
+ );
188
+ }
189
+
190
+ // 5. Done — summarize
191
+ const prLine = prUrl ? `Draft PR: ${prUrl}` : "No draft PR (push or gh step skipped / failed).";
192
+ const notice =
193
+ `Plan ${branchName} done.\n` +
194
+ ` Commit: ${commitHash.slice(0, 7)}\n` +
195
+ ` Pushed: ${pushed ? "yes" : "no (no origin remote)"}\n` +
196
+ ` ${prLine}`;
197
+ ui.notify(notice, "info");
198
+ return {
199
+ handled: true,
200
+ transformedText: notice,
201
+ completed: { branch: branchName, commit: commitHash, pushed, prUrl },
202
+ };
203
+ }
@@ -274,7 +274,96 @@ When the subagent completes, synthesize the result. Do not re-execute its work.
274
274
  };
275
275
  }
276
276
 
277
+ // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<name>/) ===
278
+ if (target.kind === "plan") {
279
+ const planDirAbs = `${state.solyDir}/plans/${target.name}`;
280
+ const planFile = `${planDirAbs}/PLAN.md`;
281
+ let planBody: string;
282
+ try {
283
+ planBody = fs.readFileSync(planFile, "utf-8");
284
+ } catch {
285
+ return {
286
+ handled: true,
287
+ transformedText:
288
+ `soly execute: plan ${target.raw} has no PLAN.md at ${planFile}.\n` +
289
+ `Run \`soly plan ${target.raw}\` first to flesh it out.`,
290
+ };
291
+ }
292
+ const workflow = loadWorkflowMarkdown("execute-plan.md");
293
+ if (!workflow) {
294
+ return {
295
+ handled: true,
296
+ transformedText:
297
+ `soly execute: workflow markdown not found: workflows-data/execute-plan.md\n` +
298
+ `This is an extension installation issue — reinstall soly.`,
299
+ };
300
+ }
301
+ // Write per-iteration context bundle so the worker has intent + STATE
302
+ // + the plan body in one file.
303
+ const iter = writeIterationContext({
304
+ solyDir: state.solyDir,
305
+ projectRoot,
306
+ kind: "exec",
307
+ planName: target.name,
308
+ });
309
+ const instruction = `soly execute ${target.raw} — executing plan.
310
+
311
+ **Plan:** ${target.name}
312
+ **Branch:** ${target.raw}
313
+ **PLAN.md:** ${planFile}
314
+
315
+ **Iteration context file written:** \`${iter.relPath}\` (${iter.tokens} tokens)
316
+ The worker reads this file first — it contains intent, STATE, ROADMAP, the
317
+ plan body inline, and any prior SUMMARYs for this plan (none on first run).
318
+
319
+ **Inline plan body (so you have must-haves before reading the file):**
320
+ \`\`\`markdown
321
+ ${planBody.slice(0, 4000)}${planBody.length > 4000 ? "\n…(truncated)" : ""}
322
+ \`\`\`
323
+
324
+ **0-POINT CHECK.** Worker must re-read .agents/docs/ (intent) before implementing.
325
+
326
+ Launch a single subagent to execute the plan. Do NOT do the work inline.
327
+
328
+ subagent({
329
+ agent: ${JSON.stringify(opts.agent ?? "worker")},
330
+ context: "fresh",
331
+ async: true,
332
+ maxSubagentDepth: 1,
333
+ task: \`You are soly-executor. Execute the plan at \`${planFile}\` end-to-end.
334
+
335
+ **FIRST ACTION — read the iteration context file:**
336
+ \`\`\`
337
+ ${iter.relPath}
338
+ \`\`\`
339
+ It contains intent, STATE, ROADMAP, and the full plan body. Do NOT skip it.
340
+
341
+ Project root: ${projectRoot}
342
+ Soly dir: ${state.solyDir}
343
+ Plan dir: ${planDirAbs}
344
+
345
+ **0-POINT CHECK — read .agents/docs/ first.**
346
+
347
+ Follow the workflow below VERBATIM.
348
+
349
+ === WORKFLOW: execute-plan.md ===
350
+ ${workflow}
351
+ === END WORKFLOW ===
352
+
353
+ Hard rules:
354
+ - All work happens on branch \`${target.raw}\`. Do not switch branches.
355
+ - When the plan is fully executed and verified, write a SUMMARY.md next to
356
+ PLAN.md summarizing what was done, what was deferred, and any deviations.
357
+ - Do not commit unless the workflow tells you to; the user reviews and merges.
358
+ \`)
359
+ }`;
360
+ return { handled: true, transformedText: instruction };
361
+ }
362
+
277
363
  // === PHASE MODE ===
364
+ if (target.kind !== "phase") {
365
+ return { handled: false };
366
+ }
278
367
  const phase = state.phases.find((p) => p.number === target.phase);
279
368
  if (!phase) {
280
369
  return {
@@ -23,6 +23,9 @@ import { buildResumeTransform } from "./resume.ts";
23
23
  import { showStatus, showLog, showDiff } from "./quick.ts";
24
24
  import { showDoctor, showIterations, showDiffIterations, showPhaseDelete, showTodos } from "./inspect.ts";
25
25
  import { buildPlanTransform, buildDiscussTransform } from "./planning.ts";
26
+ import { buildNewTransform } from "./new.ts";
27
+ import { buildDoneTransform } from "./done.ts";
28
+ import { buildMigrateTransform } from "./migrate.ts";
26
29
  import { createVerifyLoop, type VerifyState } from "./verify.ts";
27
30
  import type { ContextManager } from "../context-manager.ts";
28
31
  import type { SolyState } from "../core.js";
@@ -144,6 +147,29 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
144
147
  return { action: "transform", text: result.transformedText };
145
148
  }
146
149
 
150
+ if (cmd.verb === "new") {
151
+ const result = buildNewTransform(cmd, state, ctx.ui, ctx.cwd);
152
+ if (!result.handled || !result.transformedText) return;
153
+ // Direct execution (the workflow already called ui.notify). The
154
+ // transformed text goes to the model so it can also tell the user
155
+ // in chat, but the action is "handled" (no LLM round-trip needed).
156
+ return { action: "transform", text: result.transformedText };
157
+ }
158
+
159
+ if (cmd.verb === "done") {
160
+ const result = buildDoneTransform(cmd, state, ctx.ui, ctx.cwd);
161
+ if (!result.handled || !result.transformedText) return;
162
+ // Direct execution — workflow already called ui.notify. The
163
+ // transformed text tells the LLM (and the user in chat) what happened.
164
+ return { action: "transform", text: result.transformedText };
165
+ }
166
+
167
+ if (cmd.verb === "migrate") {
168
+ const result = buildMigrateTransform(state, ctx.ui, ctx.cwd);
169
+ if (!result.handled || !result.transformedText) return;
170
+ return { action: "transform", text: result.transformedText };
171
+ }
172
+
147
173
  if (cmd.verb === "verify") {
148
174
  const sub = (cmd.args[0] ?? "").toLowerCase();
149
175
  if (sub === "stop" || sub === "off") {
@@ -0,0 +1,172 @@
1
+ // =============================================================================
2
+ // workflows/migrate.ts — `soly migrate phases-to-plans`
3
+ // =============================================================================
4
+ //
5
+ // One-shot migration from the legacy phase layout to the new plan layout.
6
+ // Reads each phase under `.agents/phases/<NN>-<slug>/`, creates a branch
7
+ // `migrate/legacy-<NN>-<slug>`, copies `plans/PLAN.md` to
8
+ // `.agents/plans/legacy-<NN>-<slug>/PLAN.md`, and commits.
9
+ //
10
+ // The user is responsible for merging the migration branch into main.
11
+ // We don't auto-push.
12
+ //
13
+ // Phases whose branch already exists are skipped (re-running is a no-op
14
+ // for them). Phases without `plans/PLAN.md` are also skipped.
15
+ // =============================================================================
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { execFileSync } from "node:child_process";
20
+ import type { SolyState } from "../core.js";
21
+
22
+ export interface MigrateResult {
23
+ handled: boolean;
24
+ transformedText?: string;
25
+ migrated: { phase: string; branch: string; planPath: string }[];
26
+ skipped: { phase: string; reason: string }[];
27
+ }
28
+
29
+ type Notifier = {
30
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
31
+ };
32
+
33
+ function git(args: string[], opts: { cwd: string }): string {
34
+ try {
35
+ return execFileSync("git", args, {
36
+ cwd: opts.cwd,
37
+ encoding: "utf-8",
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ }).trim();
40
+ } catch (err) {
41
+ const e = err as { stderr?: Buffer | string; message?: string };
42
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
43
+ throw new Error(`git ${args.join(" ")} failed: ${stderr || e.message || "unknown error"}`);
44
+ }
45
+ }
46
+
47
+ /** List phase dirs under `<solyDir>/phases/` (any NN-prefix). */
48
+ function listPhaseDirs(solyDir: string): string[] {
49
+ const phasesRoot = path.join(solyDir, "phases");
50
+ if (!fs.existsSync(phasesRoot)) return [];
51
+ return fs
52
+ .readdirSync(phasesRoot, { withFileTypes: true })
53
+ .filter((d) => d.isDirectory())
54
+ .map((d) => d.name);
55
+ }
56
+
57
+ /** Derive `<NN>-<slug>` from a phase dir name. Returns number as string to
58
+ * preserve the leading zero (`03` → `"03"`, not `3`). */
59
+ function parsePhaseName(dirName: string): { number: string; slug: string } | null {
60
+ const m = dirName.match(/^(\d+)-(.+)$/);
61
+ if (!m) return null;
62
+ return { number: m[1], slug: m[2] };
63
+ }
64
+
65
+ export function buildMigrateTransform(
66
+ state: SolyState,
67
+ ui: Notifier,
68
+ projectRoot: string,
69
+ ): MigrateResult {
70
+ if (!state.exists) {
71
+ return {
72
+ handled: true,
73
+ transformedText: "soly migrate: no .agents/ directory in cwd — run /soly init first.",
74
+ migrated: [],
75
+ skipped: [],
76
+ };
77
+ }
78
+
79
+ const phaseDirs = listPhaseDirs(state.solyDir);
80
+ if (phaseDirs.length === 0) {
81
+ return {
82
+ handled: true,
83
+ transformedText: "soly migrate: no phases found in .agents/phases/. Nothing to migrate.",
84
+ migrated: [],
85
+ skipped: [],
86
+ };
87
+ }
88
+
89
+ // Make sure we're on master (migration creates new branches, not from here)
90
+ let currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "master";
91
+ if (currentBranch === "HEAD") currentBranch = "master"; // detached HEAD
92
+ if (currentBranch !== "master" && currentBranch !== "main") {
93
+ // Auto-stash + checkout master. We could just refuse, but that's annoying.
94
+ try {
95
+ git(["checkout", "master"], { cwd: projectRoot });
96
+ } catch {
97
+ try {
98
+ git(["checkout", "main"], { cwd: projectRoot });
99
+ } catch {
100
+ return {
101
+ handled: true,
102
+ transformedText: `soly migrate: current branch is "${currentBranch}". Switch to master/main first.`,
103
+ migrated: [],
104
+ skipped: [],
105
+ };
106
+ }
107
+ }
108
+ }
109
+
110
+ const migrated: MigrateResult["migrated"] = [];
111
+ const skipped: MigrateResult["skipped"] = [];
112
+
113
+ for (const dirName of phaseDirs) {
114
+ const parsed = parsePhaseName(dirName);
115
+ if (!parsed) {
116
+ skipped.push({ phase: dirName, reason: "doesn't match NN-name pattern" });
117
+ continue;
118
+ }
119
+ const branchName = `migrate/legacy-${parsed.number}-${parsed.slug}`;
120
+ const planSource = path.join(state.solyDir, "phases", dirName, "plans", "PLAN.md");
121
+ const planTarget = path.join(state.solyDir, "plans", `legacy-${parsed.number}-${parsed.slug}`, "PLAN.md");
122
+
123
+ // Skip if no PLAN.md
124
+ if (!fs.existsSync(planSource)) {
125
+ skipped.push({ phase: dirName, reason: "no PLAN.md under plans/" });
126
+ continue;
127
+ }
128
+
129
+ // Skip if branch already exists
130
+ let branchExists = false;
131
+ try {
132
+ git(["rev-parse", "--verify", branchName], { cwd: projectRoot });
133
+ branchExists = true;
134
+ } catch {
135
+ // not found — we'll create
136
+ }
137
+ if (branchExists) {
138
+ skipped.push({ phase: dirName, reason: `branch ${branchName} already exists` });
139
+ continue;
140
+ }
141
+
142
+ // Create branch and copy file
143
+ try {
144
+ git(["checkout", "-b", branchName], { cwd: projectRoot });
145
+ fs.mkdirSync(path.dirname(planTarget), { recursive: true });
146
+ fs.copyFileSync(planSource, planTarget);
147
+ const planTargetRel = `.agents/plans/legacy-${parsed.number}-${parsed.slug}/PLAN.md`;
148
+ git(["add", planTargetRel], { cwd: projectRoot });
149
+ git(["commit", "-m", `migrate: import phase ${dirName} as plan`], { cwd: projectRoot });
150
+ migrated.push({ phase: dirName, branch: branchName, planPath: planTarget });
151
+ } catch (err) {
152
+ skipped.push({ phase: dirName, reason: `git error: ${err instanceof Error ? err.message : String(err)}` });
153
+ }
154
+ }
155
+
156
+ // Stay on the last migration branch so the user can inspect the result.
157
+ // (We used to checkout back to master, but that removes the new PLAN.md
158
+ // files from the working tree since they only exist on the migration
159
+ // branch. The user can `git checkout master` when ready.)
160
+
161
+ const notice =
162
+ `soly migrate: ${migrated.length} migrated, ${skipped.length} skipped.\n` +
163
+ (migrated.length > 0
164
+ ? migrated.map((m) => ` + ${m.phase} → ${m.branch}`).join("\n") + "\n"
165
+ : "") +
166
+ (skipped.length > 0
167
+ ? skipped.map((s) => ` ! ${s.phase}: ${s.reason}`).join("\n") + "\n"
168
+ : "") +
169
+ `\nPush and PR the migration branches manually: \`git push origin <branch>\`.`;
170
+ ui.notify(`soly migrate: ${migrated.length} migrated, ${skipped.length} skipped`, "info");
171
+ return { handled: true, transformedText: notice, migrated, skipped };
172
+ }
@@ -0,0 +1,159 @@
1
+ // =============================================================================
2
+ // workflows/new.ts — `soly new <type>/<name>` handler
3
+ // =============================================================================
4
+ //
5
+ // Direct workflow (NOT a transform): creates a git branch of the form
6
+ // `<type>/<name>`, makes `.agents/plans/<name>/` on that branch, and writes
7
+ // a stub PLAN.md with TBD sections that the user fills in later via
8
+ // `soly plan <type>/<name>`. Plain `soly new ...` from chat just works.
9
+ //
10
+ // `<type>` is a Conventional Commits type (`feat`, `fix`, `chore`, ...).
11
+ // `<name>` is kebab-case. Branch name = `<type>/<name>`, plan directory is
12
+ // `.agents/plans/<name>/` (the type prefix is part of branch identity only).
13
+ // Returns `{ handled: false }` if the user typed something other than the
14
+ // prefix (e.g. `soly something-else`) so the regular handler can run.
15
+ // =============================================================================
16
+
17
+ import * as fs from "node:fs";
18
+ import { execFileSync } from "node:child_process";
19
+ import { parsePlanName, type SolyCommand } from "./parser.ts";
20
+ import type { SolyState } from "../core.js";
21
+
22
+ export interface NewResult {
23
+ handled: boolean;
24
+ transformedText?: string;
25
+ /** On success: branch created, plan dir + stub PLAN.md written, committed. */
26
+ scaffolded?: { branch: string; planPath: string };
27
+ }
28
+
29
+ /** We only call `notify` on the UI. Structural type so tests can fake it. */
30
+ export type Notifier = {
31
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
32
+ };
33
+
34
+ /** Run `git <args>` and capture stdout. Throws with stderr context on error. */
35
+ function git(args: string[], opts: { cwd: string }): string {
36
+ try {
37
+ return execFileSync("git", args, {
38
+ cwd: opts.cwd,
39
+ encoding: "utf-8",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ }).trim();
42
+ } catch (err) {
43
+ const e = err as { stderr?: Buffer | string; stdout?: Buffer | string; message?: string };
44
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
45
+ throw new Error(`git ${args.join(" ")} failed: ${stderr || e.message || "unknown error"}`);
46
+ }
47
+ }
48
+
49
+ /** Stub PLAN.md body with TBD sections; `soly plan` fills these in later. */
50
+ function stubPlanMarkdown(branchName: string): string {
51
+ return `# Plan: ${branchName}
52
+
53
+ _Stub — fill in via \`soly plan ${branchName}\` (uses ask_pro to gather goal / steps / acceptance criteria)._
54
+
55
+ ## Goal
56
+
57
+ <!-- What does this plan deliver? 1-2 sentences. -->
58
+
59
+ ## Steps
60
+
61
+ <!-- High-level breakdown. ~3-7 bullets. -->
62
+
63
+ ## Acceptance
64
+
65
+ <!-- How will we know the plan is done? -->
66
+ `;
67
+ }
68
+
69
+ /**
70
+ * Build an error/notification text (also returned as `handled: true,
71
+ * transformedText` so the caller shows it; for direct execution the
72
+ * workflow also calls `ui.notify` with the same string).
73
+ */
74
+ function reply(text: string): NewResult {
75
+ return { handled: true, transformedText: text };
76
+ }
77
+
78
+ export function buildNewTransform(
79
+ cmd: SolyCommand,
80
+ state: SolyState,
81
+ ui: Notifier,
82
+ projectRoot: string,
83
+ ): NewResult {
84
+ if (!state.exists) {
85
+ return reply(`soly new: no .agents/ directory in cwd — run /soly init first.`);
86
+ }
87
+
88
+ const parsed = parsePlanName(cmd.args.join(" "));
89
+ if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
90
+
91
+ const { type, name } = parsed;
92
+ const branchName = `${type}/${name}`;
93
+ // Path used by `git add`/`commit` — relative to projectRoot (where `.git/` is).
94
+ // The plan lives at `<root>/.agents/plans/<name>/`, so the repo-relative path
95
+ // is `.agents/plans/<name>`.
96
+ const planDirRel = `.agents/plans/${name}`;
97
+ const planDirAbs = `${state.solyDir}/plans/${name}`;
98
+ const planFile = `${planDirAbs}/PLAN.md`;
99
+
100
+ // 1. Preconditions
101
+ try {
102
+ git(["rev-parse", "--is-inside-work-tree"], { cwd: projectRoot });
103
+ } catch {
104
+ return reply(`soly new: not in a git repository (cwd: ${projectRoot}). Run \`git init\` first.`);
105
+ }
106
+
107
+ const statusOut = git(["status", "--short"], { cwd: projectRoot });
108
+ // Ignore our own planned files if they're present but untracked — that
109
+ // can happen if a previous `soly new` died mid-flight. Anything else
110
+ // (untracked or modified tracked files) is the user's responsibility.
111
+ if (statusOut) {
112
+ return reply(
113
+ `soly new: working tree has uncommitted changes:\n\n${statusOut}\n\n` +
114
+ `Commit or stash them first.`,
115
+ );
116
+ }
117
+
118
+ const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
119
+ if (currentBranch !== "master" && currentBranch !== "main" && !/^[a-z]+\//.test(currentBranch)) {
120
+ return reply(
121
+ `soly new: currently on "${currentBranch}" (not master/main, not a soly plan branch). ` +
122
+ `Switch back to master first with \`git checkout master\`.`,
123
+ );
124
+ }
125
+
126
+ let branchExisted = false;
127
+ try {
128
+ git(["rev-parse", "--verify", branchName], { cwd: projectRoot });
129
+ // Branch already exists — switch to it instead of creating
130
+ branchExisted = true;
131
+ git(["checkout", branchName], { cwd: projectRoot });
132
+ } catch {
133
+ // Branch doesn't exist — create it
134
+ git(["checkout", "-b", branchName], { cwd: projectRoot });
135
+ }
136
+
137
+ try {
138
+ // 2. Scaffold plan dir + stub PLAN.md
139
+ fs.mkdirSync(planDirAbs, { recursive: true });
140
+ fs.writeFileSync(planFile, stubPlanMarkdown(branchName), "utf-8");
141
+
142
+ // 3. Commit (separate from working-tree check so the new files show)
143
+ git(["add", planDirRel], { cwd: projectRoot });
144
+ git(["commit", "-m", `plan: scaffold ${branchName}`], { cwd: projectRoot });
145
+ } catch (err) {
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ return reply(`soly new: scaffold failed — ${msg}\nYou may need to manually clean up branch ${branchName}.`);
148
+ }
149
+
150
+ const notice = branchExisted
151
+ ? `Plan '${name}' reused on existing branch ${branchName}.\nPLAN.md was ${planFile}.\nNext: \`soly plan ${branchName}\``
152
+ : `Plan '${name}' scaffolded on new branch ${branchName}.\nPLAN.md: ${planFile}\nNext: \`soly plan ${branchName}\``;
153
+ ui.notify(notice, "info");
154
+ return {
155
+ handled: true,
156
+ transformedText: notice,
157
+ scaffolded: { branch: branchName, planPath: planFile },
158
+ };
159
+ }
@@ -18,7 +18,40 @@
18
18
  /** Verbs currently supported by the workflow handlers. */
19
19
  export type WorkflowVerb =
20
20
  | "execute" | "pause" | "compact" | "resume" | "status" | "log" | "diff"
21
- | "plan" | "discuss" | "help" | "doctor" | "iterations" | "phase" | "todos" | "verify";
21
+ | "plan" | "discuss" | "help" | "doctor" | "iterations" | "phase" | "todos" | "verify"
22
+ | "new" | "done" | "migrate";
23
+
24
+ /** Allowed Conventional Commits types for `soly new` / `soly plan <type>/<name>` / etc. */
25
+ export const PLAN_TYPES = ["feat", "fix", "chore", "refactor", "docs", "test", "perf", "build", "ci"] as const;
26
+
27
+ /**
28
+ * Validate `<type>/<name>` (e.g. `feat/auth-jwt`). Returns parsed parts or
29
+ * an error message. Pure — no I/O. Shared by `soly new` (workflows/new.ts)
30
+ * and the plan-mode dispatch in `describePlanTarget` / `describeExecuteTarget`.
31
+ */
32
+ export function parsePlanName(raw: string): { type: string; name: string } | { error: string } {
33
+ const trimmed = raw.trim();
34
+ if (!trimmed) return { error: "missing plan name" };
35
+ const m = trimmed.match(/^([a-z]+)\/([a-z0-9][a-z0-9-]*[a-z0-9])$/);
36
+ if (!m) {
37
+ return {
38
+ error:
39
+ `bad plan name "${trimmed}".\n` +
40
+ `\nExpected: <type>/<name>\n` +
41
+ ` type = one of ${PLAN_TYPES.join(", ")}\n` +
42
+ ` name = kebab-case\n` +
43
+ `\nExample: soly plan feat/auth-jwt`,
44
+ };
45
+ }
46
+ const [, type, name] = m;
47
+ if (!(PLAN_TYPES as readonly string[]).includes(type as string)) {
48
+ return { error: `bad type "${type}". Must be one of: ${PLAN_TYPES.join(", ")}` };
49
+ }
50
+ if ((name as string).length > 64) {
51
+ return { error: `name "${name}" is too long (max 64 chars)` };
52
+ }
53
+ return { type: type as string, name: name as string };
54
+ }
22
55
 
23
56
  export interface SolyCommand {
24
57
  verb: WorkflowVerb;
@@ -67,7 +100,10 @@ export function parseSolyCommand(text: string): SolyCommand | null {
67
100
  verb !== "iterations" &&
68
101
  verb !== "phase" &&
69
102
  verb !== "todos" &&
70
- verb !== "verify"
103
+ verb !== "verify" &&
104
+ verb !== "new" &&
105
+ verb !== "done" &&
106
+ verb !== "migrate"
71
107
  ) {
72
108
  return null;
73
109
  }
@@ -157,6 +193,7 @@ function parseNewTaskFlag(
157
193
  */
158
194
  export type ExecuteTarget =
159
195
  | { kind: "phase"; phase: number; plan: number | null; raw: string }
196
+ | { kind: "plan"; type: string; name: string; raw: string }
160
197
  | { kind: "task"; taskId: string; raw: string }
161
198
  | { kind: "all"; raw: string }
162
199
  | { kind: "feature"; feature: string; raw: string };
@@ -191,6 +228,12 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
191
228
  const target = positional.trim();
192
229
  if (!target) return null;
193
230
 
231
+ // <type>/<name> plan name — same identifier model as `soly new`.
232
+ const plan = parsePlanName(target);
233
+ if (!("error" in plan)) {
234
+ return { kind: "plan", type: plan.type, name: plan.name, raw };
235
+ }
236
+
194
237
  const phase = parsePhaseShape(target);
195
238
  if (phase) {
196
239
  return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
@@ -212,12 +255,14 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
212
255
  * What `soly plan ...` should target. Dual-mode with execute.
213
256
  *
214
257
  * phase — plan a phase
258
+ * plan — plan a `<type>/<name>` plan (`.agents/plans/<name>/PLAN.md`)
215
259
  * task — plan (write/flesh out PLAN.md for) an existing task
216
260
  * new-task — create a brand-new task dir + PLAN.md (with frontmatter)
217
261
  * feature — plan all ready tasks in a feature
218
262
  */
219
263
  export type PlanTarget =
220
264
  | { kind: "phase"; phase: number; raw: string }
265
+ | { kind: "plan"; type: string; name: string; raw: string }
221
266
  | { kind: "task"; taskId: string; raw: string }
222
267
  | { kind: "new-task"; slug: string; feature: string; raw: string }
223
268
  | { kind: "feature"; feature: string; raw: string };
@@ -253,6 +298,13 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
253
298
  const target = positional.trim();
254
299
  if (!target) return null;
255
300
 
301
+ // <type>/<name> plan name (e.g. feat/auth-jwt) — plans live at
302
+ // .agents/plans/<name>/PLAN.md, identity is the branch name.
303
+ const plan = parsePlanName(target);
304
+ if (!("error" in plan)) {
305
+ return { kind: "plan", type: plan.type, name: plan.name, raw };
306
+ }
307
+
256
308
  // Plan target only matches plain N (no .MM — plan is per-phase, executed
257
309
  // at the phase level by `soly execute <N.MM>`).
258
310
  const phase = parsePhaseOnlyShape(target);
@@ -15,7 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { describePlanTarget, type SolyCommand } from "./parser.ts";
18
+ import { describePlanTarget, parsePlanName, type SolyCommand } from "./parser.ts";
19
19
  import type { SolyState } from "../core.js";
20
20
  import {
21
21
  extractPlanSummary,
@@ -91,8 +91,45 @@ export function buildPlanTransform(cmd: SolyCommand, state: SolyState): Planning
91
91
  };
92
92
  }
93
93
 
94
- // === PHASE MODE ===
94
+ // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<name>/) ===
95
+ if (target.kind === "plan") {
96
+ const planDirAbs = `${state.solyDir}/plans/${target.name}`;
97
+ const planFile = `${planDirAbs}/PLAN.md`;
98
+ let planBody: string;
99
+ try {
100
+ planBody = fs.readFileSync(planFile, "utf-8");
101
+ } catch {
102
+ planBody = "_No PLAN.md yet — the LLM should ask the user for goal / steps / acceptance and write it from scratch._";
103
+ }
104
+ const instruction = `soly plan ${target.raw} — fleshing out PLAN.md for plan.
105
+
106
+ **Plan:** ${target.name}
107
+ **Branch:** ${target.raw}
108
+ **PLAN.md:** ${planFile}
109
+
110
+ **Inline current PLAN.md body (so you have the existing TBD sections before reading the file):**
111
+ \`\`\`markdown
112
+ ${planBody.slice(0, 4000)}${planBody.length > 4000 ? "\n…(truncated)" : ""}
113
+ \`\`\`
114
+
115
+ **0-POINT CHECK.** Re-read .agents/docs/ (intent) before fleshing out the plan.
116
+
117
+ If ask_pro is available, use it ONCE to gather goal / steps / acceptance criteria (freeText questions, batched). Then write PLAN.md with the answers. If ask_pro is NOT available, write a sensible stub plan and tell the user to refine it via \`soly plan ${target.raw}\` again.
118
+
119
+ Hard rules:
120
+ - Update ONLY ${planFile}. Don't touch other plans or the project root.
121
+ - Use Conventional Commits format: # Plan: ${target.raw} (h1), ## Goal, ## Steps, ## Acceptance.
122
+ - Don't commit — the user reviews and commits.`;
123
+ return { handled: true, transformedText: instruction };
124
+ }
125
+
126
+ // === PHASE MODE (legacy — see W6 in the plans-instead-of-phases plan) ===
95
127
  if (target.kind === "phase") {
128
+ const deprecationNotice =
129
+ "⚠️ PHASE MODE IS LEGACY. Phases have been replaced by plans (each plan = a git branch `<type>/<name>` with `.agents/plans/<name>/PLAN.md`).\n" +
130
+ "To migrate an existing phase to a plan, run \`soly migrate phases-to-plans\` once.\n" +
131
+ "For new work, use \`soly new <type>/<name>\` (e.g. \`soly new feat/auth-jwt\`).\n" +
132
+ "This phase handler still works for backward compat but won't get new features.\n\n";
96
133
  const phase = state.phases.find((p) => p.number === target.phase);
97
134
  if (!phase) {
98
135
  return {
@@ -118,7 +155,7 @@ export function buildPlanTransform(cmd: SolyCommand, state: SolyState): Planning
118
155
  kind: "plan",
119
156
  phaseNumber: target.phase,
120
157
  });
121
- const instruction = `soly plan ${target.raw} — planning phase ${target.phase} (${phase.name}).
158
+ const instruction = deprecationNotice + `soly plan ${target.raw} — planning phase ${target.phase} (${phase.name}).
122
159
 
123
160
  **Iteration context file written:** \`${iter.relPath}\` (${iter.tokens} tokens, ${iter.bytes} bytes)
124
161
  The planner reads this file first — it contains intent, STATE, ROADMAP row for this phase, phase CONTEXT, phase RESEARCH, and prior SUMMARYs.
@@ -463,13 +500,61 @@ export function buildDiscussTransform(
463
500
  }
464
501
 
465
502
  const projectRoot = path.dirname(state.solyDir);
503
+ const arg = (cmd.args[0] ?? "").trim();
504
+ if (!arg) {
505
+ const known = state.phases.map((p) => p.number).join(", ") || "(none)";
506
+ return {
507
+ handled: true,
508
+ transformedText:
509
+ `soly discuss: argument required.\n` +
510
+ `Usage:\n` +
511
+ ` soly discuss <N> — discuss phase N (legacy)\n` +
512
+ ` soly discuss <type>/<name> — discuss plan <type>/<name> (e.g. feat/auth-jwt)\n` +
513
+ `Known phases: ${known}`,
514
+ };
515
+ }
516
+
517
+ // Plan mode: `<type>/<name>`
518
+ const planParsed = parsePlanName(arg);
519
+ if (!("error" in planParsed)) {
520
+ const planDirAbs = `${state.solyDir}/plans/${planParsed.name}`;
521
+ const planFile = `${planDirAbs}/PLAN.md`;
522
+ let planBody: string;
523
+ try {
524
+ planBody = fs.readFileSync(planFile, "utf-8");
525
+ } catch {
526
+ planBody = "_No PLAN.md yet._";
527
+ }
528
+ const instruction = `soly discuss ${arg} — interactive discussion mode for plan.
529
+
530
+ **Plan:** ${planParsed.name}
531
+ **Branch:** ${arg}
532
+ **PLAN.md:** ${planFile}
533
+
534
+ **Inline current PLAN.md body:**
535
+ \`\`\`markdown
536
+ ${planBody.slice(0, 4000)}${planBody.length > 4000 ? "\n…(truncated)" : ""}
537
+ \`\`\`
538
+
539
+ **0-POINT CHECK.** Re-read .agents/docs/ (intent) before discussing.
540
+
541
+ Use \`soly_finish_discuss\` to capture the discussion outcome. The flow:
542
+ 1. Read the plan above.
543
+ 2. Identify ambiguities / open questions / scope clarifications.
544
+ 3.${hasAskPro ? " Use \`ask_pro\` (batched freeText questions) to resolve them with the user." : " Ask the user ONE clear question at a time via the chat."}
545
+ 4. Call \`soly_finish_discuss\` with the captured decision.
546
+ 5. Tell the user the next step: \`soly plan ${arg}\` to update PLAN.md with the discussion outcome.`;
547
+ return { handled: true, transformedText: instruction };
548
+ }
549
+
550
+ // Phase mode (legacy)
466
551
  const target = getPhaseForDiscuss(state, cmd.args);
467
552
  if (!target) {
468
553
  const known = state.phases.map((p) => p.number).join(", ") || "(none)";
469
554
  return {
470
555
  handled: true,
471
556
  transformedText:
472
- `soly discuss: phase argument required and must exist.\n` +
557
+ `soly discuss: bad argument "${arg}".\n` +
473
558
  `Usage: soly discuss <N> (e.g. "soly discuss 11")\n` +
474
559
  `Known phases: ${known}`,
475
560
  };