pi-soly 2.2.7 → 2.3.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.
@@ -37,12 +37,36 @@ export type WorkflowVerb =
37
37
  * soly new statistic-preparation
38
38
  * soly new login-redirect-bug
39
39
  * soly new stats-rollup
40
+ *
41
+ * If the input doesn't match the strict kebab-case shape, we attempt
42
+ * to auto-slugify it (lowercase, replace non-slug chars with `-`,
43
+ * collapse, trim). For free-form prose like "this is a feature" or
44
+ * even Cyrillic text, the auto-slugified result becomes the plan name
45
+ * and the original input is preserved as the plan's description so
46
+ * the LLM has the human-authored context.
40
47
  */
41
48
  export function parsePlanName(
42
49
  raw: string,
43
- ): { name: string; prefix: string | null } | { error: string } {
50
+ ): { name: string; prefix: string | null; autoSlugified: boolean; originalInput: string } | { error: string; tried: string } {
44
51
  const trimmed = raw.trim();
45
- if (!trimmed) return { error: "missing plan name" };
52
+ if (!trimmed) return { error: "missing plan name", tried: "" };
53
+
54
+ // Strict regex: ASCII alphanumerics + hyphen, must start and end with
55
+ // an alnum. The kebab-case shape we want to encourage.
56
+ // Unicode regex used for the lenient path below (e.g. Cyrillic, accents).
57
+ const SLUG_STRICT = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/u;
58
+ const SLUG_LENIENT = /^[\p{L}\p{N}][\p{L}\p{N}_-]*[\p{L}\p{N}]$/u;
59
+ const MAX_LEN = 64;
60
+ const MIN_LEN = 2;
61
+
62
+ /** Lowercase, replace non-slug chars with `-`, collapse, trim. */
63
+ const autoSlugify = (s: string): string =>
64
+ s
65
+ .toLowerCase()
66
+ .normalize("NFKD")
67
+ .replace(/[^\p{L}\p{N}_-]/gu, "-")
68
+ .replace(/-+/g, "-")
69
+ .replace(/^-+|-+$/g, "");
46
70
 
47
71
  // Optional `<prefix>/<slug>` form: prefix is one kebab-case word
48
72
  // (no internal slashes), slug is the same kebab-case shape. Examples:
@@ -54,49 +78,67 @@ export function parsePlanName(
54
78
  if (slashIdx > 0) {
55
79
  const prefix = trimmed.slice(0, slashIdx);
56
80
  const slug = trimmed.slice(slashIdx + 1);
57
- const prefixOk = /^[a-z][a-z0-9-]*[a-z0-9]$/.test(prefix);
58
- const slugOk = /^[a-z][a-z0-9-]*[a-z0-9]$/.test(slug);
81
+ const prefixOk = SLUG_STRICT.test(prefix);
82
+ const slugOk = SLUG_STRICT.test(slug);
59
83
  if (!prefixOk || !slugOk) {
60
84
  return {
61
85
  error:
62
86
  `bad plan name "${trimmed}".\n` +
63
- `\nExpected: <slug> OR <prefix>/<slug>\n` +
87
+ `\nExpected: <prefix>/<slug> (e.g. "feature/login-redirect-bug")\n` +
64
88
  ` prefix: one kebab-case word (e.g. "feature", "fix", "chore")\n` +
65
89
  ` slug: kebab-case (lowercase letters, digits, hyphens)\n` +
66
- `\nExample: soly new feature/statistic-preparation`,
90
+ `\nTip: pass free-form prose instead and soly will auto-slugify:\n` +
91
+ ` soly new "add a button to the settings page" → add-a-button-to-the-settings-page`,
92
+ tried: trimmed,
67
93
  };
68
94
  }
69
- return { name: slug, prefix };
95
+ return { name: slug, prefix, autoSlugified: false, originalInput: trimmed };
70
96
  }
71
97
 
72
- // Plain slug form. Must contain at least one letter — pure-digit strings
73
- // are reserved for phase numbers (`soly plan 11` → phase 11), not slugs.
74
- if (!/[a-z]/.test(trimmed)) {
75
- return {
76
- error:
77
- `bad plan name "${trimmed}".\n` +
78
- `\nExpected: <slug> OR <prefix>/<slug>\n` +
79
- ` chars: lowercase letters, digits, and hyphens\n` +
80
- ` must start and end with a letter or digit\n` +
81
- `\nExample: soly new statistic-preparation`,
82
- };
83
- }
84
- const m = trimmed.match(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
85
- if (!m) {
86
- return {
87
- error:
88
- `bad plan name "${trimmed}".\n` +
89
- `\nExpected: <slug> OR <prefix>/<slug>\n` +
90
- ` length: 2-64 chars\n` +
91
- ` chars: lowercase letters, digits, and hyphens\n` +
92
- ` must start and end with a letter or digit\n` +
93
- `\nExample: soly new statistic-preparation`,
94
- };
98
+ // Plain slug form. If it's already kebab-case, accept as-is.
99
+ // Exception: pure-digit strings are reserved for phase numbers
100
+ // (`soly plan 11` → phase 11), not slugs.
101
+ if (
102
+ SLUG_STRICT.test(trimmed) &&
103
+ /[a-z]/.test(trimmed) &&
104
+ trimmed.length >= MIN_LEN &&
105
+ trimmed.length <= MAX_LEN
106
+ ) {
107
+ return { name: trimmed, prefix: null, autoSlugified: false, originalInput: trimmed };
95
108
  }
96
- if (trimmed.length > 64) {
97
- return { error: `name "${trimmed}" is too long (max 64 chars)` };
109
+
110
+ // Otherwise, try to auto-slugify (handles free-form prose, Cyrillic,
111
+ // accented chars, mixed punctuation). The result must be a valid slug
112
+ // under the lenient Unicode regex.
113
+ //
114
+ // Exception: pure-digit strings (e.g. "11") are reserved for phase
115
+ // numbers (`soly plan 11` → phase 11), not slugs. Don't auto-slugify them.
116
+ const candidate = autoSlugify(trimmed);
117
+ const isPureDigits = /^\d+$/.test(candidate);
118
+ if (
119
+ !isPureDigits &&
120
+ candidate.length >= MIN_LEN &&
121
+ candidate.length <= MAX_LEN &&
122
+ SLUG_LENIENT.test(candidate)
123
+ ) {
124
+ return { name: candidate, prefix: null, autoSlugified: true, originalInput: trimmed };
98
125
  }
99
- return { name: trimmed, prefix: null };
126
+
127
+ // Couldn't make anything valid out of the input. Surface a clear error
128
+ // that explains what we tried and what works.
129
+ return {
130
+ error:
131
+ `bad plan name "${trimmed}".\n` +
132
+ `\nExpected: <slug> OR <prefix>/<slug>\n` +
133
+ ` length: ${MIN_LEN}-${MAX_LEN} chars\n` +
134
+ ` chars: Unicode letters/digits/hyphens/underscores (auto-slugified to kebab-case)\n` +
135
+ `\nTip: pass the plan description as a free-form string and soly will\n` +
136
+ `auto-slugify it for you:\n` +
137
+ ` soly new "add a button to the settings page" → add-a-button-to-the-settings-page\n` +
138
+ `\nOr pick a short kebab-case name yourself:\n` +
139
+ ` soly new statistic-preparation`,
140
+ tried: candidate,
141
+ };
100
142
  }
101
143
 
102
144
  export interface SolyCommand {
@@ -242,7 +284,7 @@ function parseNewTaskFlag(
242
284
  */
243
285
  export type ExecuteTarget =
244
286
  | { kind: "phase"; phase: number; plan: number | null; raw: string }
245
- | { kind: "plan"; name: string; prefix: string | null; raw: string }
287
+ | { kind: "plan"; name: string; prefix: string | null; autoSlugified: boolean; originalInput: string; raw: string }
246
288
  | { kind: "task"; taskId: string; raw: string }
247
289
  | { kind: "all"; raw: string }
248
290
  | { kind: "feature"; feature: string; raw: string };
@@ -286,16 +328,19 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
286
328
  return { kind: "task", taskId, raw };
287
329
  }
288
330
 
289
- const plan = parsePlanName(target);
290
- if (!("error" in plan)) {
291
- return { kind: "plan", name: plan.name, prefix: plan.prefix, raw };
292
- }
293
-
331
+ // Check phase shape FIRST — pure digits and NN.MM patterns are phase
332
+ // numbers, not plan slugs. Must come before parsePlanName because
333
+ // auto-slugify would turn "11.02" into "11-02" (a valid plan slug).
294
334
  const phase = parsePhaseShape(target);
295
335
  if (phase) {
296
336
  return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
297
337
  }
298
338
 
339
+ const plan = parsePlanName(target);
340
+ if (!("error" in plan)) {
341
+ return { kind: "plan", name: plan.name, prefix: plan.prefix, autoSlugified: plan.autoSlugified, originalInput: plan.originalInput, raw };
342
+ }
343
+
299
344
  return null;
300
345
  }
301
346
 
@@ -314,7 +359,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
314
359
  */
315
360
  export type PlanTarget =
316
361
  | { kind: "phase"; phase: number; raw: string }
317
- | { kind: "plan"; name: string; prefix: string | null; raw: string }
362
+ | { kind: "plan"; name: string; prefix: string | null; autoSlugified: boolean; originalInput: string; raw: string }
318
363
  | { kind: "task"; taskId: string; raw: string }
319
364
  | { kind: "new-task"; slug: string; feature: string; raw: string }
320
365
  | { kind: "feature"; feature: string; raw: string };
@@ -360,17 +405,18 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
360
405
  return { kind: "task", taskId, raw };
361
406
  }
362
407
 
363
- const plan = parsePlanName(target);
364
- if (!("error" in plan)) {
365
- return { kind: "plan", name: plan.name, prefix: plan.prefix, raw };
366
- }
367
-
368
- // Plan target only matches plain N (no .MM — plan is per-phase, executed
369
- // at the phase level by `soly execute <N.MM>`).
408
+ // Check phase shape FIRST — pure digits are phase numbers, not slugs.
409
+ // Must come before parsePlanName because auto-slugify would turn digits
410
+ // into a valid plan slug.
370
411
  const phase = parsePhaseOnlyShape(target);
371
412
  if (phase) {
372
413
  return { kind: "phase", phase: phase.phase, raw };
373
414
  }
374
415
 
416
+ const plan = parsePlanName(target);
417
+ if (!("error" in plan)) {
418
+ return { kind: "plan", name: plan.name, prefix: plan.prefix, autoSlugified: plan.autoSlugified, originalInput: plan.originalInput, raw };
419
+ }
420
+
375
421
  return null;
376
422
  }
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import { readIfExists, buildProgressBar, type SolyState } from "../core.js";
18
18
  import type { SolyConfig } from "../config.js";
19
19
  import type { SolyCommand } from "./parser.ts";
20
+ import { emit } from "../visual/event-sink.ts";
20
21
 
21
22
  const execFileAsync = promisify(execFile);
22
23
 
@@ -36,7 +37,7 @@ export function showStatus(
36
37
  config?: SolyConfig,
37
38
  ): void {
38
39
  if (!state.exists) {
39
- ui.notify("soly: no .agents/ directory in cwd", "error");
40
+ emit("soly: no .agents/ directory in cwd", "error");
40
41
  return;
41
42
  }
42
43
  const maxPhases = config?.display.maxPhasesInStatus ?? 20;
@@ -122,14 +123,14 @@ const DECISIONS_TABLE_ROW = /^\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*
122
123
 
123
124
  export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
124
125
  if (!state.exists) {
125
- ui.notify("soly log: no .agents/ directory in cwd", "error");
126
+ emit("soly log: no .agents/ directory in cwd", "error");
126
127
  return;
127
128
  }
128
129
 
129
130
  const statePath = path.join(state.solyDir, "STATE.md");
130
131
  const raw = readIfExists(statePath);
131
132
  if (!raw) {
132
- ui.notify("soly log: STATE.md not found", "error");
133
+ emit("soly log: STATE.md not found", "error");
133
134
  return;
134
135
  }
135
136
 
@@ -160,7 +161,7 @@ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
160
161
  if (limitArg) {
161
162
  const parsed = parseInt(limitArg, 10);
162
163
  if (!Number.isFinite(parsed) || parsed <= 0) {
163
- ui.notify(
164
+ emit(
164
165
  `soly log: invalid limit "${limitArg}" (must be a positive integer)`,
165
166
  "error",
166
167
  );
@@ -27,6 +27,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
27
27
  import { ListPanel, type ListItem, type ListAction, type ListGroup, type PanelKeybindings } from "../visual/list-panel.ts";
28
28
 
29
29
  import { DEFAULT_CONFIG, type SolyConfig } from "../config.ts";
30
+ import { emit } from "../visual/event-sink.ts";
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Setting descriptors
@@ -442,12 +443,12 @@ export function openSettingsUI(deps: SettingsUIDeps): void {
442
443
  try {
443
444
  const diff = diffVsDefaults(working);
444
445
  saveConfigFile(solyDir, diff);
445
- ui.notify(`settings saved → ${path.basename(solyDir)}/soly.json`, "info");
446
+ emit(`settings saved → ${path.basename(solyDir)}/soly.json`, "info");
446
447
  // Re-read the config from disk so the active config matches
447
448
  // what we just wrote.
448
449
  reloadConfig();
449
450
  } catch (e) {
450
- ui.notify(`save failed: ${(e as Error).message}`, "error");
451
+ emit(`save failed: ${(e as Error).message}`, "error");
451
452
  }
452
453
  }
453
454
  done();
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import type { AgentMessageLike, ContextManager, ContextRewriter } from "../context-manager.ts";
21
+ import { emit } from "../visual/event-sink.ts";
21
22
 
22
23
  /** Resolved verify settings (from soly config `verify`). */
23
24
  export type VerifyConfig = {
@@ -121,7 +122,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
121
122
  let exitRes: RegExp[] = [];
122
123
  let fixedRes: RegExp[] = [];
123
124
 
124
- const emit = () => deps.onState({ active, iteration, max, fresh });
125
+ const emitState = () => deps.onState({ active, iteration, max, fresh });
125
126
 
126
127
  // Captures the boundary on the first call, then strips prior iterations.
127
128
  const rewriter: ContextRewriter = (messages) => {
@@ -137,8 +138,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
137
138
  active = false;
138
139
  boundary = -1;
139
140
  deps.contextManager.setRewriter(null);
140
- emit();
141
- ctx?.ui.notify(`soly verify: ${reason}`, "info");
141
+ emit(`soly verify: ${reason}`);
142
142
  };
143
143
 
144
144
  pi.on("agent_end", async (event, ctx) => {
@@ -158,7 +158,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
158
158
  stop(ctx, `stopped after ${max} iterations`);
159
159
  return;
160
160
  }
161
- emit();
161
+ emit(`soly verify: loop iteration ${iteration}`);
162
162
  pi.sendUserMessage(deps.getConfig().prompt, { deliverAs: "followUp" });
163
163
  });
164
164
 
@@ -173,7 +173,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
173
173
  isActive: () => active,
174
174
  start(ctx, opts = {}): void {
175
175
  if (active) {
176
- ctx.ui.notify("soly verify: already running", "info");
176
+ emit("soly verify: already running");
177
177
  return;
178
178
  }
179
179
  const cfg = deps.getConfig();
@@ -185,8 +185,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
185
185
  exitRes = compilePatterns(cfg.exitPatterns);
186
186
  fixedRes = compilePatterns(cfg.issuesFixedPatterns);
187
187
  if (fresh) deps.contextManager.setRewriter(rewriter);
188
- emit();
189
- ctx.ui.notify(`soly verify: review mode on (max ${max}${fresh ? ", fresh context" : ""})`, "info");
188
+ emit("verify started");
190
189
  pi.sendUserMessage(cfg.prompt);
191
190
  },
192
191
  stop,