pi-soly 1.11.2 → 1.12.0

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.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +153 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. package/ask/tests/prompt.test.ts +0 -54
@@ -0,0 +1,55 @@
1
+ // =============================================================================
2
+ // visual/topbar.ts — soly's top "polosa" (aboveEditor widget)
3
+ // =============================================================================
4
+ //
5
+ // execute ─────────────────────────────────────────── ⊙ opus-4.8 · high
6
+ // └─ left: verb right: model (+ thinking level) ─┘
7
+ //
8
+ // Carries soly's active *workflow verb* only (phase moved to the footer).
9
+ // Hidden entirely (renders []) when no verb is active — so ordinary sessions
10
+ // show no extra line. The model is shown here when the bar is visible, and in
11
+ // the footer otherwise (see footer.ts), so it never appears twice.
12
+ // =============================================================================
13
+
14
+ import type { Component } from "@earendil-works/pi-tui";
15
+ import type { Theme } from "@earendil-works/pi-coding-agent";
16
+ import { composeBar, type Segment } from "./segments.ts";
17
+ import type { ChromeData } from "./data.ts";
18
+ import { modelText } from "./footer.ts";
19
+ import { themeStyler, type ChromeStyler } from "./style.ts";
20
+
21
+ export type TopBarOpts = { ascii: boolean; styler: ChromeStyler };
22
+
23
+ /**
24
+ * Build the top-bar lines. Returns `[]` (hidden) when there is no verb and no
25
+ * phase. Pure given a styler.
26
+ */
27
+ export function buildTopBarLines(data: ChromeData, width: number, opts: TopBarOpts): string[] {
28
+ const { ascii, styler } = opts;
29
+ const left: Segment[] = [];
30
+ // Phase now lives in the footer; the top bar appears only for an active verb.
31
+ if (data.verbLabel) left.push({ id: "verb", text: styler.fg("muted", data.verbLabel), priority: 8 });
32
+ if (left.length === 0) return [];
33
+
34
+ const right: Segment[] = [];
35
+ if (data.modelId) right.push({ id: "model", text: styler.fg("muted", modelText(data, ascii)), priority: 6 });
36
+
37
+ return [composeBar({ left, right, width, styleFill: styler.dim })];
38
+ }
39
+
40
+ /** pi Component wrapping the top bar; reads live ChromeData each render. */
41
+ export class SolyTopBar implements Component {
42
+ constructor(
43
+ private readonly data: ChromeData,
44
+ private readonly theme: Theme,
45
+ private readonly getAscii: () => boolean,
46
+ ) {}
47
+
48
+ invalidate(): void {
49
+ /* stateless — render() always reads fresh data */
50
+ }
51
+
52
+ render(width: number): string[] {
53
+ return buildTopBarLines(this.data, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) });
54
+ }
55
+ }
@@ -0,0 +1,265 @@
1
+ // =============================================================================
2
+ // visual/welcome.ts — soly startup header (replaces pi's built-in banner)
3
+ // =============================================================================
4
+ //
5
+ // Installed via ctx.ui.setHeader, shown once at startup above the chat:
6
+ //
7
+ // ████ block "soly" wordmark (accent) ...
8
+ // the project framework · running on pi, the coding engine
9
+ //
10
+ // soly adds plans · state · rules · workflows · ask_pro picker
11
+ // project v1.12 · plan 2/5 — auth refactor · ≡ 4 rules · 2 docs
12
+ // next → /execute
13
+ // start here /soly-init · /plan · /soly · /why · /rules stats
14
+ // recent 1.11.2 … · 1.10.0 …
15
+ //
16
+ // pi is explicitly credited (engine) with soly as the framework on top. The
17
+ // builder is pure; fs access (version + CHANGELOG) is isolated in
18
+ // readWelcomeMeta so the layout is unit-testable.
19
+ // =============================================================================
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import type { Component } from "@earendil-works/pi-tui";
24
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
25
+ import type { Theme } from "@earendil-works/pi-coding-agent";
26
+ import { themeStyler, type ChromeStyler } from "./style.ts";
27
+ import { type ColorMode, type RGB, colorizeColumns, gradient, hexToRgb, parseAnsiColor, variations } from "./gradient.ts";
28
+
29
+ /** Letter-fill texture (dense, with a lighter weave) — readable but detailed. */
30
+ const LETTER_TEX = ["█", "▓", "█", "▒"];
31
+ /** Particle ramp for the right-side field: sparse/light → dense/full. */
32
+ const PARTICLE = ["·", "░", "▒", "▓", "█"];
33
+ /** Left margin before the wordmark. */
34
+ const LEFT_PAD = 2;
35
+
36
+ /** Stable, well-distributed [0,1) hash of a cell (murmur3 finalizer). `salt`
37
+ * yields independent streams for placement vs. glyph weight. No flicker. */
38
+ function hash01(x: number, y: number, salt = 0): number {
39
+ let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(salt, 2246822519)) | 0;
40
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
41
+ h ^= h >>> 16;
42
+ return (h >>> 0) / 4294967296;
43
+ }
44
+
45
+ /** Block wordmark for "soly" (accent + bold). Falls back to plain text in ASCII mode. */
46
+ export const SOLY_ART: readonly string[] = [
47
+ "█████ █████ █ █ █",
48
+ "█ █ █ █ █ █",
49
+ "█████ █ █ █ █████",
50
+ " █ █ █ █ █",
51
+ "█████ █████ █████ █████",
52
+ ];
53
+
54
+ const TAGLINE = "the project framework · running on pi, the coding engine";
55
+ const SOLY_ADDS = "plans · state · rules · workflows · ask_pro picker";
56
+ const START_HERE: ReadonlyArray<[string, string]> = [
57
+ ["/soly-init", "scaffold .agents/ in a new project"],
58
+ ["/plan", "plan the current phase"],
59
+ ["/soly", "state picker · /why · /rules stats"],
60
+ ];
61
+ const LABEL_WIDTH = 11;
62
+
63
+ /** Live values for the welcome header. */
64
+ export type WelcomeInput = {
65
+ version: string;
66
+ hasProject: boolean;
67
+ phaseLabel: string | null;
68
+ nextHint: string | null;
69
+ rulesActive: number;
70
+ docsCount: number;
71
+ /** Up to N "version summary" strings parsed from CHANGELOG.md. */
72
+ recent: string[];
73
+ };
74
+
75
+ export type WelcomeOpts = {
76
+ ascii: boolean;
77
+ styler: ChromeStyler;
78
+ width: number;
79
+ /** Terminal color depth; "none" (default) disables the gradient. */
80
+ colorMode?: ColorMode;
81
+ /** Explicit gradient stops (hex); when empty the accent is used instead. */
82
+ colorStops?: string[];
83
+ /** Theme accent in RGB — the default gradient is variations of this. */
84
+ accent?: RGB | null;
85
+ };
86
+
87
+ /** Indented `label value` row with a fixed label column. */
88
+ function row(label: string, value: string, styler: ChromeStyler): string {
89
+ return ` ${styler.dim(label.padEnd(LABEL_WIDTH))}${value}`;
90
+ }
91
+
92
+ /**
93
+ * One field cell to the right of the wordmark — a particle spray that fades
94
+ * outward. Placement uses one cell hash (so it scatters, no vertical bands);
95
+ * the glyph weight uses a second, independent hash mixed with the falloff, so
96
+ * dense near the letters and sparse sparkles toward the edge — never solid bands.
97
+ */
98
+ function fieldCell(row: number, col: number, start: number, width: number): string {
99
+ const span = Math.max(12, width - start);
100
+ const fall = Math.max(0, 1 - (col - start) / span) ** 1.8; // bright near letters → faint outward
101
+ const density = Math.max(fall, 0.05); // faint starfield floor so sparks reach the full width
102
+ if (hash01(col, row) > density) return " ";
103
+ const weight = hash01(col, row, 101);
104
+ const idx = Math.min(PARTICLE.length - 1, Math.floor(fall * (0.55 + 0.5 * weight) * PARTICLE.length));
105
+ return PARTICLE[idx] ?? "·";
106
+ }
107
+
108
+ /** Build one banner row: textured letters (left) + dissolving particle field (right). */
109
+ function bannerRow(row: number, width: number): string {
110
+ const mask = SOLY_ART[row] ?? "";
111
+ const artW = visibleWidth(SOLY_ART[0] ?? "");
112
+ let line = " ".repeat(LEFT_PAD);
113
+ for (let c = LEFT_PAD; c < width; c++) {
114
+ const ac = c - LEFT_PAD;
115
+ if (ac < artW) {
116
+ const cell = mask[ac];
117
+ line += cell && cell !== " " ? (LETTER_TEX[(row + ac) % LETTER_TEX.length] ?? "█") : " ";
118
+ } else {
119
+ line += fieldCell(row, c, LEFT_PAD + artW, width);
120
+ }
121
+ }
122
+ return line;
123
+ }
124
+
125
+ /**
126
+ * The banner: a "soly" wordmark drawn with mixed block glyphs that dissolves
127
+ * into a generative particle field on the right, swept by a gradient derived
128
+ * from the theme accent (or explicit stops). Falls back to a plain accent
129
+ * wordmark in ASCII / no-color / very narrow terminals.
130
+ */
131
+ function bannerLines(opts: WelcomeOpts): string[] {
132
+ const { ascii, styler, width, colorMode = "none", colorStops = [], accent = null } = opts;
133
+ const artW = visibleWidth(SOLY_ART[0] ?? "");
134
+ if (ascii || width < artW + LEFT_PAD) return [styler.bold(styler.fg("accent", "soly"))];
135
+
136
+ const rows = SOLY_ART.map((_, r) => bannerRow(r, width));
137
+ const explicit = colorStops.map(hexToRgb).filter((c): c is RGB => c !== null);
138
+ const stops = explicit.length > 0 ? explicit : accent ? variations(accent) : [];
139
+ if (colorMode === "none" || stops.length === 0) return rows.map((row) => styler.fg("accent", row));
140
+
141
+ const colors = gradient(stops, width);
142
+ return rows.map((row) => colorizeColumns(row, colors, colorMode));
143
+ }
144
+
145
+ /** The "project" row value, depending on whether a soly project exists. */
146
+ function projectValue(input: WelcomeInput, styler: ChromeStyler): string {
147
+ if (!input.hasProject) return styler.dim("no soly project here → /soly-init to scaffold");
148
+ const bits = [styler.fg("accent", `v${input.version}`)];
149
+ if (input.phaseLabel) bits.push(input.phaseLabel);
150
+ if (input.rulesActive > 0) bits.push(`≡ ${input.rulesActive}`);
151
+ if (input.docsCount > 0) bits.push(`${input.docsCount} docs`);
152
+ return bits.join(styler.dim(" · "));
153
+ }
154
+
155
+ /**
156
+ * Build the full welcome header. Pure given a styler (identity in tests).
157
+ * Width is used only to choose the banner form; rows are not hard-wrapped.
158
+ */
159
+ export function buildWelcomeLines(input: WelcomeInput, opts: WelcomeOpts): string[] {
160
+ const { styler } = opts;
161
+ const lines: string[] = [...bannerLines(opts), "", ` ${styler.dim(TAGLINE)}`, ""];
162
+
163
+ lines.push(row("soly adds", styler.dim(SOLY_ADDS), styler));
164
+ lines.push(row("project", projectValue(input, styler), styler));
165
+ if (input.hasProject && input.nextHint) lines.push(row("next", styler.fg("accent", input.nextHint), styler));
166
+
167
+ lines.push("");
168
+ START_HERE.forEach(([cmd, desc], i) => {
169
+ const label = i === 0 ? "start here" : "";
170
+ lines.push(row(label, `${styler.fg("accent", cmd.padEnd(11))} ${styler.dim(desc)}`, styler));
171
+ });
172
+
173
+ if (input.recent.length > 0) {
174
+ lines.push("");
175
+ input.recent.forEach((entry, i) => lines.push(row(i === 0 ? "recent" : "", styler.dim(entry), styler)));
176
+ }
177
+
178
+ return lines;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Metadata (fs) — version from package.json, recent entries from CHANGELOG.md
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function readChangelogText(extRoot: string): string | null {
186
+ for (const p of [path.join(extRoot, "CHANGELOG.md"), path.join(extRoot, "..", "..", "CHANGELOG.md")]) {
187
+ try {
188
+ return fs.readFileSync(p, "utf-8");
189
+ } catch {
190
+ /* try next */
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /** Strip markdown emphasis/links from a bullet and trim to a one-liner. */
197
+ function changelogTitle(bullet: string): string {
198
+ const bold = bullet.match(/\*\*(.+?)\*\*/);
199
+ const raw = (bold ? bold[1] : bullet).replace(/[`*_]/g, "").replace(/\[(.+?)\]\(.*?\)/g, "$1").trim();
200
+ return raw.length > 46 ? `${raw.slice(0, 45)}…` : raw;
201
+ }
202
+
203
+ /** Parse the top `n` "version summary" lines from CHANGELOG.md. */
204
+ export function parseRecentChanges(text: string, n: number): string[] {
205
+ const out: string[] = [];
206
+ let version: string | null = null;
207
+ for (const line of text.split(/\r?\n/)) {
208
+ const head = line.match(/^#{2,}\s*\[?v?(\d+\.\d+\.\d+)\]?/);
209
+ if (head?.[1]) {
210
+ version = head[1];
211
+ continue;
212
+ }
213
+ const bullet = line.match(/^\s*-\s+(.*\S)\s*$/);
214
+ if (version && bullet?.[1]) {
215
+ out.push(`${version} ${changelogTitle(bullet[1])}`);
216
+ version = null;
217
+ if (out.length >= n) break;
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** Read version + recent changelog entries from the installed package. */
224
+ export function readWelcomeMeta(extRoot: string, recentCount = 2): { version: string; recent: string[] } {
225
+ let version = "0.0.0";
226
+ try {
227
+ const pkg = JSON.parse(fs.readFileSync(path.join(extRoot, "package.json"), "utf-8")) as { version?: string };
228
+ if (typeof pkg.version === "string") version = pkg.version;
229
+ } catch {
230
+ /* keep default */
231
+ }
232
+ const text = readChangelogText(extRoot);
233
+ return { version, recent: text ? parseRecentChanges(text, recentCount) : [] };
234
+ }
235
+
236
+ /** pi Component for the startup header. Reads the welcome snapshot live. */
237
+ export class SolyHeader implements Component {
238
+ constructor(
239
+ private readonly getWelcome: () => WelcomeInput | null,
240
+ private readonly theme: Theme,
241
+ private readonly getAscii: () => boolean,
242
+ private readonly getBannerColors: () => string[],
243
+ ) {}
244
+
245
+ invalidate(): void {
246
+ /* stateless */
247
+ }
248
+
249
+ render(width: number): string[] {
250
+ const input = this.getWelcome();
251
+ if (!input) return [];
252
+ const ascii = this.getAscii();
253
+ const colorMode: ColorMode = ascii ? "none" : this.theme.getColorMode();
254
+ const lines = buildWelcomeLines(input, {
255
+ ascii,
256
+ styler: themeStyler(this.theme),
257
+ width,
258
+ colorMode,
259
+ colorStops: this.getBannerColors(),
260
+ accent: parseAnsiColor(this.theme.getFgAnsi("accent")),
261
+ });
262
+ // Never let a line overflow the viewport.
263
+ return lines.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "") : line));
264
+ }
265
+ }
@@ -0,0 +1,66 @@
1
+ // =============================================================================
2
+ // visual/working.ts — working-indicator telemetry message (pure builder)
3
+ // =============================================================================
4
+ //
5
+ // pi animates the working spinner natively (ctx.ui.setWorkingIndicator with
6
+ // our snowflake frames). This module only builds the *text* shown next to it
7
+ // via ctx.ui.setWorkingMessage — a live telemetry line:
8
+ //
9
+ // Working · 8s · ↑12.4k ↓1.2k · 148 tok/s
10
+ //
11
+ // ↑ = context tokens sent with the request (from getContextUsage at start)
12
+ // ↓ = tokens generated so far this turn (grows via message_update)
13
+ // tok/s = ↓ / elapsed seconds
14
+ //
15
+ // Fields drop by priority on narrow terminals (tok/s → tokens → time → label),
16
+ // via segments.fitParts. Pure: pass an explicit maxWidth in tests.
17
+ // =============================================================================
18
+
19
+ import { fitParts, type Segment } from "./segments.ts";
20
+ import { formatElapsed, formatTokens } from "./format.ts";
21
+
22
+ /** Default spinner frames + interval (user-chosen): braille "breathing"
23
+ * (grow→shrink) → a quadrant arrow sweep → breathing again, slowed down. */
24
+ export const SPINNER_FRAMES = [
25
+ "⠁", "⠉", "⠙", "⠹", "⠽", "⠿", "⠾", "⠼", "⠶", "⠦", "⠆", "⠂",
26
+ "◴", "◷", "◶", "◵",
27
+ "⠁", "⠉", "⠙", "⠹", "⠽", "⠿", "⠾", "⠼", "⠶", "⠦", "⠆", "⠂",
28
+ ] as const;
29
+ export const SPINNER_INTERVAL_MS = 150;
30
+
31
+ export type WorkingTelemetry = {
32
+ /** Leading word, e.g. "Working". */
33
+ label: string;
34
+ /** Elapsed time since the turn started, in ms. */
35
+ elapsedMs: number;
36
+ /** Context tokens sent with the request (↑). */
37
+ inputTokens: number;
38
+ /** Tokens generated so far this turn (↓). */
39
+ outputTokens: number;
40
+ };
41
+
42
+ /**
43
+ * Build the working telemetry line, fitted to `maxWidth` visible columns.
44
+ * Drops low-priority fields first: tok/s, then tokens, then elapsed; the
45
+ * label is kept last. Text is plain (no ANSI) — the caller may style it.
46
+ */
47
+ export function buildWorkingMessage(t: WorkingTelemetry, maxWidth: number): string {
48
+ const elapsedSec = Math.floor(t.elapsedMs / 1000);
49
+ const parts: Segment[] = [{ id: "label", text: t.label, priority: 5 }];
50
+
51
+ parts.push({ id: "time", text: formatElapsed(t.elapsedMs), priority: 4 });
52
+
53
+ const tokenBits: string[] = [];
54
+ if (t.inputTokens > 0) tokenBits.push(`↑${formatTokens(t.inputTokens)}`);
55
+ if (t.outputTokens > 0) tokenBits.push(`↓${formatTokens(t.outputTokens)}`);
56
+ if (tokenBits.length > 0) {
57
+ parts.push({ id: "tokens", text: tokenBits.join(" "), priority: 3 });
58
+ }
59
+
60
+ if (t.outputTokens > 0 && elapsedSec > 0) {
61
+ const rate = Math.round(t.outputTokens / elapsedSec);
62
+ parts.push({ id: "rate", text: `${rate} tok/s`, priority: 2 });
63
+ }
64
+
65
+ return fitParts(parts, Math.max(0, maxWidth));
66
+ }
@@ -67,7 +67,7 @@ export function buildExecuteTransform(
67
67
  cmd: SolyCommand,
68
68
  state: SolyState,
69
69
  interactiveRules: string[] = [],
70
- opts: { agent?: string; useSolyWorker?: boolean } = {},
70
+ opts: { agent?: string } = {},
71
71
  ): ExecuteHandlerResult {
72
72
  if (!state.exists) {
73
73
  return {
@@ -233,7 +233,7 @@ Hard rules:
233
233
  \`
234
234
  })
235
235
 
236
- When the subagent completes, synthesize the result. Do not re-execute its work.`;
236
+ When the subagent completes, synthesize the result. Do not re-execute its work. Then suggest \`soly verify\` to self-review the change with fresh eyes before calling it done.`;
237
237
  return { handled: true, transformedText: instruction };
238
238
  }
239
239
 
@@ -286,7 +286,12 @@ When the subagent completes, synthesize the result. Do not re-execute its work.`
286
286
  }
287
287
 
288
288
  const isPlanLevel = target.plan != null;
289
- const workflowName = isPlanLevel ? "execute-plan.md" : "execute-phase.md";
289
+ // Unified model: a phase with tasks/ executes those (dependency-ordered) via
290
+ // the task-centric workflow; legacy phases (plan files, no tasks) keep the
291
+ // wave/plan workflow.
292
+ const phaseTasks = phase.tasks ?? [];
293
+ const useTasks = !isPlanLevel && phaseTasks.length > 0;
294
+ const workflowName = isPlanLevel ? "execute-plan.md" : useTasks ? "execute-task.md" : "execute-phase.md";
290
295
  const workflow = loadWorkflowMarkdown(workflowName);
291
296
  if (!workflow) {
292
297
  return {
@@ -331,12 +336,17 @@ When the subagent completes, synthesize the result. Do not re-execute its work.`
331
336
  // workflow markdown verbatim so the LLM has full context.
332
337
  const targetDesc = isPlanLevel
333
338
  ? `phase ${target.phase} plan ${String(target.plan).padStart(2, "0")}`
334
- : `phase ${target.phase} (${phase.name}) — all ${phase.planCount} plan(s)`;
339
+ : useTasks
340
+ ? `phase ${target.phase} (${phase.name}) — ${phaseTasks.length} task(s)`
341
+ : `phase ${target.phase} (${phase.name}) — all ${phase.planCount} plan(s)`;
335
342
 
336
343
  const scopeBlock = isPlanLevel
337
344
  ? `Target: ONE plan = ${targetDesc}.
338
345
  The iteration context file lists the specific plan at section 6.`
339
- : `Target: ${targetDesc}.
346
+ : useTasks
347
+ ? `Target: ${targetDesc} — the unified task model.
348
+ Execute the tasks under \`${phase.dir}/tasks/\`. Run only tasks whose \`depends-on\` are all satisfied (status: done), in dependency order — sequential is fine. Each task produces its own SUMMARY.md in its task dir and flips its PLAN.md frontmatter to \`status: done\`. The wave/plan language in the workflow below maps onto these tasks.`
349
+ : `Target: ${targetDesc}.
340
350
  The iteration context file lists all plans (their frontmatter) in section 6, grouped by wave.`;
341
351
 
342
352
  const childRole = isPlanLevel
@@ -362,7 +372,7 @@ subagent({
362
372
  maxSubagentDepth: 1, // worker must not spawn sub-sub-agents
363
373
  task: \`You are ${childRole}.
364
374
 
365
- Your job: ${isPlanLevel ? "execute ONE plan and produce its SUMMARY.md" : "execute ALL plans in this phase using wave-based parallel execution"}.
375
+ Your job: ${isPlanLevel ? "execute ONE plan and produce its SUMMARY.md" : useTasks ? "execute the phase's ready tasks in dependency order, each producing its SUMMARY.md" : "execute ALL plans in this phase using wave-based parallel execution"}.
366
376
 
367
377
  **FIRST ACTION — read the iteration context file:**
368
378
  \`\`\`
@@ -398,7 +408,7 @@ Hard rules:
398
408
  \`
399
409
  })
400
410
 
401
- When the subagent completes, synthesize the result and confirm STATE.md was updated. Do not re-execute its work.`;
411
+ When the subagent completes, synthesize the result and confirm STATE.md was updated. Do not re-execute its work. Then suggest \`soly verify\` to self-review the work with fresh eyes before calling the phase done.`;
402
412
 
403
413
  return { handled: true, transformedText: instruction };
404
414
  }
@@ -23,6 +23,9 @@ import { buildResumeTransform } from "./resume.ts";
23
23
  import { showStatus, showLog, showDiff } from "./quick.ts";
24
24
  import { showDoctor, showIterations, showDiffIterations, showPhaseDelete, showTodos } from "./inspect.ts";
25
25
  import { buildPlanTransform, buildDiscussTransform } from "./planning.ts";
26
+ import { createVerifyLoop, type VerifyState } from "./verify.ts";
27
+ import { buildMigrateTransform } from "./migrate.ts";
28
+ import type { ContextManager } from "../context-manager.ts";
26
29
  import type { SolyState } from "../core.js";
27
30
  import type { SolyConfig } from "../config.js";
28
31
 
@@ -39,18 +42,45 @@ export interface WorkflowsDeps {
39
42
  /** Fired when a recognized soly verb is parsed (handled OR transformed).
40
43
  * Used by the parent extension to reset drift counters etc. */
41
44
  onWorkflowUsed?: () => void;
45
+ /** Shared owner of pi's `context` channel (for /verify fresh-context mode). */
46
+ contextManager: ContextManager;
47
+ /** Fired when the verify loop's state changes (drives the chrome top bar). */
48
+ onVerifyState?: (state: VerifyState) => void;
49
+ /** Set/clear the active workflow-mode label shown in the chrome top bar. */
50
+ setVerbLabel?: (verb: string | null) => void;
51
+ }
52
+
53
+ /** Verbs that put the session into a visible "mode" (shown in the chrome top bar). */
54
+ const MODE_VERBS: readonly string[] = ["execute", "plan", "discuss", "resume"];
55
+
56
+ /**
57
+ * execute/plan instructions tell the model to launch a `subagent(...)`. That
58
+ * tool is provided by the external pi-subagents plugin. When it's absent the
59
+ * instruction is impossible, so prepend an authoritative override telling the
60
+ * model to do the work inline instead — removing the silent breakage until
61
+ * soly ships first-party delegation.
62
+ */
63
+ export function withSubagentPreflight(text: string, activeTools: string[]): string {
64
+ if (activeTools.includes("subagent")) return text;
65
+ return (
66
+ "> ⚠ The `subagent` tool is NOT installed in this session. Ignore any " +
67
+ '"launch a subagent / do not work inline" instruction below — instead do this ' +
68
+ "work yourself, inline, in THIS session, applying the same close-out discipline. " +
69
+ "(Install pi-subagents for delegated/parallel execution.)\n\n" +
70
+ text
71
+ );
42
72
  }
43
73
 
44
74
  export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
45
75
  const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed } = deps;
46
- // The current agent is owned by the separate `pi-switch` extension.
47
- // It writes `globalThis.__PI_SWITCH_AGENT__` (in-process) and
48
- // `.soly/agent` (persisted). We read the in-process value first (fresh);
49
- // fall back to "worker" if pi-switch isn't installed.
50
- const getCurrentAgent = (): string => {
51
- return (globalThis as { __PI_SWITCH_AGENT__?: string }).__PI_SWITCH_AGENT__ ?? "worker";
52
- };
53
76
 
77
+ // Self-review loop ("soly verify"). Owns its own agent_end + input hooks;
78
+ // fresh-context mode rewrites the next LLM call through the context manager.
79
+ const verifyLoop = createVerifyLoop(pi, {
80
+ contextManager: deps.contextManager,
81
+ getConfig: () => getConfig().verify,
82
+ onState: (state) => deps.onVerifyState?.(state),
83
+ });
54
84
  // Track whether we need to fire ctx.compact() at the end of the upcoming
55
85
  // turn. Reset on every user input — only set if the user types
56
86
  // "soly compact" (which expands to a handoff + compact request).
@@ -65,9 +95,15 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
65
95
  if (event.source !== "interactive") return;
66
96
  if (event.text.trim().startsWith("/")) return;
67
97
 
98
+ // Any interactive input ends the previous workflow mode; a mode verb
99
+ // below re-sets it. Verify manages its own label via onVerifyState.
100
+ deps.setVerbLabel?.(null);
101
+
68
102
  const cmd = parseSolyCommand(event.text);
69
103
  if (!cmd) return;
70
104
 
105
+ if (MODE_VERBS.includes(cmd.verb)) deps.setVerbLabel?.(cmd.verb);
106
+
71
107
  // Notify the parent extension that a soly verb was used
72
108
  // (resets the drift counter, etc.). Fires for BOTH "handled"
73
109
  // and "transform" actions — both are real workflow usage.
@@ -78,11 +114,9 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
78
114
  // ----- LLM-driven transforms -----
79
115
 
80
116
  if (cmd.verb === "execute") {
81
- const result = buildExecuteTransform(cmd, state, getInteractiveRules(), {
82
- useSolyWorker: getConfig().agent.useSolyWorkerSubagents,
83
- });
117
+ const result = buildExecuteTransform(cmd, state, getInteractiveRules());
84
118
  if (!result.handled || !result.transformedText) return;
85
- return { action: "transform", text: result.transformedText };
119
+ return { action: "transform", text: withSubagentPreflight(result.transformedText, getActiveTools()) };
86
120
  }
87
121
 
88
122
  if (cmd.verb === "pause" || cmd.verb === "compact") {
@@ -101,7 +135,7 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
101
135
  if (cmd.verb === "plan") {
102
136
  const result = buildPlanTransform(cmd, state);
103
137
  if (!result.handled || !result.transformedText) return;
104
- return { action: "transform", text: result.transformedText };
138
+ return { action: "transform", text: withSubagentPreflight(result.transformedText, getActiveTools()) };
105
139
  }
106
140
 
107
141
  if (cmd.verb === "discuss") {
@@ -111,41 +145,54 @@ export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
111
145
  return { action: "transform", text: result.transformedText };
112
146
  }
113
147
 
148
+ if (cmd.verb === "migrate") {
149
+ const result = buildMigrateTransform(state);
150
+ if (!result.handled || !result.transformedText) return;
151
+ return { action: "transform", text: result.transformedText };
152
+ }
153
+
154
+ if (cmd.verb === "verify") {
155
+ const sub = (cmd.args[0] ?? "").toLowerCase();
156
+ if (sub === "stop" || sub === "off") {
157
+ verifyLoop.stop(ctx, "stopped");
158
+ } else {
159
+ const maxArg = cmd.args.find((a) => /^\d+$/.test(a));
160
+ const fresh = cmd.args.some((a) => a === "fresh" || a === "--fresh");
161
+ verifyLoop.start(ctx, { max: maxArg ? parseInt(maxArg, 10) : undefined, fresh: fresh || undefined });
162
+ }
163
+ return { action: "handled" };
164
+ }
165
+
114
166
  if (cmd.verb === "help") {
115
167
  return {
116
168
  action: "transform",
117
- text: `soly subcommand picker (esc to cancel).
118
-
119
- Available verbs (all start with \`soly <verb>\` or use \`/soly <verb>\` in slash form):
120
-
121
- position one-screen current position summary
122
- state full STATE.md body
123
- plan current PLAN.md body
124
- context current phase CONTEXT.md
125
- research current phase RESEARCH.md
126
- roadmap — ROADMAP.md body
127
- progress — progress bar + counts
128
- phases list all phases with C/R markers
129
- tasks list all tasks grouped by feature (new in v2)
130
- task <id> show one task's PLAN + SUMMARY
131
- features list all features (new in v2)
132
- milestoneshow the active milestone document
133
- log [N] last N (default 20) decisions from STATE.md
134
- diff — git status + uncommitted .soly/ changes
135
- doctor — health check: missing files, broken refs, stale iterations
136
- iterations [N] — list recent iteration files
137
- todos show pi-todo live list (.soly/todos.json or .pi-todos.json)
138
- phase delete <N> — soft-delete a phase
139
- reload — re-read project state from disk
140
- plan <N> — produce PLAN.md for phase N
141
- discuss <N> — interactive discussion of phase N
142
- execute <N> — execute all plans in phase N (or \`execute N.MM\` for one plan)
143
- pause — write HANDOFF.json + .continue-here.md
144
- compact — pause + auto-compact session
145
- resume [N] — restore from handoff (scoped to phase N if given)
146
- help — this picker
147
-
148
- Unknown / missing verb? Use \`/soly\` (slash) for the picker.`,
169
+ text: `soly workflow verbs (type \`soly <verb>\`):
170
+
171
+ Lifecycle:
172
+ discuss <N> — talk through phase N's decisions (interactive)
173
+ plan <N> produce PLAN.md for phase N
174
+ execute <N|N.MM> execute a phase or one plan (also <task-id>, --all, --feature)
175
+ verify [N] [fresh] self-review loop until "no issues" (max N; \`verify stop\` to exit)
176
+ pause / compact write handoff (compact also compresses the session)
177
+ resume [N] restore from handoff (scoped to phase N if given)
178
+
179
+ Quick info (no LLM round-trip):
180
+ status position + progress + phases
181
+ log [N] last N decisions from STATE.md
182
+ diff git status + uncommitted .soly/ changes
183
+ doctor health check (missing files, broken refs, stale iterations)
184
+ iterations [N]recent iteration bundles
185
+ todos pi-todo live list
186
+ phase delete <N> — soft-delete a phase
187
+
188
+ Maintenance:
189
+ migrate convert a legacy layout (NN-PLAN files / features/) to phases/<N>/tasks/
190
+
191
+ State inspection lives on the slash form — \`/soly <sub>\`:
192
+ position · state · plan · roadmap · progress · phases · tasks · task <id> ·
193
+ features · milestone · context · research · config · reload
194
+
195
+ (\`/soly\` with no argument opens the interactive picker.)`,
149
196
  };
150
197
  }
151
198