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.
- package/commands/_helpers.ts +3 -0
- package/commands/artifacts.ts +8 -8
- package/commands/docs.ts +6 -6
- package/commands/rules.ts +17 -17
- package/commands/rulewizard.ts +5 -2
- package/commands/soly.ts +28 -28
- package/commands/why.ts +3 -3
- package/commands.ts +1 -1
- package/index.ts +19 -11
- package/init.ts +2 -1
- package/mcp/commands.ts +18 -17
- package/mcp/elicitation-handler.ts +2 -1
- package/mcp/index.ts +6 -5
- package/mcp/init.ts +5 -4
- package/mcp/notify.ts +2 -1
- package/notification.ts +2 -1
- package/package.json +1 -1
- package/visual/chrome.ts +17 -1
- package/visual/data.ts +8 -0
- package/visual/event-sink.ts +30 -0
- package/visual/footer.ts +19 -1
- package/workflows/done.ts +1 -1
- package/workflows/index.ts +5 -3
- package/workflows/inspect.ts +14 -13
- package/workflows/new.ts +36 -8
- package/workflows/parser.ts +93 -47
- package/workflows/quick.ts +5 -4
- package/workflows/settings-ui.ts +3 -2
- package/workflows/verify.ts +6 -7
package/workflows/parser.ts
CHANGED
|
@@ -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 =
|
|
58
|
-
const slugOk =
|
|
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>
|
|
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
|
-
`\
|
|
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.
|
|
73
|
-
// are reserved for phase numbers
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
}
|
package/workflows/quick.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
emit(
|
|
164
165
|
`soly log: invalid limit "${limitArg}" (must be a positive integer)`,
|
|
165
166
|
"error",
|
|
166
167
|
);
|
package/workflows/settings-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
451
|
+
emit(`save failed: ${(e as Error).message}`, "error");
|
|
451
452
|
}
|
|
452
453
|
}
|
|
453
454
|
done();
|
package/workflows/verify.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|