pi-soly 1.13.5 → 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.
@@ -6,6 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
+ import { solyDirFor } from "../core.js";
9
10
  import type { SolyState } from "../core.js";
10
11
  import type { SolyConfig } from "../config.js";
11
12
 
@@ -28,11 +29,11 @@ interface DoctorCheck {
28
29
  export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, config: SolyConfig, activeTools: string[] = []): void {
29
30
  const checks: DoctorCheck[] = [];
30
31
 
31
- // 1. .soly/ exists
32
+ // 1. .agents/ exists
32
33
  checks.push({
33
- name: ".soly/ directory",
34
+ name: ".agents/ directory",
34
35
  status: state.exists ? "pass" : "fail",
35
- detail: state.exists ? state.solyDir : "no .soly/ found in cwd",
36
+ detail: state.exists ? state.solyDir : "no .agents/ found in cwd",
36
37
  });
37
38
 
38
39
  // 2. STATE.md exists + has frontmatter
@@ -177,13 +178,13 @@ export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, confi
177
178
  }
178
179
  }
179
180
 
180
- // 9. .soly/rules/ exists if state says rules are loaded
181
+ // 9. .agents/rules/ exists if state says rules are loaded
181
182
  if (state.exists) {
182
183
  const rulesDir = path.join(state.solyDir, "rules");
183
184
  checks.push({
184
- name: ".soly/rules/ directory",
185
+ name: ".agents/rules/ directory",
185
186
  status: fs.existsSync(rulesDir) ? "pass" : "warn",
186
- detail: fs.existsSync(rulesDir) ? "present" : "no rules directory — soly will fall back to ~/.soly/rules/",
187
+ detail: fs.existsSync(rulesDir) ? "present" : "no rules directory — soly will fall back to ~/.agents/rules/",
187
188
  });
188
189
  }
189
190
 
@@ -220,7 +221,7 @@ export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, confi
220
221
  name: "project layout",
221
222
  status: legacy ? "info" : "pass",
222
223
  detail: legacy
223
- ? "legacy plans/features detected — run `soly migrate` to move to phases/<N>/tasks/"
224
+ ? "legacy plans/features detected — still supported alongside the unified phases/<N>/tasks/ model"
224
225
  : "unified model (phase = group of tasks)",
225
226
  });
226
227
  }
@@ -258,7 +259,7 @@ function pluralDays(n: number): string {
258
259
  }
259
260
 
260
261
  // ---------------------------------------------------------------------------
261
- // soly todos — read .soly/todos.json or .pi-todos.json and show as a notify.
262
+ // soly todos — read .agents/todos.json or .pi-todos.json and show as a notify.
262
263
  // Mirrors what pi-todo would render in the footer, so the user can see
263
264
  // todos even when pi-todo extension isn't loaded (e.g. just-installed).
264
265
  // ---------------------------------------------------------------------------
