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.
- package/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
package/visual/topbar.ts
ADDED
|
@@ -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
|
+
}
|
package/workflows/execute.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function buildExecuteTransform(
|
|
|
67
67
|
cmd: SolyCommand,
|
|
68
68
|
state: SolyState,
|
|
69
69
|
interactiveRules: string[] = [],
|
|
70
|
-
opts: { agent?: string
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
}
|
package/workflows/index.ts
CHANGED
|
@@ -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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
plan
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|