pi-soly 1.16.0 → 1.16.2

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
@@ -589,12 +589,12 @@ What must the LLM do?
589
589
  }
590
590
  switch (verb) {
591
591
  case "new": {
592
- const r = buildNewTransform(cmd, state, ui, ctx.cwd);
592
+ const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
593
593
  if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
594
594
  return;
595
595
  }
596
596
  case "done": {
597
- const r = buildDoneTransform(cmd, state, ui, ctx.cwd);
597
+ const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
598
598
  if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
599
599
  return;
600
600
  }
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,17 @@ 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: lowercase, then allow only kebab-case chars (no
276
+ // slashes/dots/spaces). Empty string is allowed and means
277
+ // "no prefix". We do NOT warn on slash/uppercase — the user
278
+ // probably meant a different prefix shape; silently normalizing
279
+ // keeps configs from being rejected for cosmetic reasons.
280
+ const lowercased = over.plan.defaultBranchPrefix.toLowerCase();
281
+ const sanitized = lowercased.replace(/[^a-z0-9-]/g, "");
282
+ const collapsed = sanitized.replace(/-+/g, "-").replace(/^-|-$/g, "");
283
+ merged.plan.defaultBranchPrefix = collapsed;
284
+ }
259
285
  if (over.chrome) {
260
286
  if (typeof over.chrome.enabled === "boolean") merged.chrome.enabled = over.chrome.enabled;
261
287
  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
@@ -145,20 +149,27 @@ export function buildNudgeSection(
145
149
  // model toward the workflow lifecycle instead of ad-hoc edits. This block
146
150
  // also tells the model it may *itself* scaffold a new plan after asking the
147
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>/\`.`;
148
156
  const workflowPoint =
149
157
  opts.hasProject && heuristics.nonTrivial
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.
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\`).
151
162
 
152
163
  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.`
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.`
162
173
  : "";
163
174
 
164
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.16.0",
3
+ "version": "1.16.2",
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 <slug>`);
96
+ return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
97
97
  }
98
- const { name } = parsed;
99
- const branchName = 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 });
@@ -274,9 +274,12 @@ When the subagent completes, synthesize the result. Do not re-execute its work.
274
274
  };
275
275
  }
276
276
 