@@ -266,7 +267,7 @@ function pluralDays(n: number): string {
266
267
  /** Try to find a todo file in cwd. Returns the path or null. */
267
268
  function findTodosFile(cwd: string): string | null {
268
269
  const candidates = [
269
- path.join(cwd, ".soly", "todos.json"),
270
+ path.join(solyDirFor(cwd), "todos.json"),
270
271
  path.join(cwd, ".pi-todos.json"),
271
272
  ];
272
273
  for (const c of candidates) {
@@ -281,13 +282,13 @@ export function showTodos(
281
282
  ui: InspectUI,
282
283
  ): void {
283
284
  if (!state.exists) {
284
- ui.notify("soly todos: no .soly/ directory in cwd", "error");
285
+ ui.notify("soly todos: no .agents/ directory in cwd", "error");
285
286
  return;
286
287
  }
287
288
  const file = findTodosFile(state.solyDir);
288
289
  if (!file) {
289
290
  ui.notify(
290
- "soly todos: no todo file found. Install the `pi-todo` extension or write `.soly/todos.json` manually.",
291
+ "soly todos: no todo file found. Install the `pi-todo` extension or write `.agents/todos.json` manually.",
291
292
  "info",
292
293
  );
293
294
  return;
@@ -329,7 +330,7 @@ export function showIterations(
329
330
  limitDefault: number = 10,
330
331
  ): void {
331
332
  if (!state.exists) {
332
- ui.notify("soly iterations: no .soly/ directory in cwd", "error");
333
+ ui.notify("soly iterations: no .agents/ directory in cwd", "error");
333
334
  return;
334
335
  }
335
336
  const iterDir = path.join(state.solyDir, "iterations");
@@ -398,7 +399,7 @@ export function showDiffIterations(
398
399
  ui: InspectUI,
399
400
  ): void {
400
401
  if (!state.exists) {
401
- ui.notify("soly diff iterations: no .soly/ directory in cwd", "error");
402
+ ui.notify("soly diff iterations: no .agents/ directory in cwd", "error");
402
403
  return;
403
404
  }
404
405
  const iterDir = path.join(state.solyDir, "iterations");
@@ -456,7 +457,7 @@ export function showPhaseDelete(
456
457
  ui: InspectUI,
457
458
  ): void {
458
459
  if (!state.exists) {
459
- ui.notify("soly phase delete: no .soly/ directory in cwd", "error");
460
+ ui.notify("soly phase delete: no .agents/ directory in cwd", "error");
460
461
  return;
461
462
  }
462
463
  if (cmd.args.length < 1) {
@@ -492,7 +493,7 @@ export function showPhaseDelete(
492
493
  out.push(`✓ Phase ${phaseNum} (${phase.name}) moved to .trash/`);
493
494
  out.push(` ${phase.dir} → ${dest}`);
494
495
  out.push("");
495
- out.push("To restore: `mv` it back to .soly/phases/");
496
+ out.push("To restore: `mv` it back to .agents/phases/");
496
497
  out.push("To permanently delete: `rm -rf " + dest + "`");
497
498
  ui.notify(out.join("\n"), "info");
498
499
  }
@@ -1,85 +1,172 @@
1
1
  // =============================================================================
2
- // workflows/migrate.ts — `soly migrate`: legacy layout → unified (LLM-runnable)
2
+ // workflows/migrate.ts — `soly migrate phases-to-plans`
3
3
  // =============================================================================
4
4
  //
5
- // The unified model is `phases/<N>/tasks/<id>/PLAN.md`. Two legacy shapes need
6
- // converting:
7
- // - standalone plan files `phases/<N>/<NN-MM>-PLAN.md` (+ *-SUMMARY.md)
8
- // - the separate features model `features/<f>/tasks/<id>/`
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
9
  //
10
- // Conversion is semantic (derive a task id/kind/deps from a plan), so we hand
11
- // the model a careful, verify-before-delete protocol rather than doing blind
12
- // file moves in the extension. Returns a transform the LLM executes inline.
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.
13
15
  // =============================================================================
14
16
 
15
- import type { SolyState } from "../core.ts";
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
+ }
16
28
 
17
- export type MigrateResult = { handled: boolean; transformedText?: string };
29
+ type Notifier = {
30
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
31
+ };
18
32
 
19
- /** Phases that still use legacy standalone plan files and have no tasks/ yet. */
20
- function legacyPhases(state: SolyState): string[] {
21
- return state.phases
22
- .filter((p) => p.plans.length > 0 && (!p.tasks || p.tasks.length === 0))
23
- .map((p) => `${p.slug} (${p.plans.length} plan${p.plans.length === 1 ? "" : "s"})`);
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
+ }
24
45
  }
25
46
 
26
- export function buildMigrateTransform(state: SolyState): MigrateResult {
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 {
27
70
  if (!state.exists) {
28
71
  return {
29
72
  handled: true,
30
- transformedText:
31
- `soly migrate: no .soly/ project in this directory — nothing to migrate.`,
73
+ transformedText: "soly migrate: no .agents/ directory in cwd — run /soly init first.",
74
+ migrated: [],
75
+ skipped: [],
32
76
  };
33
77
  }
34
78
 
35
- const phases = legacyPhases(state);
36
- const features = state.features.map((f) => `${f.slug} (${f.taskCount} task${f.taskCount === 1 ? "" : "s"})`);
37
-
38
- if (phases.length === 0 && features.length === 0) {
79
+ const phaseDirs = listPhaseDirs(state.solyDir);
80
+ if (phaseDirs.length === 0) {
39
81
  return {
40
82
  handled: true,
41
- transformedText:
42
- `soly migrate: already on the unified model (phase = group of tasks). Nothing to migrate.`,
83
+ transformedText: "soly migrate: no phases found in .agents/phases/. Nothing to migrate.",
84
+ migrated: [],
85
+ skipped: [],
43
86
  };
44
87
  }
45
88
 
46
- const detected: string[] = [];
47
- if (phases.length > 0) detected.push(`- Legacy plan files in: ${phases.join(", ")}`);
48
- if (features.length > 0) detected.push(`- Legacy \`features/\` dirs: ${features.join(", ")}`);
49
-
50
- return {
51
- handled: true,
52
- transformedText: `soly migrate convert the legacy layout to the unified model (phase = group of tasks).
53
-
54
- Target layout:
55
- .soly/phases/<N>/tasks/<task-id>/PLAN.md (frontmatter: id, kind, status, depends-on, feature?)
56
- .soly/phases/<N>/tasks/<task-id>/SUMMARY.md
57
-
58
- Detected legacy artifacts:
59
- ${detected.join("\n")}
60
-
61
- Do the conversion YOURSELF, carefully, one item at a time. NEVER delete an old file until the new one is written and you've confirmed its contents. Use \`soly_read\` / \`soly_snippet\` to read, your file tools to write/move.
62
-
63
- **A. For each legacy plan \`phases/<N>/<NN-MM>-PLAN.md\`:**
64
- 1. Read it. Derive a stable task id \`<short-slug>-<4hex>\` from the plan's title/intent (lowercase, hyphenated, e.g. \`auth-login-a3f9\`).
65
- 2. Write \`phases/<N>/tasks/<id>/PLAN.md\` with frontmatter, then the original body unchanged:
66
- \`\`\`
67
- ---
68
- id: <id>
69
- kind: <be|fe|infra|docs|test> # infer from the plan
70
- status: <ready|done> # done if a matching *-SUMMARY.md exists
71
- depends-on: [] # fill if the plan depends on earlier plans/tasks
72
- ---
73
- <original plan body>
74
- \`\`\`
75
- 3. If a matching \`<NN-MM>-SUMMARY.md\` exists, move it to \`phases/<N>/tasks/<id>/SUMMARY.md\`.
76
- 4. Verify the new files exist and read correctly, THEN delete the old \`<NN-MM>-PLAN.md\` and its \`*-SUMMARY.md\`.
77
-
78
- **B. For each \`features/<f>/tasks/<id>/\`:**
79
- 1. Decide which phase it belongs to (ask the user with \`ask_pro\` if it's not obvious from the task/feature).
80
- 2. Move the whole task dir to \`phases/<N>/tasks/<id>/\` and add \`feature: <f>\` to its PLAN.md frontmatter.
81
- 3. When a feature's tasks are all moved, remove the now-empty \`features/<f>/\`.
82
-
83
- **Close out:** update any path references in STATE.md / ROADMAP.md, run \`soly status\` and \`soly doctor\` to confirm the new layout loads, then commit. Report a short summary of what moved (counts + any tasks you couldn't place).`,
84
- };
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 };
85
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" | "migrate";
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;
@@ -68,6 +101,8 @@ export function parseSolyCommand(text: string): SolyCommand | null {
68
101
  verb !== "phase" &&
69
102
  verb !== "todos" &&
70
103
  verb !== "verify" &&
104
+ verb !== "new" &&
105
+ verb !== "done" &&
71
106
  verb !== "migrate"
72
107
  ) {
73
108
  return null;
@@ -158,6 +193,7 @@ function parseNewTaskFlag(
158
193
  */
159
194
  export type ExecuteTarget =
160
195
  | { kind: "phase"; phase: number; plan: number | null; raw: string }
196
+ | { kind: "plan"; type: string; name: string; raw: string }
161
197
  | { kind: "task"; taskId: string; raw: string }
162
198
  | { kind: "all"; raw: string }
163
199
  | { kind: "feature"; feature: string; raw: string };
@@ -192,6 +228,12 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
192
228
  const target = positional.trim();
193
229
  if (!target) return null;
194
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
+
195
237
  const phase = parsePhaseShape(target);
196
238
  if (phase) {
197
239
  return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
@@ -213,12 +255,14 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
213
255
  * What `soly plan ...` should target. Dual-mode with execute.
214
256
  *
215
257
  * phase — plan a phase
258
+ * plan — plan a `<type>/<name>` plan (`.agents/plans/<name>/PLAN.md`)
216
259
  * task — plan (write/flesh out PLAN.md for) an existing task
217
260
  * new-task — create a brand-new task dir + PLAN.md (with frontmatter)
218
261
  * feature — plan all ready tasks in a feature
219
262
  */
220
263
  export type PlanTarget =
221
264
  | { kind: "phase"; phase: number; raw: string }
265
+ | { kind: "plan"; type: string; name: string; raw: string }
222
266
  | { kind: "task"; taskId: string; raw: string }
223
267
  | { kind: "new-task"; slug: string; feature: string; raw: string }
224
268
  | { kind: "feature"; feature: string; raw: string };
@@ -254,6 +298,13 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
254
298
  const target = positional.trim();
255
299
  if (!target) return null;
256
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
+
257
308
  // Plan target only matches plain N (no .MM — plan is per-phase, executed
258
309
  // at the phase level by `soly execute <N.MM>`).
259
310
  const phase = parsePhaseOnlyShape(target);
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // Like execute, we transform the input into a detailed LLM instruction that
9
9
  // walks the LLM through the soly pause-work workflow. The LLM produces both:
10
- // - .soly/HANDOFF.json (machine-readable state for resume)
11
- // - .soly/.continue-here.md (human-readable context)
10
+ // - .agents/HANDOFF.json (machine-readable state for resume)
11
+ // - .agents/.continue-here.md (human-readable context)
12
12
  //
13
13
  // For `compact`, we additionally call ctx.compact() AFTER the handoff files
14
14
  // are written — but we still let the LLM drive the handoff generation, since
@@ -94,7 +94,7 @@ export function buildPauseTransform(
94
94
  return {
95
95
  handled: true,
96
96
  transformedText:
97
- `soly: no .soly/ directory found in cwd (${state.solyDir || "<cwd>"}) — nothing to pause.\n` +
97
+ `soly: no .agents/ directory found in cwd (${state.solyDir || "<cwd>"}) — nothing to pause.\n` +
98
98
  `If you wanted to start a soly project, see the soly quickstart.`,
99
99
  triggerCompact: false,
100
100
  };
@@ -122,7 +122,7 @@ export function buildPauseTransform(
122
122
 
123
123
  ${actionLine}
124
124
 
125
- Current position (from .soly/STATE.md):
125
+ Current position (from .agents/STATE.md):
126
126
  ${state.position
127
127
  ? ` phase: ${state.position.phase}\n plan: ${state.position.plan}\n status: ${state.position.status}`
128
128
  : " (no position set — likely pre-planning or paused at a milestone boundary)"}
@@ -139,7 +139,7 @@ Follow the workflow below VERBATIM — these are the user-approved soly instruct
139
139
  ${workflow}
140
140
  === END WORKFLOW ===
141
141
 
142
- After you write both .soly/HANDOFF.json and .soly/.continue-here.md, tell the user:
142
+ After you write both .agents/HANDOFF.json and .agents/.continue-here.md, tell the user:
143
143
  - where the files were written (absolute paths)
144
144
  - the resume command: soly resume
145
145
  - ${isCompact ? "session will be compacted at end of this turn" : "session state preserved as-is"}