pi-soly 0.2.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.
@@ -0,0 +1,268 @@
1
+ // =============================================================================
2
+ // workflows/parser.ts — Shared `soly <verb> <args>` parser
3
+ // =============================================================================
4
+ //
5
+ // Parses user input like "soly execute 11" or "soly pause" into a structured
6
+ // command that each workflow handler can dispatch on.
7
+ //
8
+ // Convention:
9
+ // - User types exactly "soly <verb> <args...>" (lowercase required for match)
10
+ // - The extension intercepts via the `input` event (no slash-command needed)
11
+ // - The handler transforms the input into a detailed LLM instruction that
12
+ // delegates to the `subagent(...)` tool (provided by pi-subagents)
13
+ //
14
+ // This module is pure parsing — no I/O, no extension state. Trivial to unit
15
+ // test in isolation.
16
+ // =============================================================================
17
+
18
+ /** Verbs currently supported by the workflow handlers. */
19
+ export type WorkflowVerb =
20
+ | "execute" | "pause" | "compact" | "resume" | "status" | "log" | "diff"
21
+ | "plan" | "discuss" | "help" | "doctor" | "iterations" | "phase" | "todos";
22
+
23
+ export interface SolyCommand {
24
+ verb: WorkflowVerb;
25
+ args: string[];
26
+ /** Original input, for logging/debugging. */
27
+ raw: string;
28
+ }
29
+
30
+ /**
31
+ * Try to parse `text` as a `soly <verb> <args>` command.
32
+ * Returns null if the text doesn't match the convention.
33
+ *
34
+ * Whitespace is normalized; case is preserved for args but verb is matched
35
+ * case-insensitively to be friendly.
36
+ */
37
+ export function parseSolyCommand(text: string): SolyCommand | null {
38
+ const trimmed = text.trim();
39
+ if (!trimmed) return null;
40
+ // Reject the slash-command form ("/soly ...") — that's pi's territory;
41
+ // we want plain "soly ..." text input only.
42
+ if (trimmed.startsWith("/")) return null;
43
+ const lower = trimmed.toLowerCase();
44
+ // Plain "soly" (no verb) → "help" picker
45
+ if (lower === "soly" || lower === "soly ") {
46
+ return { verb: "help", args: [], raw: trimmed };
47
+ }
48
+ // Case-insensitive `soly` prefix (so "SOLY Execute 11" matches).
49
+ if (!lower.startsWith("soly ")) return null;
50
+
51
+ const tokens = trimmed.split(/\s+/);
52
+ // tokens[0] === "soly"
53
+ const verbRaw = (tokens[1] ?? "").toLowerCase();
54
+ const verb = verbRaw as WorkflowVerb;
55
+ if (
56
+ verb !== "execute" &&
57
+ verb !== "pause" &&
58
+ verb !== "compact" &&
59
+ verb !== "resume" &&
60
+ verb !== "status" &&
61
+ verb !== "log" &&
62
+ verb !== "diff" &&
63
+ verb !== "plan" &&
64
+ verb !== "discuss" &&
65
+ verb !== "help" &&
66
+ verb !== "doctor" &&
67
+ verb !== "iterations" &&
68
+ verb !== "phase" &&
69
+ verb !== "todos"
70
+ ) {
71
+ return null;
72
+ }
73
+
74
+ return {
75
+ verb,
76
+ args: tokens.slice(2),
77
+ raw: trimmed,
78
+ };
79
+ }
80
+
81
+ // =============================================================================
82
+ // Shared target-parsing helpers
83
+ // =============================================================================
84
+ //
85
+ // These are used by both `describeExecuteTarget` and `describePlanTarget` to
86
+ // avoid duplicating the regex / flag-extraction logic. Pure functions, no
87
+ // I/O, no extension state.
88
+ // =============================================================================
89
+
90
+ /** Shape of an already-tokenized `soly <verb> ...` args list. */
91
+ interface ArgsShape {
92
+ raw: string;
93
+ flags: string[];
94
+ positional: string;
95
+ }
96
+
97
+ function parseArgsShape(args: string[]): ArgsShape {
98
+ const raw = args.join(" ").trim();
99
+ if (!raw) return { raw: "", flags: [], positional: "" };
100
+ return {
101
+ raw,
102
+ flags: args.filter((a) => a.startsWith("--")),
103
+ positional: args.find((a) => !a.startsWith("--")) ?? "",
104
+ };
105
+ }
106
+
107
+ /** Match `<N>` or `<N.MM>` (phase / plan). Returns null if not a phase shape. */
108
+ function parsePhaseShape(s: string): { phase: number; plan: number | null } | null {
109
+ const m = s.match(/^(\d+)(?:\.(\d+))?$/);
110
+ if (!m) return null;
111
+ return {
112
+ phase: parseInt(m[1], 10),
113
+ plan: m[2] != null ? parseInt(m[2], 10) : null,
114
+ };
115
+ }
116
+
117
+ /** Match `<N>` only — for `soly plan`, which never has a plan sub-index. */
118
+ function parsePhaseOnlyShape(s: string): { phase: number } | null {
119
+ const m = s.match(/^(\d+)$/);
120
+ if (!m) return null;
121
+ return { phase: parseInt(m[1], 10) };
122
+ }
123
+
124
+ /** Match task-id `<slug>-<4hex>`, case-insensitive. */
125
+ function parseTaskIdShape(s: string): string | null {
126
+ return s.match(/^[a-z0-9][a-z0-9-]*-[a-f0-9]{4}$/i) ? s : null;
127
+ }
128
+
129
+ /** Extract `--feature <name>` from args. Returns null if not present or invalid. */
130
+ function parseFeatureFlag(args: string[]): string | null {
131
+ const idx = args.indexOf("--feature");
132
+ if (idx === -1) return null;
133
+ const feature = args[idx + 1];
134
+ return feature && !feature.startsWith("--") ? feature : null;
135
+ }
136
+
137
+ /** Extract `--new-task <slug>` together with `--feature <name>`. */
138
+ function parseNewTaskFlag(
139
+ args: string[],
140
+ ): { slug: string; feature: string } | null {
141
+ const idx = args.indexOf("--new-task");
142
+ const feature = parseFeatureFlag(args);
143
+ if (idx === -1 || !feature) return null;
144
+ const slug = args[idx + 1];
145
+ if (!slug || slug.startsWith("--")) return null;
146
+ return { slug, feature };
147
+ }
148
+
149
+ // =============================================================================
150
+ // execute target
151
+ // =============================================================================
152
+
153
+ /**
154
+ * What `soly execute ...` should target. Dual-mode: phases and tasks
155
+ * live side by side.
156
+ */
157
+ export type ExecuteTarget =
158
+ | { kind: "phase"; phase: number; plan: number | null; raw: string }
159
+ | { kind: "task"; taskId: string; raw: string }
160
+ | { kind: "all"; raw: string }
161
+ | { kind: "feature"; feature: string; raw: string };
162
+
163
+ /**
164
+ * Parse `soly execute <args>` into a structured target.
165
+ *
166
+ * Recognized forms:
167
+ * <N> — execute all plans in phase N
168
+ * <N.MM> — execute a specific plan
169
+ * <task-id> — execute a specific task (slug-hash, e.g. auth-be-login-a3f9)
170
+ * --all — execute all ready tasks (sequential in v0.1)
171
+ * --feature <n> — execute all tasks in a feature (sequential in v0.1)
172
+ *
173
+ * Returns null when args are missing or malformed.
174
+ */
175
+ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
176
+ const { raw, flags, positional } = parseArgsShape(args);
177
+ if (!raw) return null;
178
+
179
+ // --all / --all-ready
180
+ if (flags.includes("--all") || flags.includes("--all-ready")) {
181
+ return { kind: "all", raw };
182
+ }
183
+
184
+ // --feature <name>
185
+ const feature = parseFeatureFlag(args);
186
+ if (feature) {
187
+ return { kind: "feature", feature, raw };
188
+ }
189
+
190
+ const target = positional.trim();
191
+ if (!target) return null;
192
+
193
+ const phase = parsePhaseShape(target);
194
+ if (phase) {
195
+ return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
196
+ }
197
+
198
+ const taskId = parseTaskIdShape(target);
199
+ if (taskId) {
200
+ return { kind: "task", taskId, raw };
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ // =============================================================================
207
+ // plan target
208
+ // =============================================================================
209
+
210
+ /**
211
+ * What `soly plan ...` should target. Dual-mode with execute.
212
+ *
213
+ * phase — plan a phase
214
+ * task — plan (write/flesh out PLAN.md for) an existing task
215
+ * new-task — create a brand-new task dir + PLAN.md (with frontmatter)
216
+ * feature — plan all ready tasks in a feature
217
+ */
218
+ export type PlanTarget =
219
+ | { kind: "phase"; phase: number; raw: string }
220
+ | { kind: "task"; taskId: string; raw: string }
221
+ | { kind: "new-task"; slug: string; feature: string; raw: string }
222
+ | { kind: "feature"; feature: string; raw: string };
223
+
224
+ /**
225
+ * Parse `soly plan <args>` into a structured target.
226
+ *
227
+ * Recognized forms:
228
+ * <N> — plan phase N
229
+ * <task-id> — plan existing task (PLAN.md already exists, flesh it out)
230
+ * --new-task <slug> --feature <n> — create new task dir + PLAN.md skeleton
231
+ * --feature <n> — plan all ready tasks in a feature
232
+ *
233
+ * Returns null when args are missing or malformed.
234
+ */
235
+ export function describePlanTarget(args: string[]): PlanTarget | null {
236
+ const { raw, positional } = parseArgsShape(args);
237
+ if (!raw) return null;
238
+
239
+ // --new-task <slug> --feature <name> (order-independent)
240
+ const newTask = parseNewTaskFlag(args);
241
+ if (newTask) {
242
+ return { kind: "new-task", slug: newTask.slug, feature: newTask.feature, raw };
243
+ }
244
+
245
+ // --feature <name> (only when it's the only flag — disambiguate from
246
+ // the new-task case above where --feature is also present)
247
+ const feature = parseFeatureFlag(args);
248
+ if (feature) {
249
+ return { kind: "feature", feature, raw };
250
+ }
251
+
252
+ const target = positional.trim();
253
+ if (!target) return null;
254
+
255
+ // Plan target only matches plain N (no .MM — plan is per-phase, executed
256
+ // at the phase level by `soly execute <N.MM>`).
257
+ const phase = parsePhaseOnlyShape(target);
258
+ if (phase) {
259
+ return { kind: "phase", phase: phase.phase, raw };
260
+ }
261
+
262
+ const taskId = parseTaskIdShape(target);
263
+ if (taskId) {
264
+ return { kind: "task", taskId, raw };
265
+ }
266
+
267
+ return null;
268
+ }
@@ -0,0 +1,150 @@
1
+ // =============================================================================
2
+ // workflows/pause.ts — `soly pause` / `soly compact` handler
3
+ // =============================================================================
4
+ //
5
+ // Intercepts "soly pause" (just write handoff) and "soly compact" (write
6
+ // handoff AND trigger session compaction).
7
+ //
8
+ // Like execute, we transform the input into a detailed LLM instruction that
9
+ // walks the LLM through the soly pause-work workflow. The LLM produces both:
10
+ // - .soly/HANDOFF.json (machine-readable state for resume)
11
+ // - .soly/.continue-here.md (human-readable context)
12
+ //
13
+ // For `compact`, we additionally call ctx.compact() AFTER the handoff files
14
+ // are written — but we still let the LLM drive the handoff generation, since
15
+ // the work-done/work-remaining/decisions/blockers content requires reading
16
+ // the current session context.
17
+ // =============================================================================
18
+
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import type { SolyCommand } from "./parser.js";
23
+ import type { SolyState } from "../core.js";
24
+
25
+ /** Resolve <extension>/workflows-data/<name>.md regardless of cwd. */
26
+ function loadWorkflowMarkdown(name: string): string | null {
27
+ try {
28
+ const here = path.dirname(fileURLToPath(import.meta.url));
29
+ const candidate = path.resolve(here, "..", "workflows-data", name);
30
+ if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf-8");
31
+ } catch {
32
+ // fall through
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export interface PauseHandlerResult {
38
+ handled: boolean;
39
+ transformedText?: string;
40
+ /** Whether the extension should also call ctx.compact() after the handoff. */
41
+ triggerCompact: boolean;
42
+ }
43
+
44
+ /** Build HANDOFF.json scaffold from current state — the LLM fills in details. */
45
+ function handoffScaffold(state: SolyState): string {
46
+ const projectRoot = path.dirname(state.solyDir);
47
+ const position = state.position;
48
+ const phase = state.currentPhase;
49
+
50
+ return JSON.stringify(
51
+ {
52
+ schema_version: "1.0",
53
+ generated_by: "soly extension",
54
+ generated_at: new Date().toISOString(),
55
+ project_root: projectRoot,
56
+ soly_dir: state.solyDir,
57
+ milestone: state.milestone,
58
+ milestone_name: state.milestoneName,
59
+ status: state.status,
60
+ position: position
61
+ ? {
62
+ phase: position.phase,
63
+ plan: position.plan,
64
+ status: position.status,
65
+ }
66
+ : null,
67
+ current_phase: phase
68
+ ? {
69
+ number: phase.number,
70
+ name: phase.name,
71
+ slug: phase.slug,
72
+ dir: phase.dir,
73
+ plan_count: phase.planCount,
74
+ }
75
+ : null,
76
+ progress: state.progress,
77
+ work_completed: [], // LLM fills in
78
+ work_remaining: [], // LLM fills in
79
+ decisions: [], // LLM fills in (or read from STATE.md Decisions table)
80
+ blockers: [], // LLM fills in
81
+ human_actions_pending: [], // LLM fills in
82
+ resume_command: "soly resume", // not yet implemented; documented
83
+ },
84
+ null,
85
+ 2,
86
+ );
87
+ }
88
+
89
+ export function buildPauseTransform(
90
+ cmd: SolyCommand,
91
+ state: SolyState,
92
+ ): PauseHandlerResult {
93
+ if (!state.exists) {
94
+ return {
95
+ handled: true,
96
+ transformedText:
97
+ `soly: no .soly/ directory found in cwd (${state.solyDir || "<cwd>"}) — nothing to pause.\n` +
98
+ `If you wanted to start a soly project, see the soly quickstart.`,
99
+ triggerCompact: false,
100
+ };
101
+ }
102
+
103
+ const isCompact = cmd.verb === "compact";
104
+ const workflow = loadWorkflowMarkdown("pause-work.md");
105
+ if (!workflow) {
106
+ return {
107
+ handled: true,
108
+ transformedText:
109
+ `soly: pause-work workflow markdown not found: workflows-data/pause-work.md\n` +
110
+ `This is an extension installation issue — reinstall soly.`,
111
+ triggerCompact: false,
112
+ };
113
+ }
114
+
115
+ const scaffold = handoffScaffold(state);
116
+
117
+ const actionLine = isCompact
118
+ ? `Action: PAUSE + COMPACT the session after handoff files are written.`
119
+ : `Action: PAUSE only. Do not call ctx.compact() — the user wants to keep the session as-is.`;
120
+
121
+ const instruction = `soly ${cmd.verb} — preparing handoff for resume.
122
+
123
+ ${actionLine}
124
+
125
+ Current position (from .soly/STATE.md):
126
+ ${state.position
127
+ ? ` phase: ${state.position.phase}\n plan: ${state.position.plan}\n status: ${state.position.status}`
128
+ : " (no position set — likely pre-planning or paused at a milestone boundary)"}
129
+
130
+ Use this HANDOFF.json scaffold as your starting point (you'll fill in the work_*, decisions, blockers, human_actions_pending arrays from the current session context):
131
+
132
+ \`\`\`json
133
+ ${scaffold}
134
+ \`\`\`
135
+
136
+ Follow the workflow below VERBATIM — these are the user-approved soly instructions, not suggestions.
137
+
138
+ === WORKFLOW: pause-work.md ===
139
+ ${workflow}
140
+ === END WORKFLOW ===
141
+
142
+ After you write both .soly/HANDOFF.json and .soly/.continue-here.md, tell the user:
143
+ - where the files were written (absolute paths)
144
+ - the resume command: soly resume
145
+ - ${isCompact ? "session will be compacted at end of this turn" : "session state preserved as-is"}
146
+
147
+ ${isCompact ? `IMPORTANT: the extension will call ctx.compact() for you at the end of this turn. Do NOT call it yourself — your job is just to produce the handoff files. The compaction will happen automatically once this turn completes.` : ""}`;
148
+
149
+ return { handled: true, transformedText: instruction, triggerCompact: isCompact };
150
+ }