277
- // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<name>/) ===
277
+ // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<prefix>-<name>/) ===
278
278
  if (target.kind === "plan") {
279
- const planDirAbs = `${state.solyDir}/plans/${target.name}`;
279
+ // Plan dir is always flattened: `<prefix>-<name>` if a prefix is
280
+ // present, else just `<name>`.
281
+ const dirSlug = target.prefix ? `${target.prefix}-${target.name}` : target.name;
282
+ const planDirAbs = `${state.solyDir}/plans/${dirSlug}`;
280
283
  const planFile = `${planDirAbs}/PLAN.md`;
281
284
  let planBody: string;
282
285
  try {
@@ -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,16 +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 { 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;
96
- // Path used by `git add`/`commit` relative to projectRoot (where `.git/` is).
97
- // The plan lives at `<root>/.agents/plans/<name>/`, so the repo-relative path
98
- // is `.agents/plans/<name>`.
99
- const planDirRel = `.agents/plans/${name}`;
100
- 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}`;
101
104
  const planFile = `${planDirAbs}/PLAN.md`;
102
105
 
103
106
  // 1. Preconditions
@@ -37,17 +37,44 @@ export type WorkflowVerb =
37
37
  * soly new login-redirect-bug
38
38
  * soly new stats-rollup
39
39
  */
40
- export function parsePlanName(raw: string): { name: string } | { error: string } {
40
+ export function parsePlanName(
41
+ raw: string,
42
+ ): { name: string; prefix: string | null } | { error: string } {
41
43
  const trimmed = raw.trim();
42
44
  if (!trimmed) return { error: "missing plan name" };
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
+
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.
45
73
  if (!/[a-z]/.test(trimmed)) {
46
74
  return {
47
75
  error:
48
76
  `bad plan name "${trimmed}".\n` +
49
- `\nExpected: <slug> (kebab-case, no type prefix, must contain a letter)\n` +
50
- ` length: 2-64 chars\n` +
77
+ `\nExpected: <slug> OR <prefix>/<slug>\n` +
51
78
  ` chars: lowercase letters, digits, and hyphens\n` +
52
79
  ` must start and end with a letter or digit\n` +
53
80
  `\nExample: soly new statistic-preparation`,
@@ -58,7 +85,7 @@ export function parsePlanName(raw: string): { name: string } | { error: string }
58
85
  return {
59
86
  error:
60
87
  `bad plan name "${trimmed}".\n` +
61
- `\nExpected: <slug> (kebab-case, no type prefix)\n` +
88
+ `\nExpected: <slug> OR <prefix>/<slug>\n` +
62
89
  ` length: 2-64 chars\n` +
63
90
  ` chars: lowercase letters, digits, and hyphens\n` +
64
91
  ` must start and end with a letter or digit\n` +
@@ -68,7 +95,7 @@ export function parsePlanName(raw: string): { name: string } | { error: string }
68
95
  if (trimmed.length > 64) {
69
96
  return { error: `name "${trimmed}" is too long (max 64 chars)` };
70
97
  }
71
- return { name: trimmed };
98
+ return { name: trimmed, prefix: null };
72
99
  }
73
100
 
74
101
  export interface SolyCommand {
@@ -214,7 +241,7 @@ function parseNewTaskFlag(
214
241
  */
215
242
  export type ExecuteTarget =
216
243
  | { kind: "phase"; phase: number; plan: number | null; raw: string }
217
- | { kind: "plan"; name: string; raw: string }
244
+ | { kind: "plan"; name: string; prefix: string | null; raw: string }
218
245
  | { kind: "task"; taskId: string; raw: string }
219
246
  | { kind: "all"; raw: string }
220
247
  | { kind: "feature"; feature: string; raw: string };
@@ -260,7 +287,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
260
287
 
261
288
  const plan = parsePlanName(target);
262
289
  if (!("error" in plan)) {
263
- return { kind: "plan", name: plan.name, raw };
290
+ return { kind: "plan", name: plan.name, prefix: plan.prefix, raw };
264
291
  }
265
292
 
266
293
  const phase = parsePhaseShape(target);
@@ -286,7 +313,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
286
313
  */
287
314
  export type PlanTarget =
288
315
  | { kind: "phase"; phase: number; raw: string }
289
- | { kind: "plan"; name: string; raw: string }
316
+ | { kind: "plan"; name: string; prefix: string | null; raw: string }
290
317
  | { kind: "task"; taskId: string; raw: string }
291
318
  | { kind: "new-task"; slug: string; feature: string; raw: string }
292
319
  | { kind: "feature"; feature: string; raw: string };
@@ -334,7 +361,7 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
334
361
 
335
362
  const plan = parsePlanName(target);
336
363
  if (!("error" in plan)) {
337
- return { kind: "plan", name: plan.name, raw };
364
+ return { kind: "plan", name: plan.name, prefix: plan.prefix, raw };
338
365
  }
339
366
 
340
367
  // Plan target only matches plain N (no .MM — plan is per-phase, executed
@@ -91,9 +91,13 @@ export function buildPlanTransform(cmd: SolyCommand, state: SolyState): Planning
91
91
  };
92
92
  }
93
93
 
94
- // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<name>/) ===
94
+ // === PLAN MODE (new dual-mode: `<type>/<name>` plans live under .agents/plans/<prefix>-<name>/) ===
95
95
  if (target.kind === "plan") {
96
- const planDirAbs = `${state.solyDir}/plans/${target.name}`;
96
+ // Plan dir is always flattened: `<prefix>-<name>` if a prefix is
97
+ // present (user typed `<prefix>/<slug>` or the project default
98
+ // applied), else just `<name>`.
99
+ const dirSlug = target.prefix ? `${target.prefix}-${target.name}` : target.name;
100
+ const planDirAbs = `${state.solyDir}/plans/${dirSlug}`;
97
101
  const planFile = `${planDirAbs}/PLAN.md`;
98
102
  let planBody: string;
99
103
  try {
@@ -514,10 +518,11 @@ export function buildDiscussTransform(
514
518
  };
515
519
  }
516
520
 
517
- // Plan mode: `<type>/<name>`
521
+ // Plan mode: `<slug>` or `<prefix>/<slug>`
518
522
  const planParsed = parsePlanName(arg);
519
523
  if (!("error" in planParsed)) {
520
- const planDirAbs = `${state.solyDir}/plans/${planParsed.name}`;
524
+ const dirSlug = planParsed.prefix ? `${planParsed.prefix}-${planParsed.name}` : planParsed.name;
525
+ const planDirAbs = `${state.solyDir}/plans/${dirSlug}`;
521
526
  const planFile = `${planDirAbs}/PLAN.md`;
522
527
  let planBody: string;
523
528
  try {