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 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
- 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/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: { hasProject?: boolean; confirmBeforeCode?: boolean | ConfirmLevel } = {},
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.** 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.`
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.15.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 <type>/<name>`);
96
+ return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
97
97
  }
98
- const { type, name } = parsed;
99
- const branchName = `${type}/${name}`;
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", `${type}(${name}): wip`], { cwd: projectRoot });
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);
@@ -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(cmd, state, ctx.ui, ctx.cwd);
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 { 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}`;
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
- if (currentBranch !== "master" && currentBranch !== "main" && !/^[a-z]+\//.test(currentBranch)) {
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\`.`,
@@ -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 `<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(
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
- const m = trimmed.match(/^([a-z]+)\/([a-z0-9][a-z0-9-]*[a-z0-9])$/);
36
- if (!m) {
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: <type>/<name>\n` +
41
- ` type = one of ${PLAN_TYPES.join(", ")}\n` +
42
- ` name = kebab-case\n` +
43
- `\nExample: soly plan feat/auth-jwt`,
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 [, 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(", ")}` };
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 ((name as string).length > 64) {
51
- return { error: `name "${name}" is too long (max 64 chars)` };
95
+ if (trimmed.length > 64) {
96
+ return { error: `name "${trimmed}" is too long (max 64 chars)` };
52
97
  }
53
- return { type: type as string, name: name as string };
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
- return s.match(/^[a-z0-9][a-z0-9-]*-[a-f0-9]{4}$/i) ? s : null;
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"; type: string; name: string; raw: string }
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
- // <type>/<name> plan name — same identifier model as `soly new`.
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", type: plan.type, name: plan.name, raw };
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"; type: string; name: string; raw: string }
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
- // <type>/<name> plan name (e.g. feat/auth-jwt) — plans live at
302
- // .agents/plans/<name>/PLAN.md, identity is the branch name.
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", type: plan.type, name: plan.name, raw };
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
  }