pi-soly 2.2.6 → 2.3.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/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![banner](./packages/pi-soly/.assets/banner.png)
2
+
1
3
  <div align="center">
2
4
 
3
5
  # ⚡ pi-soly
@@ -11,8 +13,6 @@
11
13
 
12
14
  </div>
13
15
 
14
- ![banner](./packages/pi-soly/.assets/banner.png)
15
-
16
16
  ## What it is
17
17
 
18
18
  pi-soly turns a plain pi-coding-agent session into a structured project:
@@ -40,6 +40,12 @@ export interface CommandsDeps {
40
40
  getConfig: () => SolyConfig;
41
41
  reloadConfig: () => void;
42
42
  getIntentDocs: () => IntentDoc[];
43
+ /** Record a non-error soly event. Rendered as a sub-line under the
44
+ * Working indicator (└─ prefixed, dim/yellow by level). Use this for
45
+ * informational events (e.g. "reloaded 47 rules") — they shouldn't be
46
+ * popups. Errors must still go through `ui.notify(text, "error")` for
47
+ * the popup surface. */
48
+ recordEvent: (text: string, level?: "info" | "warning") => void;
43
49
  }
44
50
 
45
51
  /** Open a focused list modal (overlay) for the given grouped items. */
@@ -6,16 +6,16 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
6
6
  import { getArtifactServer, ensureArtifactServer, artifactDir } from "../artifact/session.ts";
7
7
  import { openListPanel, openExternally, type CommandsDeps } from "./_helpers.ts";
8
8
 
9
- type ArtifactsDeps = Pick<CommandsDeps, "getConfig">;
9
+ type ArtifactsDeps = Pick<CommandsDeps, "getConfig" | "recordEvent">;
10
10
 
