pi-soly 1.15.0 → 1.16.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/ask/picker.ts CHANGED
@@ -498,7 +498,11 @@ export class AskProComponent extends Container {
498
498
  * string in a multi answer). Returns "" if no custom string. */
499
499
  private getCustomString(ans: AskAnswer | AskMultiAnswer | undefined): string {
500
500
  if (ans === undefined) return "";
501
+ // Single-pick answers are numbers — not an array, so .find() would crash.
502
+ // Cover the string case (typed Other… in single-pick) and treat numeric
503
+ // answers as "no custom string yet".
501
504
  if (typeof ans === "string") return ans;
505
+ if (typeof ans === "number") return "";
502
506
  const found = (ans as AskMultiAnswer).find((a) => typeof a === "string");
503
507
  return typeof found === "string" ? found : "";
504
508
  }
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);
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);
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
- const s = getState();
618
- if (!s.currentPlanPath) {
619
- ui.notify("soly: no current plan", "error");
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
- showFile(
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/nudge.ts CHANGED
@@ -142,10 +142,23 @@ export function buildNudgeSection(
142
142
  : "";
143
143
 
144
144
  // 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.
145
+ // model toward the workflow lifecycle instead of ad-hoc edits. This block
146
+ // also tells the model it may *itself* scaffold a new plan after asking the
147
+ // user — the user explicitly opted into this in 1.16.0.
146
148
  const workflowPoint =
147
149
  opts.hasProject && heuristics.nonTrivial
148
- ? `\n\n4. **Route project work through the soly workflow.** For phase/task work in \`.agents/\`, prefer the lifecycle over ad-hoc edits: \`soly discuss <N>\` (scope) → \`soly plan <N>\` (write tasks) → \`soly execute <N>\` (do them) \`soly verify\` (review). Run \`soly status\` to see where you are. Skip only for a genuine one-off.`
150
+ ? `\n\n4. **Route project work through the soly plan workflow.** Each plan is a git branch named by a kebab-case slug (\`statistic-preparation\`, \`login-redirect-bug\`, \`stats-rollup\`, ) with \`.agents/plans/<slug>/PLAN.md\` on it. Two parallel plans don't collide.
151
+
152
+ Lifecycle (text commands you type — pi routes them through the workflow handlers):
153
+ - \`soly new <slug>\` — scaffold: branch + plan dir + stub PLAN.md + commit
154
+ - \`soly discuss <slug>\` — interactive discussion of the plan (uses ask_pro)
155
+ - \`soly plan <slug>\` — flesh out PLAN.md via ask_pro
156
+ - \`soly execute <slug>\` — execute the plan in a subagent
157
+ - \`soly done <slug>\` — commit + push + open draft PR via gh
158
+ - \`soly verify\` — self-review loop until clean
159
+ - \`soly status\` — current position + progress (no LLM round-trip)
160
+
161
+ **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 first via ask_pro (\"I'll create plan 'statistic-preparation' — continue?\"), then type \`soly new statistic-preparation\` in your next output. Don't ask for confirmation on 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
162
  : "";
150
163
 
151
164
  // 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.15.0",
3
+ "version": "1.16.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",
package/workflows/done.ts CHANGED
@@ -93,10 +93,10 @@ 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 <type>/<name>`);
96
+ return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug>`);
97
97
  }
98
- const { type, name } = parsed;
99
- const branchName = `${type}/${name}`;
98
+ const { name } = parsed;
99
+ const branchName = name;
100
100
 
101
101
  // 1. Preconditions
102
102
  const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot });
@@ -126,7 +126,7 @@ export function buildDoneTransform(
126
126
  // Re-check after add
127
127
  const statusAfter = git(["status", "--short"], { cwd: projectRoot });
128
128
  if (statusAfter) {
129
- git(["commit", "-m", `${type}(${name}): wip`], { cwd: projectRoot });
129
+ git(["commit", "-m", `${name}: wip`], { cwd: projectRoot });
130
130
  }
131
131
  } catch (err) {
132
132
  const msg = err instanceof Error ? err.message : String(err);
@@ -194,6 +194,9 @@ export function buildDoneTransform(
194
194
  ` Commit: ${commitHash.slice(0, 7)}\n` +
195
195
  ` Pushed: ${pushed ? "yes" : "no (no origin remote)"}\n` +
196
196
  ` ${prLine}`;
197
+
198
+ // (No registry file to update — the plan's git branch IS the source of
199
+ // truth for "active plans". See PLAN.md / "Out of scope" for the trade-off.)
197
200
  ui.notify(notice, "info");
198
201
  return {
199
202
  handled: true,
package/workflows/new.ts CHANGED
@@ -88,8 +88,11 @@ export function buildNewTransform(
88
88
  const parsed = parsePlanName(cmd.args.join(" "));
89
89
  if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
90
90
 
91
- const { type, name } = parsed;
92
- const branchName = `${type}/${name}`;
91
+ const { name } = parsed;
92
+ // Branch IS the slug (no <type>/ prefix). Convention chosen over
93
+ // Conventional-Branches because `soly new` users tend to give a full
94
+ // descriptive name already; the type prefix adds noise without info.
95
+ const branchName = name;
93
96
  // Path used by `git add`/`commit` — relative to projectRoot (where `.git/` is).
94
97
  // The plan lives at `<root>/.agents/plans/<name>/`, so the repo-relative path
95
98
  // is `.agents/plans/<name>`.
@@ -116,7 +119,15 @@ export function buildNewTransform(
116
119
  }
117
120
 
118
121
  const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
119
- if (currentBranch !== "master" && currentBranch !== "main" && !/^[a-z]+\//.test(currentBranch)) {
122
+ // A plan branch is a kebab-case slug (no `<type>/` prefix after 1.15.x).
123
+ // The branch can also be the long-lived integration branches `master`
124
+ // or `main`. Anything else (release tags, weird suffixes) → user must
125
+ // checkout first.
126
+ if (
127
+ currentBranch !== "master" &&
128
+ currentBranch !== "main" &&
129
+ !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch)
130
+ ) {
120
131
  return reply(
121
132
  `soly new: currently on "${currentBranch}" (not master/main, not a soly plan branch). ` +
122
133
  `Switch back to master first with \`git checkout master\`.`,
@@ -21,36 +21,54 @@ 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 `<type>/<name>` (e.g. `feat/auth-jwt`). Returns parsed parts or
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(raw: string): { type: string; name: string } | { error: string } {
40
+ export function parsePlanName(raw: string): { name: string } | { error: string } {
33
41
  const trimmed = raw.trim();
34
42
  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) {
43
+ // Must contain at least one letter — pure-digit strings are reserved for
44
+ // phase numbers (`soly plan 11` → phase 11), not plan slugs.
45
+ if (!/[a-z]/.test(trimmed)) {
37
46
  return {
38
47
  error:
39
48
  `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`,
49
+ `\nExpected: <slug> (kebab-case, no type prefix, must contain a letter)\n` +
50
+ ` length: 2-64 chars\n` +
51
+ ` chars: lowercase letters, digits, and hyphens\n` +
52
+ ` must start and end with a letter or digit\n` +
53
+ `\nExample: soly new statistic-preparation`,
44
54
  };
45
55
  }
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(", ")}` };
56
+ const m = trimmed.match(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
57
+ if (!m) {
58
+ return {
59
+ error:
60
+ `bad plan name "${trimmed}".\n` +
61
+ `\nExpected: <slug> (kebab-case, no type prefix)\n` +
62
+ ` length: 2-64 chars\n` +
63
+ ` chars: lowercase letters, digits, and hyphens\n` +
64
+ ` must start and end with a letter or digit\n` +
65
+ `\nExample: soly new statistic-preparation`,
66
+ };
49
67
  }
50
- if ((name as string).length > 64) {
51
- return { error: `name "${name}" is too long (max 64 chars)` };
68
+ if (trimmed.length > 64) {
69
+ return { error: `name "${trimmed}" is too long (max 64 chars)` };
52
70
  }
53
- return { type: type as string, name: name as string };
71
+ return { name: trimmed };
54
72
  }
55
73
 
56
74
  export interface SolyCommand {
@@ -160,7 +178,10 @@ function parsePhaseOnlyShape(s: string): { phase: number } | null {
160
178
 
161
179
  /** Match task-id `<slug>-<4hex>`, case-insensitive. */
162
180
  function parseTaskIdShape(s: string): string | null {
163
- return s.match(/^[a-z0-9][a-z0-9-]*-[a-f0-9]{4}$/i) ? s : null;
181
+ const m = s.match(/^([a-z0-9][a-z0-9-]*)-([a-f0-9]{4})$/i);
182
+ if (!m) return null;
183
+ // Normalize to lowercase hex so downstream comparisons match.
184
+ return `${m[1]}-${m[2].toLowerCase()}`;
164
185
  }
165
186
 
166
187
  /** Extract `--feature <name>` from args. Returns null if not present or invalid. */
@@ -193,7 +214,7 @@ function parseNewTaskFlag(
193
214
  */
194
215
  export type ExecuteTarget =
195
216
  | { kind: "phase"; phase: number; plan: number | null; raw: string }
196
- | { kind: "plan"; type: string; name: string; raw: string }
217
+ | { kind: "plan"; name: string; raw: string }
197
218
  | { kind: "task"; taskId: string; raw: string }
198
219
  | { kind: "all"; raw: string }
199
220
  | { kind: "feature"; feature: string; raw: string };
@@ -228,10 +249,18 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
228
249
  const target = positional.trim();
229
250
  if (!target) return null;
230
251
 
231
- // <type>/<name> plan name — same identifier model as `soly new`.
252
+ // <slug> plan name — same identifier model as `soly new`. Checked AFTER
253
+ // task ids because some task ids look exactly like plan slugs (e.g.
254
+ // `auth-be-login-a3f9` matches the kebab-case regex). Task-id check
255
+ // is more specific (trailing 4-hex) so it wins.
256
+ const taskId = parseTaskIdShape(target);
257
+ if (taskId) {
258
+ return { kind: "task", taskId, raw };
259
+ }
260
+
232
261
  const plan = parsePlanName(target);
233
262
  if (!("error" in plan)) {
234
- return { kind: "plan", type: plan.type, name: plan.name, raw };
263
+ return { kind: "plan", name: plan.name, raw };
235
264
  }
236
265
 
237
266
  const phase = parsePhaseShape(target);
@@ -239,11 +268,6 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
239
268
  return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
240
269
  }
241
270
 
242
- const taskId = parseTaskIdShape(target);
243
- if (taskId) {
244
- return { kind: "task", taskId, raw };
245
- }
246
-
247
271
  return null;
248
272
  }
249
273
 
@@ -262,7 +286,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
262
286
  */
263
287
  export type PlanTarget =
264
288
  | { kind: "phase"; phase: number; raw: string }
265
- | { kind: "plan"; type: string; name: string; raw: string }
289
+ | { kind: "plan"; name: string; raw: string }
266
290
  | { kind: "task"; taskId: string; raw: string }
267
291
  | { kind: "new-task"; slug: string; feature: string; raw: string }
268
292
  | { kind: "feature"; feature: string; raw: string };
@@ -298,11 +322,19 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
298
322
  const target = positional.trim();
299
323
  if (!target) return null;
300
324
 
301
- // <type>/<name> plan name (e.g. feat/auth-jwt) — plans live at
302
- // .agents/plans/<name>/PLAN.md, identity is the branch name.
325
+ // <slug> plan name (e.g. statistic-preparation) — plans live at
326
+ // .agents/plans/<slug>/PLAN.md, identity is the branch name. Checked
327
+ // AFTER task ids because some task ids look exactly like plan slugs
328
+ // (e.g. `auth-be-login-a3f9` matches the kebab-case regex). Task-id
329
+ // check is more specific (trailing 4-hex) so it wins.
330
+ const taskId = parseTaskIdShape(target);
331
+ if (taskId) {
332
+ return { kind: "task", taskId, raw };
333
+ }
334
+
303
335
  const plan = parsePlanName(target);
304
336
  if (!("error" in plan)) {
305
- return { kind: "plan", type: plan.type, name: plan.name, raw };
337
+ return { kind: "plan", name: plan.name, raw };
306
338
  }
307
339
 
308
340
  // Plan target only matches plain N (no .MM — plan is per-phase, executed
@@ -312,10 +344,5 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
312
344
  return { kind: "phase", phase: phase.phase, raw };
313
345
  }
314
346
 
315
- const taskId = parseTaskIdShape(target);
316
- if (taskId) {
317
- return { kind: "task", taskId, raw };
318
- }
319
-
320
347
  return null;
321
348
  }