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 +21 -6
- package/iteration.ts +2 -0
- package/package.json +1 -1
- package/workflows/done.ts +203 -0
- package/workflows/execute.ts +89 -0
- package/workflows/index.ts +26 -0
- package/workflows/migrate.ts +172 -0
- package/workflows/new.ts +159 -0
- package/workflows/parser.ts +54 -2
- package/workflows/planning.ts +89 -4
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
|
-
|
|
82
|
-
soly
|
|
83
|
-
soly
|
|
84
|
-
soly
|
|
85
|
-
soly
|
|
86
|
-
soly
|
|
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.
|
|
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
|
+
}
|
package/workflows/execute.ts
CHANGED
|
@@ -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 {
|
package/workflows/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/workflows/new.ts
ADDED
|
@@ -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
|
+
}
|
package/workflows/parser.ts
CHANGED
|
@@ -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);
|
package/workflows/planning.ts
CHANGED
|
@@ -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
|
-
// ===
|
|
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:
|
|
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
|
};
|