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.
- package/README.md +29 -15
- package/artifact/index.ts +3 -3
- package/artifact/render.ts +2 -2
- package/ask/README.md +3 -1
- package/ask/index.ts +16 -2
- package/codemap.ts +2 -2
- package/commands.ts +34 -63
- package/config.ts +20 -12
- package/core.ts +16 -18
- package/docs.ts +4 -4
- package/index.ts +20 -21
- package/init.ts +7 -7
- package/intent.ts +12 -12
- package/iteration.ts +14 -12
- package/notification.ts +1 -1
- package/notifications-log.ts +2 -2
- package/nudge.ts +41 -6
- package/package.json +1 -2
- package/skills/soly-framework/SKILL.md +34 -42
- package/status.ts +2 -2
- package/tools.ts +14 -14
- package/util.ts +2 -2
- package/visual/welcome.ts +3 -3
- package/workflows/done.ts +203 -0
- package/workflows/execute.ts +105 -16
- package/workflows/index.ts +25 -9
- package/workflows/inspect.ts +16 -15
- package/workflows/migrate.ts +150 -63
- package/workflows/new.ts +159 -0
- package/workflows/parser.ts +52 -1
- package/workflows/pause.ts +5 -5
- package/workflows/planning.ts +107 -22
- package/workflows/quick.ts +7 -7
- package/workflows/resume.ts +14 -14
- package/workflows-data/discuss-phase.md +9 -9
- package/workflows-data/execute-phase.md +4 -4
- package/workflows-data/execute-plan.md +8 -8
- package/workflows-data/execute-task.md +7 -7
- package/workflows-data/pause-work.md +18 -18
- package/workflows-data/plan-phase.md +7 -7
- package/workflows-data/plan-task.md +18 -18
- package/migrate.ts +0 -258
package/workflows/inspect.ts
CHANGED
|
@@ -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. .
|
|
32
|
+
// 1. .agents/ exists
|
|
32
33
|
checks.push({
|
|
33
|
-
name: ".
|
|
34
|
+
name: ".agents/ directory",
|
|
34
35
|
status: state.exists ? "pass" : "fail",
|
|
35
|
-
detail: state.exists ? state.solyDir : "no .
|
|
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. .
|
|
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: ".
|
|
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 ~/.
|
|
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 —
|
|
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 .
|
|
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, "
|
|
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 .
|
|
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 `.
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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
|
}
|
package/workflows/migrate.ts
CHANGED
|
@@ -1,85 +1,172 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// workflows/migrate.ts — `soly migrate
|
|
2
|
+
// workflows/migrate.ts — `soly migrate phases-to-plans`
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
29
|
+
type Notifier = {
|
|
30
|
+
notify: (text: string, level?: "info" | "warning" | "error") => void;
|
|
31
|
+
};
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
83
|
+
transformedText: "soly migrate: no phases found in .agents/phases/. Nothing to migrate.",
|
|
84
|
+
migrated: [],
|
|
85
|
+
skipped: [],
|
|
43
86
|
};
|
|
44
87
|
}
|
|
45
88
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
${
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
}
|
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;
|
|
@@ -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);
|
package/workflows/pause.ts
CHANGED
|
@@ -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
|
-
// - .
|
|
11
|
-
// - .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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"}
|