pi-soly 1.15.1 → 1.16.1
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/commands.ts +116 -9
- package/config.ts +22 -0
- package/index.ts +1 -0
- package/nudge.ts +27 -3
- package/package.json +1 -1
- package/workflows/done.ts +6 -5
- package/workflows/index.ts +10 -2
- package/workflows/new.ts +22 -8
- package/workflows/parser.ts +89 -35
package/commands.ts
CHANGED
|
@@ -46,6 +46,12 @@ import type { SolyConfig } from "./config.ts";
|
|
|
46
46
|
import { initSolyProject } from "./init.js";
|
|
47
47
|
import { ListPanel, type ListItem, type ListAction } from "./visual/list-panel.ts";
|
|
48
48
|
import { getArtifactServer, ensureArtifactServer, artifactDir } from "./artifact/session.ts";
|
|
49
|
+
import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "./workflows/parser.ts";
|
|
50
|
+
import { buildNewTransform } from "./workflows/new.ts";
|
|
51
|
+
import { buildDoneTransform } from "./workflows/done.ts";
|
|
52
|
+
import { buildMigrateTransform } from "./workflows/migrate.ts";
|
|
53
|
+
import { buildPlanTransform, buildDiscussTransform } from "./workflows/planning.ts";
|
|
54
|
+
import { buildExecuteTransform } from "./workflows/execute.ts";
|
|
49
55
|
|
|
50
56
|
/** Minimum ui surface the command handlers actually need. */
|
|
51
57
|
export interface CommandUI {
|
|
@@ -558,6 +564,70 @@ What must the LLM do?
|
|
|
558
564
|
description: string;
|
|
559
565
|
run: (parts: string[]) => void | Promise<void>;
|
|
560
566
|
};
|
|
567
|
+
/** Dispatch a workflow verb from the `/soly` slash-command picker.
|
|
568
|
+
* Same handlers used by the `soly <verb> ...` text-command path —
|
|
569
|
+
* registered here so humans can also drive them via
|
|
570
|
+
* `/soly <verb> ...` without having to type natural language and
|
|
571
|
+
* wait for the LLM to translate. */
|
|
572
|
+
const runWorkflow = async (
|
|
573
|
+
verb: WorkflowVerb,
|
|
574
|
+
parts: string[],
|
|
575
|
+
ctx: ExtensionCommandContext,
|
|
576
|
+
ui: CommandUI,
|
|
577
|
+
): Promise<void> => {
|
|
578
|
+
// parts[0] is the verb name; the args follow.
|
|
579
|
+
const args = parts.slice(1);
|
|
580
|
+
const cmd: SolyCommand = {
|
|
581
|
+
verb,
|
|
582
|
+
args,
|
|
583
|
+
raw: `soly ${verb}${args.length ? " " + args.join(" ") : ""}`,
|
|
584
|
+
};
|
|
585
|
+
const state = getState();
|
|
586
|
+
if (!state.exists) {
|
|
587
|
+
ui.notify("soly: no .agents/ project here — run `/soly init` to scaffold one", "info");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
switch (verb) {
|
|
591
|
+
case "new": {
|
|
592
|
+
const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
|
|
593
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
case "done": {
|
|
597
|
+
const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
|
|
598
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
case "migrate": {
|
|
602
|
+
const r = buildMigrateTransform(state, ui, ctx.cwd);
|
|
603
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
case "plan": {
|
|
607
|
+
const r = buildPlanTransform(cmd, state);
|
|
608
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
case "discuss": {
|
|
612
|
+
// Slash-command path doesn't have live ask_pro detection.
|
|
613
|
+
// Pass `false` so the discuss transform falls back to its
|
|
614
|
+
// plain-text discussion format; the LLM-driven path still
|
|
615
|
+
// gets the richer ask_pro flow.
|
|
616
|
+
const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
|
|
617
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
case "execute": {
|
|
621
|
+
// Slash-command path doesn't have live interactive rules.
|
|
622
|
+
const r = buildExecuteTransform(cmd, state, []);
|
|
623
|
+
if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
default:
|
|
627
|
+
ui.notify(`soly: workflow '${verb}' not yet wired into slash command`, "info");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
561
631
|
const subcommands: Record<string, SolySub> = {
|
|
562
632
|
// `agent` subcommand REMOVED — moved to the separate `pi-switch`
|
|
563
633
|
// extension (rotor switcher removed in 1.4.0).
|
|
@@ -612,17 +682,25 @@ What must the LLM do?
|
|
|
612
682
|
run: () => showFile("STATE.md", getState().stateBody),
|
|
613
683
|
},
|
|
614
684
|
plan: {
|
|
615
|
-
description: "current PLAN.md body",
|
|
616
|
-
run: () => {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
685
|
+
description: "current PLAN.md body, OR `plan <slug>` to flesh out a plan",
|
|
686
|
+
run: (parts) => {
|
|
687
|
+
// Dual-purpose: no args = show current PLAN.md; with args
|
|
688
|
+
// = dispatch to the plan-mode workflow. Same key the
|
|
689
|
+
// state picker already had (state picker items and
|
|
690
|
+
// workflow verbs share the same `/soly` namespace).
|
|
691
|
+
if (parts.length <= 1) {
|
|
692
|
+
const s = getState();
|
|
693
|
+
if (!s.currentPlanPath) {
|
|
694
|
+
ui.notify("soly: no current plan", "error");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
showFile(
|
|
698
|
+
`PLAN: ${path.basename(s.currentPlanPath)}`,
|
|
699
|
+
readIfExists(s.currentPlanPath),
|
|
700
|
+
);
|
|
620
701
|
return;
|
|
621
702
|
}
|
|
622
|
-
|
|
623
|
-
`PLAN: ${path.basename(s.currentPlanPath)}`,
|
|
624
|
-
readIfExists(s.currentPlanPath),
|
|
625
|
-
);
|
|
703
|
+
void runWorkflow("plan", parts, ctx, ui);
|
|
626
704
|
},
|
|
627
705
|
},
|
|
628
706
|
context: {
|
|
@@ -797,6 +875,35 @@ What must the LLM do?
|
|
|
797
875
|
);
|
|
798
876
|
},
|
|
799
877
|
},
|
|
878
|
+
// ------------------------------------------------------------------
|
|
879
|
+
// Workflow verbs — same handlers used by the `soly <verb> ...`
|
|
880
|
+
// text-command path. Registered here so humans can also drive
|
|
881
|
+
// them via `/soly <verb> ...` from the slash picker without
|
|
882
|
+
// typing natural language and waiting for the LLM to translate.
|
|
883
|
+
// Each verb's buildXxxTransform already calls ui.notify on
|
|
884
|
+
// success, so we just need to construct a fake `SolyCommand`
|
|
885
|
+
// from the slash args and call the handler.
|
|
886
|
+
// ------------------------------------------------------------------
|
|
887
|
+
new: {
|
|
888
|
+
description: "scaffold a new plan: create branch + .agents/plans/<slug>/PLAN.md (e.g. `/soly new statistic-preparation`)",
|
|
889
|
+
run: (parts) => runWorkflow("new", parts, ctx, ui),
|
|
890
|
+
},
|
|
891
|
+
execute: {
|
|
892
|
+
description: "execute a plan via subagent (e.g. `/soly execute statistic-preparation`)",
|
|
893
|
+
run: (parts) => runWorkflow("execute", parts, ctx, ui),
|
|
894
|
+
},
|
|
895
|
+
discuss: {
|
|
896
|
+
description: "interactive discussion of a plan (e.g. `/soly discuss statistic-preparation`)",
|
|
897
|
+
run: (parts) => runWorkflow("discuss", parts, ctx, ui),
|
|
898
|
+
},
|
|
899
|
+
done: {
|
|
900
|
+
description: "commit + push + open draft PR for a plan branch (e.g. `/soly done statistic-preparation`)",
|
|
901
|
+
run: (parts) => runWorkflow("done", parts, ctx, ui),
|
|
902
|
+
},
|
|
903
|
+
migrate: {
|
|
904
|
+
description: "one-shot: import .agents/phases/<NN>-<slug>/plans/PLAN.md as plan branches",
|
|
905
|
+
run: () => runWorkflow("migrate", [], ctx, ui),
|
|
906
|
+
},
|
|
800
907
|
};
|
|
801
908
|
|
|
802
909
|
// Single-width BMP glyphs only — astral/VS16 emoji are mis-measured by
|
package/config.ts
CHANGED
|
@@ -121,6 +121,18 @@ export interface SolyConfig {
|
|
|
121
121
|
* 0 = keep forever. */
|
|
122
122
|
retentionDays: number;
|
|
123
123
|
};
|
|
124
|
+
plan: {
|
|
125
|
+
/** Optional branch prefix prepended when the user runs `soly new <slug>`
|
|
126
|
+
* without an explicit `<prefix>/<slug>` form. Common values:
|
|
127
|
+
* "feature" → branches like `feature/statistic-preparation`
|
|
128
|
+
* "fix" → branches like `fix/login-redirect`
|
|
129
|
+
* "" (default)→ no prefix; branch = slug verbatim.
|
|
130
|
+
* Per-project only (set in `.agents/soly.json`). User-supplied
|
|
131
|
+
* `<prefix>/<slug>` always wins over this default.
|
|
132
|
+
* Plan dir is always `.agents/plans/<prefix>-<slug>` (flattened)
|
|
133
|
+
* so the on-disk layout stays one-deep. */
|
|
134
|
+
defaultBranchPrefix: string;
|
|
135
|
+
};
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
export const DEFAULT_CONFIG: SolyConfig = {
|
|
@@ -184,6 +196,9 @@ export const DEFAULT_CONFIG: SolyConfig = {
|
|
|
184
196
|
theme: "",
|
|
185
197
|
retentionDays: 7,
|
|
186
198
|
},
|
|
199
|
+
plan: {
|
|
200
|
+
defaultBranchPrefix: "", // "" = no prefix; branch = slug verbatim
|
|
201
|
+
},
|
|
187
202
|
};
|
|
188
203
|
|
|
189
204
|
/** Raw parsed shape from a config.json — could be partial, could have wrong types. */
|
|
@@ -256,6 +271,13 @@ function deepMerge(base: SolyConfig, over: RawConfig): SolyConfig {
|
|
|
256
271
|
if (over.editor && typeof over.editor.command === "string") {
|
|
257
272
|
merged.editor.command = over.editor.command;
|
|
258
273
|
}
|
|
274
|
+
if (over.plan && typeof over.plan.defaultBranchPrefix === "string") {
|
|
275
|
+
// Sanitize: allow only kebab-case words (no slashes, dots, etc.).
|
|
276
|
+
// Empty string is allowed and means "no prefix".
|
|
277
|
+
const sanitized = over.plan.defaultBranchPrefix.replace(/[^a-z0-9-]/gi, "");
|
|
278
|
+
const collapsed = sanitized.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
279
|
+
merged.plan.defaultBranchPrefix = collapsed;
|
|
280
|
+
}
|
|
259
281
|
if (over.chrome) {
|
|
260
282
|
if (typeof over.chrome.enabled === "boolean") merged.chrome.enabled = over.chrome.enabled;
|
|
261
283
|
if (typeof over.chrome.ascii === "boolean") merged.chrome.ascii = over.chrome.ascii;
|
package/index.ts
CHANGED
|
@@ -694,6 +694,7 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
694
694
|
buildNudgeSection(heuristics, {
|
|
695
695
|
hasProject: state.exists,
|
|
696
696
|
confirmBeforeCode: getActiveConfig().agent.confirmBeforeCode,
|
|
697
|
+
defaultBranchPrefix: getActiveConfig().plan.defaultBranchPrefix,
|
|
697
698
|
}),
|
|
698
699
|
);
|
|
699
700
|
|
package/nudge.ts
CHANGED
|
@@ -117,7 +117,11 @@ const ASK_DIRECTIVE = `**Confirm before coding.** Don't jump straight into writi
|
|
|
117
117
|
|
|
118
118
|
export function buildNudgeSection(
|
|
119
119
|
heuristics: TaskHeuristics,
|
|
120
|
-
opts: {
|
|
120
|
+
opts: {
|
|
121
|
+
hasProject?: boolean;
|
|
122
|
+
confirmBeforeCode?: boolean | ConfirmLevel;
|
|
123
|
+
defaultBranchPrefix?: string;
|
|
124
|
+
} = {},
|
|
121
125
|
): string {
|
|
122
126
|
// Always-on rules (cheap to add, high signal):
|
|
123
127
|
// - Don't dive in on non-trivial tasks without a brief check
|
|
@@ -142,10 +146,30 @@ export function buildNudgeSection(
|
|
|
142
146
|
: "";
|
|
143
147
|
|
|
144
148
|
// When there's an active soly project and the task is non-trivial, steer the
|
|
145
|
-
// model toward the workflow lifecycle instead of ad-hoc edits.
|
|
149
|
+
// model toward the workflow lifecycle instead of ad-hoc edits. This block
|
|
150
|
+
// also tells the model it may *itself* scaffold a new plan after asking the
|
|
151
|
+
// user — the user explicitly opted into this in 1.16.0.
|
|
152
|
+
const prefix = opts.defaultBranchPrefix ?? "";
|
|
153
|
+
const branchLine = prefix
|
|
154
|
+
? `Branches look like \`${prefix}/<slug>\` (the project default is **\`"${prefix}"\`**). Plan dir: \`.agents/plans/${prefix}-<slug>/\`.`
|
|
155
|
+
: `Branches look like \`<slug>\` (no project default prefix is set). Plan dir: \`.agents/plans/<slug>/\`.`;
|
|
146
156
|
const workflowPoint =
|
|
147
157
|
opts.hasProject && heuristics.nonTrivial
|
|
148
|
-
? `\n\n4. **Route project work through the soly workflow.**
|
|
158
|
+
? `\n\n4. **Route project work through the soly plan workflow.** Each plan is a git branch with \`.agents/plans/<slug>/PLAN.md\` on it. Two parallel plans don't collide.
|
|
159
|
+
|
|
160
|
+
**Branch naming convention for THIS project:** ${branchLine}
|
|
161
|
+
\`soly new <slug>\` applies the project prefix automatically. \`soly new <prefix>/<slug>\` overrides it (use this when the work isn't a "feature" — e.g. \`soly new fix/login-redirect-bug\`).
|
|
162
|
+
|
|
163
|
+
Lifecycle (text commands you type — pi routes them through the workflow handlers):
|
|
164
|
+
- \`soly new <slug-or-prefix>/<slug>\` — scaffold: branch + plan dir + stub PLAN.md + commit
|
|
165
|
+
- \`soly discuss <slug>\` — interactive discussion of the plan (uses ask_pro)
|
|
166
|
+
- \`soly plan <slug>\` — flesh out PLAN.md via ask_pro
|
|
167
|
+
- \`soly execute <slug>\` — execute the plan in a subagent
|
|
168
|
+
- \`soly done <slug>\` — commit + push + open draft PR via gh
|
|
169
|
+
- \`soly verify\` — self-review loop until clean
|
|
170
|
+
- \`soly status\` — current position + progress (no LLM round-trip)
|
|
171
|
+
|
|
172
|
+
**You may scaffold a new plan yourself** when the user asks for a new piece of work and the scope is clear (or the user just confirmed). Propose the slug AND the full branch name first via ask_pro — offer the default \`${prefix ? prefix + "/" : ""}<slug>\` plus the alternatives \`fix/<slug>\`, \`chore/<slug>\`, or just \`<slug>\` (no prefix). Pick whatever fits the work, then type \`soly new …\` in your next output. Don't ask for trivial one-liners or when the user already said "go". Skip only for a genuine one-off that doesn't deserve a plan branch.`
|
|
149
173
|
: "";
|
|
150
174
|
|
|
151
175
|
// Confirm-before-coding gate: for non-trivial implementation, pull the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
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",
|
package/workflows/done.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function buildDoneTransform(
|
|
|
84
84
|
state: SolyState,
|
|
85
85
|
ui: Notifier,
|
|
86
86
|
projectRoot: string,
|
|
87
|
-
opts: { ghPath?: string } = {},
|
|
87
|
+
opts: { ghPath?: string; defaultBranchPrefix?: string } = {},
|
|
88
88
|
): DoneResult {
|
|
89
89
|
if (!state.exists) {
|
|
90
90
|
return reply(`soly done: no .agents/ directory in cwd — run /soly init first.`);
|
|
@@ -93,10 +93,11 @@ export function buildDoneTransform(
|
|
|
93
93
|
const raw = cmd.args.join(" ").trim();
|
|
94
94
|
const parsed = parsePlanName(raw);
|
|
95
95
|
if ("error" in parsed) {
|
|
96
|
-
return reply(`soly done: ${parsed.error}\n\nUsage: soly done <
|
|
96
|
+
return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
|
|
97
97
|
}
|
|
98
|
-
const {
|
|
99
|
-
const
|
|
98
|
+
const { name, prefix } = parsed;
|
|
99
|
+
const effectivePrefix = prefix ?? opts.defaultBranchPrefix ?? "";
|
|
100
|
+
const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name;
|
|
100
101
|
|
|
101
102
|
// 1. Preconditions
|
|
102
103
|
const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot });
|
|
@@ -126,7 +127,7 @@ export function buildDoneTransform(
|
|
|
126
127
|
// Re-check after add
|
|
127
128
|
const statusAfter = git(["status", "--short"], { cwd: projectRoot });
|
|
128
129
|
if (statusAfter) {
|
|
129
|
-
git(["commit", "-m", `${
|
|
130
|
+
git(["commit", "-m", `${name}: wip`], { cwd: projectRoot });
|
|
130
131
|
}
|
|
131
132
|
} catch (err) {
|
|
132
133
|
const msg = err instanceof Error ? err.message : String(err);
|
package/workflows/index.ts
CHANGED
|
@@ -148,7 +148,13 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
if (cmd.verb === "new") {
|
|
151
|
-
const result = buildNewTransform(
|
|
151
|
+
const result = buildNewTransform(
|
|
152
|
+
cmd,
|
|
153
|
+
state,
|
|
154
|
+
ctx.ui,
|
|
155
|
+
ctx.cwd,
|
|
156
|
+
getConfig().plan.defaultBranchPrefix,
|
|
157
|
+
);
|
|
152
158
|
if (!result.handled || !result.transformedText) return;
|
|
153
159
|
// Direct execution (the workflow already called ui.notify). The
|
|
154
160
|
// transformed text goes to the model so it can also tell the user
|
|
@@ -157,7 +163,9 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
if (cmd.verb === "done") {
|
|
160
|
-
const result = buildDoneTransform(cmd, state, ctx.ui, ctx.cwd
|
|
166
|
+
const result = buildDoneTransform(cmd, state, ctx.ui, ctx.cwd, {
|
|
167
|
+
defaultBranchPrefix: getConfig().plan.defaultBranchPrefix,
|
|
168
|
+
});
|
|
161
169
|
if (!result.handled || !result.transformedText) return;
|
|
162
170
|
// Direct execution — workflow already called ui.notify. The
|
|
163
171
|
// transformed text tells the LLM (and the user in chat) what happened.
|
package/workflows/new.ts
CHANGED
|
@@ -80,6 +80,7 @@ export function buildNewTransform(
|
|
|
80
80
|
state: SolyState,
|
|
81
81
|
ui: Notifier,
|
|
82
82
|
projectRoot: string,
|
|
83
|
+
defaultBranchPrefix = "",
|
|
83
84
|
): NewResult {
|
|
84
85
|
if (!state.exists) {
|
|
85
86
|
return reply(`soly new: no .agents/ directory in cwd — run /soly init first.`);
|
|
@@ -88,13 +89,18 @@ export function buildNewTransform(
|
|
|
88
89
|
const parsed = parsePlanName(cmd.args.join(" "));
|
|
89
90
|
if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
|
|
90
91
|
|
|
91
|
-
const {
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
const { name, prefix } = parsed;
|
|
93
|
+
// Branch can be one of three shapes:
|
|
94
|
+
// - "feature/statistic-preparation" (user typed <prefix>/<slug>)
|
|
95
|
+
// - "feature/statistic-preparation" (config has defaultBranchPrefix "feature", user typed "statistic-preparation")
|
|
96
|
+
// - "statistic-preparation" (no prefix anywhere)
|
|
97
|
+
// The plan dir is always flattened to keep the on-disk layout one-deep:
|
|
98
|
+
// `.agents/plans/<prefix>-<slug>` or `.agents/plans/<slug>`.
|
|
99
|
+
const effectivePrefix = prefix ?? defaultBranchPrefix;
|
|
100
|
+
const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name;
|
|
101
|
+
const dirSlug = effectivePrefix ? `${effectivePrefix}-${name}` : name;
|
|
102
|
+
const planDirRel = `.agents/plans/${dirSlug}`;
|
|
103
|
+
const planDirAbs = `${state.solyDir}/plans/${dirSlug}`;
|
|
98
104
|
const planFile = `${planDirAbs}/PLAN.md`;
|
|
99
105
|
|
|
100
106
|
// 1. Preconditions
|
|
@@ -116,7 +122,15 @@ export function buildNewTransform(
|
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
|
|
119
|
-
|
|
125
|
+
// A plan branch is a kebab-case slug (no `<type>/` prefix after 1.15.x).
|
|
126
|
+
// The branch can also be the long-lived integration branches `master`
|
|
127
|
+
// or `main`. Anything else (release tags, weird suffixes) → user must
|
|
128
|
+
// checkout first.
|
|
129
|
+
if (
|
|
130
|
+
currentBranch !== "master" &&
|
|
131
|
+
currentBranch !== "main" &&
|
|
132
|
+
!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch)
|
|
133
|
+
) {
|
|
120
134
|
return reply(
|
|
121
135
|
`soly new: currently on "${currentBranch}" (not master/main, not a soly plan branch). ` +
|
|
122
136
|
`Switch back to master first with \`git checkout master\`.`,
|
package/workflows/parser.ts
CHANGED
|
@@ -21,36 +21,81 @@ export type WorkflowVerb =
|
|
|
21
21
|
| "plan" | "discuss" | "help" | "doctor" | "iterations" | "phase" | "todos" | "verify"
|
|
22
22
|
| "new" | "done" | "migrate";
|
|
23
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
24
|
/**
|
|
28
|
-
* Validate `<
|
|
25
|
+
* Validate `<slug>` (e.g. `statistic-preparation`). Returns parsed parts or
|
|
29
26
|
* an error message. Pure — no I/O. Shared by `soly new` (workflows/new.ts)
|
|
30
27
|
* and the plan-mode dispatch in `describePlanTarget` / `describeExecuteTarget`.
|
|
28
|
+
*
|
|
29
|
+
* Convention: a plan is a kebab-case slug that doubles as the git branch
|
|
30
|
+
* name. No type prefix — we dropped the Conventional-Branches `<type>/<name>`
|
|
31
|
+
* shape after 1.15.x because the type adds noise without information for
|
|
32
|
+
* `soly new` users (the branch list itself is the registry, and the prefix
|
|
33
|
+
* doesn't help readers pick the right branch).
|
|
34
|
+
*
|
|
35
|
+
* Examples:
|
|
36
|
+
* soly new statistic-preparation
|
|
37
|
+
* soly new login-redirect-bug
|
|
38
|
+
* soly new stats-rollup
|
|
31
39
|
*/
|
|
32
|
-
export function parsePlanName(
|
|
40
|
+
export function parsePlanName(
|
|
41
|
+
raw: string,
|
|
42
|
+
): { name: string; prefix: string | null } | { error: string } {
|
|
33
43
|
const trimmed = raw.trim();
|
|
34
44
|
if (!trimmed) return { error: "missing plan name" };
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
|
|
46
|
+
// Optional `<prefix>/<slug>` form: prefix is one kebab-case word
|
|
47
|
+
// (no internal slashes), slug is the same kebab-case shape. Examples:
|
|
48
|
+
// feature/statistic-preparation
|
|
49
|
+
// fix/login-redirect-bug
|
|
50
|
+
// chore/upgrade-deps
|
|
51
|
+
// Plan dirs flatten the slash: `.agents/plans/<prefix>-<slug>`.
|
|
52
|
+
const slashIdx = trimmed.indexOf("/");
|
|
53
|
+
if (slashIdx > 0) {
|
|
54
|
+
const prefix = trimmed.slice(0, slashIdx);
|
|
55
|
+
const slug = trimmed.slice(slashIdx + 1);
|
|
56
|
+
const prefixOk = /^[a-z][a-z0-9-]*[a-z0-9]$/.test(prefix);
|
|
57
|
+
const slugOk = /^[a-z][a-z0-9-]*[a-z0-9]$/.test(slug);
|
|
58
|
+
if (!prefixOk || !slugOk) {
|
|
59
|
+
return {
|
|
60
|
+
error:
|
|
61
|
+
`bad plan name "${trimmed}".\n` +
|
|
62
|
+
`\nExpected: <slug> OR <prefix>/<slug>\n` +
|
|
63
|
+
` prefix: one kebab-case word (e.g. "feature", "fix", "chore")\n` +
|
|
64
|
+
` slug: kebab-case (lowercase letters, digits, hyphens)\n` +
|
|
65
|
+
`\nExample: soly new feature/statistic-preparation`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { name: slug, prefix };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Plain slug form. Must contain at least one letter — pure-digit strings
|
|
72
|
+
// are reserved for phase numbers (`soly plan 11` → phase 11), not slugs.
|
|
73
|
+
if (!/[a-z]/.test(trimmed)) {
|
|
37
74
|
return {
|
|
38
75
|
error:
|
|
39
76
|
`bad plan name "${trimmed}".\n` +
|
|
40
|
-
`\nExpected: <
|
|
41
|
-
`
|
|
42
|
-
`
|
|
43
|
-
`\nExample: soly
|
|
77
|
+
`\nExpected: <slug> OR <prefix>/<slug>\n` +
|
|
78
|
+
` chars: lowercase letters, digits, and hyphens\n` +
|
|
79
|
+
` must start and end with a letter or digit\n` +
|
|
80
|
+
`\nExample: soly new statistic-preparation`,
|
|
44
81
|
};
|
|
45
82
|
}
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
48
|
-
return {
|
|
83
|
+
const m = trimmed.match(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
|
|
84
|
+
if (!m) {
|
|
85
|
+
return {
|
|
86
|
+
error:
|
|
87
|
+
`bad plan name "${trimmed}".\n` +
|
|
88
|
+
`\nExpected: <slug> OR <prefix>/<slug>\n` +
|
|
89
|
+
` length: 2-64 chars\n` +
|
|
90
|
+
` chars: lowercase letters, digits, and hyphens\n` +
|
|
91
|
+
` must start and end with a letter or digit\n` +
|
|
92
|
+
`\nExample: soly new statistic-preparation`,
|
|
93
|
+
};
|
|
49
94
|
}
|
|
50
|
-
if (
|
|
51
|
-
return { error: `name "${
|
|
95
|
+
if (trimmed.length > 64) {
|
|
96
|
+
return { error: `name "${trimmed}" is too long (max 64 chars)` };
|
|
52
97
|
}
|
|
53
|
-
return {
|
|
98
|
+
return { name: trimmed, prefix: null };
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
export interface SolyCommand {
|
|
@@ -160,7 +205,10 @@ function parsePhaseOnlyShape(s: string): { phase: number } | null {
|
|
|
160
205
|
|
|
161
206
|
/** Match task-id `<slug>-<4hex>`, case-insensitive. */
|
|
162
207
|
function parseTaskIdShape(s: string): string | null {
|
|
163
|
-
|
|
208
|
+
const m = s.match(/^([a-z0-9][a-z0-9-]*)-([a-f0-9]{4})$/i);
|
|
209
|
+
if (!m) return null;
|
|
210
|
+
// Normalize to lowercase hex so downstream comparisons match.
|
|
211
|
+
return `${m[1]}-${m[2].toLowerCase()}`;
|
|
164
212
|
}
|
|
165
213
|
|
|
166
214
|
/** Extract `--feature <name>` from args. Returns null if not present or invalid. */
|
|
@@ -193,7 +241,7 @@ function parseNewTaskFlag(
|
|
|
193
241
|
*/
|
|
194
242
|
export type ExecuteTarget =
|
|
195
243
|
| { kind: "phase"; phase: number; plan: number | null; raw: string }
|
|
196
|
-
| { kind: "plan";
|
|
244
|
+
| { kind: "plan"; name: string; raw: string }
|
|
197
245
|
| { kind: "task"; taskId: string; raw: string }
|
|
198
246
|
| { kind: "all"; raw: string }
|
|
199
247
|
| { kind: "feature"; feature: string; raw: string };
|
|
@@ -228,10 +276,18 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
|
|
|
228
276
|
const target = positional.trim();
|
|
229
277
|
if (!target) return null;
|
|
230
278
|
|
|
231
|
-
// <
|
|
279
|
+
// <slug> plan name — same identifier model as `soly new`. Checked AFTER
|
|
280
|
+
// task ids because some task ids look exactly like plan slugs (e.g.
|
|
281
|
+
// `auth-be-login-a3f9` matches the kebab-case regex). Task-id check
|
|
282
|
+
// is more specific (trailing 4-hex) so it wins.
|
|
283
|
+
const taskId = parseTaskIdShape(target);
|
|
284
|
+
if (taskId) {
|
|
285
|
+
return { kind: "task", taskId, raw };
|
|
286
|
+
}
|
|
287
|
+
|
|
232
288
|
const plan = parsePlanName(target);
|
|
233
289
|
if (!("error" in plan)) {
|
|
234
|
-
return { kind: "plan",
|
|
290
|
+
return { kind: "plan", name: plan.name, raw };
|
|
235
291
|
}
|
|
236
292
|
|
|
237
293
|
const phase = parsePhaseShape(target);
|
|
@@ -239,11 +295,6 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
|
|
|
239
295
|
return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
|
|
240
296
|
}
|
|
241
297
|
|
|
242
|
-
const taskId = parseTaskIdShape(target);
|
|
243
|
-
if (taskId) {
|
|
244
|
-
return { kind: "task", taskId, raw };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
298
|
return null;
|
|
248
299
|
}
|
|
249
300
|
|
|
@@ -262,7 +313,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
|
|
|
262
313
|
*/
|
|
263
314
|
export type PlanTarget =
|
|
264
315
|
| { kind: "phase"; phase: number; raw: string }
|
|
265
|
-
| { kind: "plan";
|
|
316
|
+
| { kind: "plan"; name: string; raw: string }
|
|
266
317
|
| { kind: "task"; taskId: string; raw: string }
|
|
267
318
|
| { kind: "new-task"; slug: string; feature: string; raw: string }
|
|
268
319
|
| { kind: "feature"; feature: string; raw: string };
|
|
@@ -298,11 +349,19 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
|
|
|
298
349
|
const target = positional.trim();
|
|
299
350
|
if (!target) return null;
|
|
300
351
|
|
|
301
|
-
// <
|
|
302
|
-
// .agents/plans/<
|
|
352
|
+
// <slug> plan name (e.g. statistic-preparation) — plans live at
|
|
353
|
+
// .agents/plans/<slug>/PLAN.md, identity is the branch name. Checked
|
|
354
|
+
// AFTER task ids because some task ids look exactly like plan slugs
|
|
355
|
+
// (e.g. `auth-be-login-a3f9` matches the kebab-case regex). Task-id
|
|
356
|
+
// check is more specific (trailing 4-hex) so it wins.
|
|
357
|
+
const taskId = parseTaskIdShape(target);
|
|
358
|
+
if (taskId) {
|
|
359
|
+
return { kind: "task", taskId, raw };
|
|
360
|
+
}
|
|
361
|
+
|
|
303
362
|
const plan = parsePlanName(target);
|
|
304
363
|
if (!("error" in plan)) {
|
|
305
|
-
return { kind: "plan",
|
|
364
|
+
return { kind: "plan", name: plan.name, raw };
|
|
306
365
|
}
|
|
307
366
|
|
|
308
367
|
// Plan target only matches plain N (no .MM — plan is per-phase, executed
|
|
@@ -312,10 +371,5 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
|
|
|
312
371
|
return { kind: "phase", phase: phase.phase, raw };
|
|
313
372
|
}
|
|
314
373
|
|
|
315
|
-
const taskId = parseTaskIdShape(target);
|
|
316
|
-
if (taskId) {
|
|
317
|
-
return { kind: "task", taskId, raw };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
374
|
return null;
|
|
321
375
|
}
|