11
11
  export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps): void {
12
- const { getConfig } = deps;
12
+ const { getConfig, recordEvent } = deps;
13
13
 
14
14
  pi.registerCommand("artifacts", {
15
15
  description: "browse this project's html_artifact gallery (list, open, clear)",
16
16
  handler: async (args, ctx) => {
17
17
  if (!getConfig().artifacts.server) {
18
- ctx.ui.notify("soly: artifact server is disabled (artifacts.server=false)", "info");
18
+ recordEvent("artifact server is disabled (artifacts.server=false)");
19
19
  return;
20
20
  }
21
21
  // Always re-resolve: reuse another window's server or start ours, and
@@ -27,7 +27,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
27
27
  // ignore — handled by the count check below
28
28
  }
29
29
  if (!server || server.count === 0) {
30
- ctx.ui.notify("soly: no artifacts for this project yet (use the html_artifact tool)", "info");
30
+ recordEvent("no artifacts for this project yet (use the html_artifact tool)");
31
31
  return;
32
32
  }
33
33
  const gallery = server.galleryUrl();
@@ -35,15 +35,15 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
35
35
 
36
36
  if (sub === "clear") {
37
37
  const n = server.clear();
38
- ctx.ui.notify(`soly: cleared ${n} artifact(s)`, "info");
38
+ recordEvent(`cleared ${n} artifact(s)`);
39
39
  return;
40
40
  }
41
41
  if (sub === "open" || sub === "gallery") {
42
42
  try {
43
43
  await openExternally(pi, gallery);
44
- ctx.ui.notify(`soly: opened ${gallery}`, "info");
44
+ recordEvent(`opened ${gallery}`);
45
45
  } catch {
46
- ctx.ui.notify(`soly artifacts gallery: ${gallery}`, "info");
46
+ recordEvent(`soly artifacts gallery: ${gallery}`);
47
47
  }
48
48
  return;
49
49
  }
@@ -80,7 +80,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
80
80
 
81
81
  // Non-TUI: print the list + gallery URL.
82
82
  const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
83
- ctx.ui.notify([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"), "info");
83
+ recordEvent([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"));
84
84
  },
85
85
  });
86
86
  }
package/commands/docs.ts CHANGED
@@ -7,10 +7,10 @@ import { formatTok, solyDirFor } from "../core.ts";
7
7
  import { buildIntentStats, formatIntentStats, loadInlineIntentBodies, type IntentInlineDoc } from "../intent.ts";
8
8
  import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
9
9
 
10
- type DocsDeps = Pick<CommandsDeps, "getIntentDocs">;
10
+ type DocsDeps = Pick<CommandsDeps, "getIntentDocs" | "recordEvent">;
11
11
 
12
12
  export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
13
- const { getIntentDocs } = deps;
13
+ const { getIntentDocs, recordEvent } = deps;
14
14
 
15
15
  pi.registerCommand("docs", {
16
16
  description: "manage soly intent docs (stats — show context breakdown)",
@@ -30,7 +30,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
30
30
  if (sub === "list") {
31
31
  const docs = getIntentDocs();
32
32
  if (docs.length === 0) {
33
- ui.notify("no intent docs found in .agents/docs/ — drop your vision/domain docs there", "info");
33
+ recordEvent("no intent docs found in .agents/docs/ — drop your vision/domain docs there");
34
34
  return;
35
35
  }
36
36
  if (ctx.mode === "tui") {
@@ -55,7 +55,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
55
55
  });
56
56
  return;
57
57
  }
58
- ui.notify(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"), "info");
58
+ recordEvent(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"));
59
59
  return;
60
60
  }
61
61
 
@@ -63,7 +63,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
63
63
  const docs = getIntentDocs();
64
64
  const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
65
65
  const stats = buildIntentStats(docs, inlineBodies);
66
- ui.notify(formatIntentStats(stats), "info");
66
+ recordEvent(formatIntentStats(stats));
67
67
  return;
68
68
  }
69
69
 
package/commands/rules.ts CHANGED
@@ -30,10 +30,10 @@ function ruleItem(r: RuleFile): ListItem {
30
30
  };
31
31
  }
32
32
 
33
- type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus">;
33
+ type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus" | "recordEvent">;
34
34
 
35
35
  export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
36
- const { getRules, getOverridden, refreshRules, updateStatus } = deps;
36
+ const { getRules, getOverridden, refreshRules, updateStatus, recordEvent } = deps;
37
37
 
38
38
  pi.registerCommand("rules", {
39
39
  description: "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
@@ -55,7 +55,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
55
55
  const rules = getRules();
56
56
  const overridden = getOverridden();
57
57
  if (rules.length === 0 && overridden.length === 0) {
58
- ui.notify("soly: no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
58
+ recordEvent("no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
59
59
  return;
60
60
  }
61
61
  // Rich modal in the TUI; plain select elsewhere (RPC/print).
@@ -85,7 +85,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
85
85
  for (const p of overridden) {
86
86
  lines.push(`⊘ [overridden] ${p}`);
87
87
  }
88
- ui.notify(lines.join("\n"), "info");
88
+ recordEvent(lines.join("\n"));
89
89
  return;
90
90
  }
91
91
 
@@ -100,20 +100,20 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
100
100
  return;
101
101
  }
102
102
  const text = `---\n${r.meta.description ? `description: ${r.meta.description}\n` : ""}source: ${r.sourceLabel}\nenabled: ${r.enabled}\n---\n\n${r.body}`;
103
- ui.notify(text, "info");
103
+ recordEvent(text);
104
104
  return;
105
105
  }
106
106
 
107
107
  if (sub === "stats") {
108
108
  const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
109
- ui.notify(formatAnalyticsFull(analytics), "info");
109
+ recordEvent(formatAnalyticsFull(analytics));
110
110
  return;
111
111
  }
112
112
 
113
113
  if (sub === "analytics") {
114
114
  const rules = getRules();
115
115
  const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
116
- ui.notify(formatRulesContextStats(stats), "info");
116
+ recordEvent(formatRulesContextStats(stats));
117
117
  return;
118
118
  }
119
119
 
@@ -129,14 +129,14 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
129
129
  }
130
130
  r.enabled = sub === "enable";
131
131
  updateStatus(ui);
132
- ui.notify(`Rule ${target}: ${sub}d`, "info");
132
+ recordEvent(`Rule ${target}: ${sub}d`);
133
133
  return;
134
134
  }
135
135
 
136
136
  if (sub === "reload") {
137
137
  refreshRules();
138
138
  updateStatus(ui);
139
- ui.notify(`Reloaded ${getRules().length} rules`, "info");
139
+ recordEvent(`Reloaded ${getRules().length} rules`);
140
140
  return;
141
141
  }
142
142
 
@@ -3,8 +3,10 @@
3
3
  // =============================================================================
4
4
 
5
5
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import type { CommandsDeps } from "./_helpers.ts";
6
7
 
7
- export function registerRulewizardCommand(pi: ExtensionAPI): void {
8
+ export function registerRulewizardCommand(pi: ExtensionAPI, deps: CommandsDeps): void {
9
+ void deps; // rulewizard doesn't currently emit non-error events
8
10
  pi.registerCommand("rulewizard", {
9
11
  description:
10
12
  "interactive guide: decide whether a constraint should be a soly rule, an .editorconfig entry, or a linter config (eslint/biome/prettier). Use this BEFORE writing a new rule to avoid duplicating what linters already enforce.",
package/commands/soly.ts CHANGED
@@ -25,10 +25,10 @@ import { initSolyProject } from "../init.js";
25
25
  import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "../workflows/parser.ts";
26
26
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
27
27
 
28
- type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState">;
28
+ type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState" | "recordEvent">;
29
29
 
30
30
  export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
31
- const { getState, getConfig, reloadConfig, updateStatus, refreshState } = deps;
31
+ const { getState, getConfig, reloadConfig, updateStatus, refreshState, recordEvent } = deps;
32
32
 
33
33
  const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
34
34
  const ui: CommandUI = {
@@ -105,22 +105,22 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
105
105
  switch (verb) {
106
106
  case "new": {
107
107
  const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
108
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
108
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
109
109
  return;
110
110
  }
111
111
  case "done": {
112
112
  const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
113
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
113
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
114
114
  return;
115
115
  }
116
116
  case "migrate": {
117
117
  const r = buildMigrateTransform(state, ui, ctx.cwd);
118
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
118
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
119
119
  return;
120
120
  }
121
121
  case "plan": {
122
122
  const r = buildPlanTransform(cmd, state);
123
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
123
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
124
124
  return;
125
125
  }
126
126
  case "discuss": {
@@ -129,13 +129,13 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
129
129
  // plain-text discussion format; the LLM-driven path still
130
130
  // gets the richer ask_pro flow.
131
131
  const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
132
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
132
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
133
133
  return;
134
134
  }
135
135
  case "execute": {
136
136
  // Slash-command path doesn't have live interactive rules.
137
137
  const r = buildExecuteTransform(cmd, state, []);
138
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
138
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
139
139
  return;
140
140
  }
141
141
  default:
@@ -181,7 +181,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
181
181
  "info",
182
182
  );
183
183
  } else {
184
- ui.notify(`${s.milestone} — no position set`, "info");
184
+ recordEvent(`${s.milestone} — no position set`);
185
185
  }
186
186
  },
187
187
  },
@@ -244,7 +244,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
244
244
  run: () => {
245
245
  const s = getState();
246
246
  const line = `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
247
- ui.notify(line, "info");
247
+ recordEvent(line);
248
248
  },
249
249
  },
250
250
  phases: {
@@ -252,7 +252,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
252
252
  run: () => {
253
253
  const phases = getState().phases;
254
254
  if (phases.length === 0) {
255
- ui.notify("no phases", "info");
255
+ recordEvent("no phases");
256
256
  return;
257
257
  }
258
258
  const current = getState().currentPhase?.number;
@@ -261,7 +261,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
261
261
  const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
262
262
  return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
263
263
  });
264
- ui.notify(lines.join("\n"), "info");
264
+ recordEvent(lines.join("\n"));
265
265
  },
266
266
  },
267
267
  tasks: {
@@ -269,7 +269,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
269
269
  run: () => {
270
270
  const s = getState();
271
271
  if (s.tasks.length === 0) {
272
- ui.notify("no tasks", "info");
272
+ recordEvent("no tasks");
273
273
  return;
274
274
  }
275
275
  const byFeature = new Map<string, typeof s.tasks>();
@@ -288,7 +288,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
288
288
  }
289
289
  out.push("");
290
290
  }
291
- ui.notify(out.join("\n"), "info");
291
+ recordEvent(out.join("\n"));
292
292
  },
293
293
  },
294
294
  task: {
@@ -319,7 +319,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
319
319
  if (summaryBody) {
320
320
  showFile("SUMMARY.md", summaryBody);
321
321
  } else {
322
- ui.notify("no SUMMARY.md yet", "info");
322
+ recordEvent("no SUMMARY.md yet");
323
323
  }
324
324
  },
325
325
  },
@@ -328,14 +328,14 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
328
328
  run: () => {
329
329
  const features = getState().features;
330
330
  if (features.length === 0) {
331
- ui.notify("no features", "info");
331
+ recordEvent("no features");
332
332
  return;
333
333
  }
334
334
  const lines = features.map((f) => {
335
335
  const rm = f.readmeExists ? "R" : "·";
336
336
  return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
337
337
  });
338
- ui.notify(lines.join("\n"), "info");
338
+ recordEvent(lines.join("\n"));
339
339
  },
340
340
  },
341
341
  milestone: {
@@ -343,7 +343,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
343
343
  run: () => {
344
344
  const s = getState();
345
345
  if (!s.milestone || s.milestone === "—") {
346
- ui.notify("soly: no milestone set", "info");
346
+ recordEvent("no milestone set");
347
347
  return;
348
348
  }
349
349
  const candidates = [
@@ -369,7 +369,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
369
369
  refreshState();
370
370
  updateStatus(ui);
371
371
  const s = getState();
372
- ui.notify(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`, "info");
372
+ recordEvent(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`);
373
373
  },
374
374
  },
375
375
  // ------------------------------------------------------------------
package/commands/why.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
6
  import type { CommandsDeps } from "./_helpers.ts";
7
7
 
8
- type WhyDeps = Pick<CommandsDeps, "getState" | "getRules">;
8
+ type WhyDeps = Pick<CommandsDeps, "getState" | "getRules" | "recordEvent">;
9
9
 
10
10
  export function registerWhyCommand(pi: ExtensionAPI, deps: WhyDeps): void {
11
- const { getState, getRules } = deps;
11
+ const { getState, getRules, recordEvent } = deps;
12
12
 
13
13
  pi.registerCommand("why", {
14
14
  description:
package/commands.ts CHANGED
@@ -39,6 +39,6 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
39
39
  registerDocsCommand(pi, deps);
40
40
  registerSolyCommand(pi, deps); // also registers /sly and /s
41
41
  registerArtifactsCommand(pi, deps);
42
- registerRulewizardCommand(pi);
42
+ registerRulewizardCommand(pi, deps);
43
43
  registerWhyCommand(pi, deps);
44
44
  }
package/index.ts CHANGED
@@ -384,6 +384,10 @@ export default function solyExtension(pi: ExtensionAPI) {
384
384
  }
385
385
  },
386
386
  getIntentDocs: () => intentDocs,
387
+ // Non-error soly events land in the Working sub-line (└─ prefixed).
388
+ // The route goes through the chrome so the sub-line auto-clears on
389
+ // the next agent_start.
390
+ recordEvent: (text, level) => chrome.recordEvent(text, level),
387
391
  });
388
392
 
389
393
  registerTools(pi, {
@@ -393,6 +397,7 @@ export default function solyExtension(pi: ExtensionAPI) {
393
397
  });
394
398
 
395
399
  registerWorkflows(pi, {
400
+ recordEvent: (text, level) => chrome.recordEvent(text, level),
396
401
  getState: () => state,
397
402
  getInteractiveRules: () =>
398
403
  combinedRules()
@@ -531,9 +536,11 @@ export default function solyExtension(pi: ExtensionAPI) {
531
536
  },
532
537
  });
533
538
  // Editors save in bursts (write to .tmp, rename, touch). Coalesce
534
- // those rapid reload events into a single user-visible notify.
539
+ // those rapid reload events into a single sub-line event under the
540
+ // Working indicator (└─ reloaded 47 rules). Errors here are real
541
+ // (disk I/O failed) and stay as popups.
535
542
  hotReload.setNotifyHandler((reason) => {
536
- ctx.ui.notify(`soly: rules reloaded (${reason})`, "info");
543
+ chrome.recordEvent(`reloaded rules (${reason})`);
537
544
  });
538
545
 
539
546
  // Notifications (one-shot at startup)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.2.6",
3
+ "version": "2.3.1",
4
4
  "description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/visual/chrome.ts CHANGED
@@ -61,6 +61,11 @@ export type Chrome = {
61
61
  updateWorking(ui: ExtensionUIContext, outputTokens: number): void;
62
62
  /** Stop the telemetry line and restore the default message (agent_end). */
63
63
  stopWorking(ui: ExtensionUIContext): void;
64
+ /** Record a non-error soly event. Rendered as a sub-line under the
65
+ * Working indicator (└─ prefixed, level-marker glyph). Auto-cleared on
66
+ * the next `startWorking`. Errors are still surfaced via
67
+ * `ui.notify(text, "error")` — call that directly for the popup. */
68
+ recordEvent(text: string, level?: "info" | "warning"): void;
64
69
  /** Restore pi's native footer/widgets/indicator (session_shutdown / disable). */
65
70
  dispose(ui?: ExtensionUIContext): void;
66
71
  };
@@ -98,9 +103,15 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
98
103
  };
99
104
 
100
105
  return {
106
+ recordEvent(text: string, level: "info" | "warning" = "info"): void {
107
+ data.recentEvent = text;
108
+ data.recentEventLevel = level;
109
+ try { tui?.requestRender(); } catch { /* not mounted yet */ }
110
+ },
111
+
101
112
  data,
102
113
 
103
- install(ui): void {
114
+ install(ui: ExtensionUIContext): void {
104
115
  if (!getConfig().enabled) return;
105
116
  ui.setWorkingIndicator({ frames: getConfig().spinnerFrames, intervalMs: getConfig().spinnerIntervalMs });
106
117
  ui.setFooter((t, theme, footerData) => {
@@ -133,6 +144,10 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
133
144
  startWorking(ui): void {
134
145
  if (!getConfig().enabled || !getConfig().telemetry) return;
135
146
  clearWorking();
147
+ // Each new turn starts with a clean sub-line — old events were
148
+ // from the previous turn, not relevant to the current one.
149
+ data.recentEvent = null;
150
+ data.recentEventLevel = null;
136
151
  working = { ui, startMs: Date.now(), inputTokens: data.ctxTokens ?? 0, outputTokens: 0, timer: null };
137
152
  renderWorking();
138
153
  working.timer = setInterval(renderWorking, 1000);
package/visual/data.ts CHANGED
@@ -39,6 +39,12 @@ export type ChromeData = {
39
39
  verbLabel: string | null;
40
40
  /** html_artifacts created this session (0 = no segment). */
41
41
  artifactCount: number;
42
+ /** Most recent non-error soly event (e.g. "reloaded 47 rules"). Rendered
43
+ * as a sub-line under the Working indicator; auto-cleared by the next
44
+ * agent_start. null = no recent event. */
45
+ recentEvent: string | null;
46
+ /** Level of the recent event (used for glyph/color). */
47
+ recentEventLevel: "info" | "warning" | "error" | null;
42
48
  };
43
49
 
44
50
  /** A fresh ChromeData with everything empty/idle. */
@@ -58,5 +64,7 @@ export function emptyChromeData(): ChromeData {
58
64
  phaseLabel: null,
59
65
  verbLabel: null,
60
66
  artifactCount: 0,
67
+ recentEvent: null,
68
+ recentEventLevel: null,
61
69
  };
62
70
  }
package/visual/footer.ts CHANGED
@@ -124,6 +124,15 @@ export class SolyFooter implements Component {
124
124
  }
125
125
 
126
126
  render(width: number): string[] {
127
- return [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
127
+ const out: string[] = [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
128
+ const event = this.data.recentEvent;
129
+ if (event) {
130
+ const glyph = this.data.recentEventLevel === "warning" ? "└─ ⚠" : "└─";
131
+ const styled = this.data.recentEventLevel === "warning"
132
+ ? themeStyler(this.theme).fg("warning", `${glyph} ${event}`)
133
+ : themeStyler(this.theme).dim(`${glyph} ${event}`);
134
+ out.push(" " + styled);
135
+ }
136
+ return out;
128
137
  }
129
138
  }
package/workflows/done.ts CHANGED
@@ -95,7 +95,7 @@ export function buildDoneTransform(
95
95
  if ("error" in parsed) {
96
96
  return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
97
97
  }
98
- const { name, prefix } = parsed;
98
+ const { name, prefix, autoSlugified, originalInput } = parsed;
99
99
  const effectivePrefix = prefix ?? opts.defaultBranchPrefix ?? "";
100
100
  const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name;
101
101
 
@@ -32,6 +32,7 @@ import type { SolyState } from "../core.js";
32
32
  import type { SolyConfig } from "../config.js";
33
33
 
34
34
  export interface WorkflowsDeps {
35
+ recordEvent: (text: string, level?: "info" | "warning") => void;
35
36
  getState: () => SolyState;
36
37
  /** List of rule relPaths marked `interactive: true` — inlined into the
37
38
  * execute instruction so the model knows which rules are explicitly out of
@@ -57,7 +58,7 @@ export interface WorkflowsDeps {
57
58
  const MODE_VERBS: readonly string[] = ["execute", "plan", "discuss", "resume"];
58
59
 
59
60
  export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
60
- const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed } = deps;
61
+ const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed, recordEvent } = deps;
61
62
 
62
63
  // Self-review loop ("soly verify"). Owns its own agent_end + input hooks;
63
64
  // fresh-context mode rewrites the next LLM call through the context manager.
@@ -276,7 +277,7 @@ State inspection lives on the slash form — \`/soly <sub>\`:
276
277
  "and .agents/.continue-here.md. Preserve milestone/phase/plan position and key " +
277
278
  "decisions in the summary. Drop implementation-detail noise.",
278
279
  onComplete: () => {
279
- ctx.ui.notify("soly: session compacted. Use `soly resume` to pick up.", "info");
280
+ recordEvent("session compacted use 'soly resume' to pick up");
280
281
  },
281
282
  onError: (err) => {
282
283
  ctx.ui.notify(`soly: compact failed — ${err.message}`, "error");
package/workflows/new.ts CHANGED
@@ -89,7 +89,7 @@ export function buildNewTransform(
89
89
  const parsed = parsePlanName(cmd.args.join(" "));
90
90
  if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
91
91
 
92
- const { name, prefix } = parsed;
92
+ const { name, prefix, autoSlugified, originalInput } = parsed;
93
93
  // Branch can be one of three shapes:
94
94
  // - "feature/statistic-preparation" (user typed <prefix>/<slug>)
95
95
  // - "feature/statistic-preparation" (config has defaultBranchPrefix "feature", user typed "statistic-preparation")
@@ -123,18 +123,45 @@ export function buildNewTransform(
123
123
 
124
124
  const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
125
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.
126
+ // The base can also be the long-lived integration branches `master`/`main`.
127
+ // Anything else (release tags, weird suffixes, feature branches from
128
+ // previous work) — we auto-checkout the base branch instead of
129
+ // blocking the user. The LLM driving soly shouldn't have to manage
130
+ // git state.
129
131
  if (
130
132
  currentBranch !== "master" &&
131
133
  currentBranch !== "main" &&
132
134
  !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch)
133
135
  ) {
134
- return reply(
135
- `soly new: currently on "${currentBranch}" (not master/main, not a soly plan branch). ` +
136
- `Switch back to master first with \`git checkout master\`.`,
137
- );
136
+ // Detect which base branch the repo uses: try `main` first, fall
137
+ // back to `master`. If neither exists, give up the repo is in
138
+ // a state the user needs to resolve by hand.
139
+ const baseBranch = ["main", "master"].find((b) => {
140
+ try {
141
+ git(["rev-parse", "--verify", b], { cwd: projectRoot });
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ });
147
+ if (!baseBranch) {
148
+ return reply(
149
+ `soly new: cannot find a base branch to base off — repo has neither "main" nor "master". ` +
150
+ `Run \`git checkout <base>\` first, or create a default branch.`,
151
+ );
152
+ }
153
+ try {
154
+ git(["checkout", baseBranch], { cwd: projectRoot });
155
+ ui.notify(
156
+ `auto-checkout ${baseBranch} (was on ${currentBranch})`,
157
+ "info",
158
+ );
159
+ } catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ return reply(
162
+ `soly new: failed to checkout ${baseBranch} (was on ${currentBranch}): ${msg}`,
163
+ );
164
+ }
138
165
  }
139
166
 
140
167
  let branchExisted = false;
@@ -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
  }