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,258 @@
1
+ // =============================================================================
2
+ // workflows/quick.ts — Direct-response workflow handlers
3
+ // =============================================================================
4
+ //
5
+ // These verbs (status, log, diff) return immediate results without an LLM
6
+ // round-trip. The extension reads disk / runs git, formats a notification,
7
+ // and the LLM never sees the request.
8
+ //
9
+ // Why: these are common, low-judgment operations where the LLM would just
10
+ // regurgitate what the extension can compute more accurately and faster.
11
+ // =============================================================================
12
+
13
+ import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { readIfExists, buildProgressBar, type SolyState } from "../core.js";
18
+ import type { SolyConfig } from "../config.js";
19
+ import type { SolyCommand } from "./parser.js";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ /** Minimum UI surface needed for the quick handlers. */
24
+ interface QuickUI {
25
+ notify: (text: string, kind?: "info" | "warning" | "error") => void;
26
+ }
27
+
28
+ // =============================================================================
29
+ // soly status
30
+ // =============================================================================
31
+
32
+ export function showStatus(
33
+ _cmd: SolyCommand,
34
+ state: SolyState,
35
+ ui: QuickUI,
36
+ config?: SolyConfig,
37
+ ): void {
38
+ if (!state.exists) {
39
+ ui.notify("soly: no .soly/ directory in cwd", "error");
40
+ return;
41
+ }
42
+ const maxPhases = config?.display.maxPhasesInStatus ?? 20;
43
+
44
+ const lines: string[] = [];
45
+ lines.push("=== soly status ===");
46
+ lines.push("");
47
+ lines.push(
48
+ `milestone: ${state.milestone}${state.milestoneName ? ` — ${state.milestoneName}` : ""}`,
49
+ );
50
+ lines.push(`status: ${state.status}`);
51
+ if (state.lastUpdated) lines.push(`updated: ${state.lastUpdated}`);
52
+ lines.push("");
53
+
54
+ if (state.position) {
55
+ lines.push(`position:`);
56
+ lines.push(` phase: ${state.position.phase}`);
57
+ lines.push(` plan: ${state.position.plan}`);
58
+ lines.push(` status: ${state.position.status}`);
59
+ } else {
60
+ lines.push("position: (none — run `soly plan <N>` to start a phase)");
61
+ }
62
+ lines.push("");
63
+
64
+ lines.push(
65
+ `progress: ${buildProgressBar(state.progress.percent, 30)} ${state.progress.percent}%`,
66
+ );
67
+ lines.push(` ${state.progress.completedPhases}/${state.progress.totalPhases} phases, ${state.progress.completedPlans}/${state.progress.totalPlans} plans`);
68
+ lines.push("");
69
+
70
+ if (state.phases.length > 0) {
71
+ lines.push("phases:");
72
+ const current = state.currentPhase?.number;
73
+ for (const p of state.phases.slice(0, maxPhases)) {
74
+ const marker = current === p.number ? "→" : " ";
75
+ const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
76
+ lines.push(
77
+ ` ${marker} ${String(p.number).padStart(2, "0")}. ${p.name.padEnd(28)} [${cr}] plans=${p.planCount}`,
78
+ );
79
+ }
80
+ if (state.phases.length > maxPhases) {
81
+ lines.push(` ... and ${state.phases.length - maxPhases} more (use config.display.maxPhasesInStatus to show more)`);
82
+ }
83
+ }
84
+
85
+ // NEW (A7): recent activity — last 3 iteration files
86
+ const iterDir = path.join(state.solyDir, "iterations");
87
+ if (fs.existsSync(iterDir)) {
88
+ const files = fs.readdirSync(iterDir)
89
+ .filter((f) => f.endsWith(".md"))
90
+ .map((f) => ({ f, stat: fs.statSync(path.join(iterDir, f)) }))
91
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
92
+ .slice(0, 3);
93
+ if (files.length > 0) {
94
+ lines.push("");
95
+ lines.push("recent iterations:");
96
+ for (const { f, stat } of files) {
97
+ const ago = humanizeAge(Date.now() - stat.mtimeMs);
98
+ lines.push(` ${f} (${ago})`);
99
+ }
100
+ }
101
+ }
102
+
103
+ ui.notify(lines.join("\n"), "info");
104
+ }
105
+
106
+ // =============================================================================
107
+ // soly log
108
+ // =============================================================================
109
+
110
+ function humanizeAge(ms: number): string {
111
+ if (ms < 60_000) return "just now";
112
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
113
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
114
+ if (ms < 30 * 86_400_000) return `${Math.round(ms / 86_400_000)}d ago`;
115
+ return `${Math.round(ms / (30 * 86_400_000))}mo ago`;
116
+ }
117
+
118
+ const DECISIONS_HEADER = /^##\s*Decisions\s*$/;
119
+ const DECISIONS_TABLE_HEADER = /^\|\s*Decision\s*\|/;
120
+ const DECISIONS_TABLE_SEPARATOR = /^\|[\s\-:|]+\|$/;
121
+ const DECISIONS_TABLE_ROW = /^\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*$/;
122
+
123
+ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
124
+ if (!state.exists) {
125
+ ui.notify("soly log: no .soly/ directory in cwd", "error");
126
+ return;
127
+ }
128
+
129
+ const statePath = path.join(state.solyDir, "STATE.md");
130
+ const raw = readIfExists(statePath);
131
+ if (!raw) {
132
+ ui.notify("soly log: STATE.md not found", "error");
133
+ return;
134
+ }
135
+
136
+ const lines = raw.split(/\r?\n/);
137
+ const decisionsIdx = lines.findIndex((l) => DECISIONS_HEADER.test(l));
138
+ if (decisionsIdx === -1) {
139
+ ui.notify("soly log: no Decisions table in STATE.md", "info");
140
+ return;
141
+ }
142
+
143
+ // Collect rows from the table
144
+ const rows: Array<{ decision: string; rationale: string; phase: string }> = [];
145
+ for (let i = decisionsIdx + 1; i < lines.length; i++) {
146
+ const line = lines[i];
147
+ if (!line.startsWith("|")) break; // table ended
148
+ if (DECISIONS_TABLE_HEADER.test(line) || DECISIONS_TABLE_SEPARATOR.test(line)) continue;
149
+ const m = line.match(DECISIONS_TABLE_ROW);
150
+ if (m) rows.push({ decision: m[1].trim(), rationale: m[2].trim(), phase: m[3].trim() });
151
+ }
152
+
153
+ if (rows.length === 0) {
154
+ ui.notify("soly log: Decisions table is empty", "info");
155
+ return;
156
+ }
157
+
158
+ // Optional limit: `soly log 5` — last 5 decisions
159
+ const limitArg = cmd.args[0]?.trim();
160
+ if (limitArg) {
161
+ const parsed = parseInt(limitArg, 10);
162
+ if (!Number.isFinite(parsed) || parsed <= 0) {
163
+ ui.notify(
164
+ `soly log: invalid limit "${limitArg}" (must be a positive integer)`,
165
+ "error",
166
+ );
167
+ return;
168
+ }
169
+ }
170
+ const limit = limitArg ? parseInt(limitArg, 10) : 20;
171
+ const tail = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows.slice(-20);
172
+
173
+ const out: string[] = [];
174
+ out.push(`=== last ${tail.length} of ${rows.length} decisions ===`);
175
+ out.push("");
176
+ for (const r of tail) {
177
+ out.push(` [${r.phase}] ${r.decision}`);
178
+ out.push(` ${r.rationale}`);
179
+ out.push("");
180
+ }
181
+ ui.notify(out.join("\n"), "info");
182
+ }
183
+
184
+ // =============================================================================
185
+ // soly diff
186
+ // =============================================================================
187
+
188
+ interface DiffResult {
189
+ stdout: string;
190
+ stderr: string;
191
+ code: number;
192
+ }
193
+
194
+ async function safeExec(file: string, args: string[], cwd: string): Promise<DiffResult> {
195
+ try {
196
+ const { stdout, stderr } = await execFileAsync(file, args, {
197
+ cwd,
198
+ maxBuffer: 4 * 1024 * 1024,
199
+ encoding: "utf-8",
200
+ });
201
+ return { stdout, stderr, code: 0 };
202
+ } catch (e) {
203
+ const err = e as { stdout?: string; stderr?: string; code?: number };
204
+ return {
205
+ stdout: err.stdout ?? "",
206
+ stderr: err.stderr ?? "",
207
+ code: typeof err.code === "number" ? err.code : 1,
208
+ };
209
+ }
210
+ }
211
+
212
+ export async function showDiff(
213
+ _cmd: SolyCommand,
214
+ state: SolyState,
215
+ ui: QuickUI,
216
+ ): Promise<void> {
217
+ // Graceful without .soly/: use cwd as project root, skip the .soly/ filter
218
+ const projectRoot = state.solyDir ? path.dirname(state.solyDir) : process.cwd();
219
+ const solyDir = state.solyDir; // may be empty when run outside a soly project
220
+
221
+ // 1. git status (short)
222
+ const status = await safeExec("git", ["status", "--short", "--branch"], projectRoot);
223
+ // 2. git diff (tracked changes, no untracked)
224
+ const diff = await safeExec("git", ["diff", "--stat"], projectRoot);
225
+ // 3. uncommitted .soly/ file changes (since last commit)
226
+ const solyChanges = await safeExec(
227
+ "git",
228
+ ["status", "--short", "--", solyDir],
229
+ projectRoot,
230
+ );
231
+
232
+ const out: string[] = [];
233
+ out.push(state.exists ? "=== soly diff ===" : "=== git diff (no .soly/ in cwd) ===");
234
+ out.push("");
235
+
236
+ if (status.code !== 0) {
237
+ out.push("(git not available or not a git repo)");
238
+ } else {
239
+ out.push("git status --short --branch:");
240
+ out.push(status.stdout.trim() || " (clean working tree)");
241
+ out.push("");
242
+ if (diff.stdout.trim()) {
243
+ out.push("git diff --stat:");
244
+ out.push(diff.stdout.trim());
245
+ out.push("");
246
+ }
247
+ if (solyDir) {
248
+ if (solyChanges.stdout.trim()) {
249
+ out.push("uncommitted .soly/ changes:");
250
+ out.push(solyChanges.stdout.trim());
251
+ } else {
252
+ out.push("uncommitted .soly/ changes: (none)");
253
+ }
254
+ }
255
+ }
256
+
257
+ ui.notify(out.join("\n"), "info");
258
+ }
@@ -0,0 +1,201 @@
1
+ // =============================================================================
2
+ // workflows/resume.ts — `soly resume [phase]` handler
3
+ // =============================================================================
4
+ //
5
+ // Intercepts "soly resume" (or "soly resume <N>" to scope to a specific phase)
6
+ // and transforms it into a kickoff prompt that re-establishes the full
7
+ // session context from the last handoff.
8
+ //
9
+ // Reads:
10
+ // - .soly/HANDOFF.json (machine-readable state, written by pause/compact)
11
+ // - .soly/.continue-here.md (human-readable context, written by pause/compact)
12
+ //
13
+ // If neither file exists, falls back to a "no prior handoff" message that
14
+ // tells the LLM to load context from .soly/STATE.md + ROADMAP.md normally.
15
+ // =============================================================================
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import type { SolyCommand } from "./parser.js";
20
+ import type { SolyState } from "../core.js";
21
+ import { readIfExists } from "../core.js";
22
+
23
+ interface HandoffJson {
24
+ schema_version?: string;
25
+ generated_at?: string;
26
+ milestone?: string;
27
+ milestone_name?: string;
28
+ status?: string;
29
+ position?: { phase?: string; plan?: string; status?: string } | null;
30
+ current_phase?: {
31
+ number?: number;
32
+ name?: string;
33
+ slug?: string;
34
+ dir?: string;
35
+ plan_count?: number;
36
+ } | null;
37
+ progress?: {
38
+ total_phases?: number;
39
+ completed_phases?: number;
40
+ total_plans?: number;
41
+ completed_plans?: number;
42
+ percent?: number;
43
+ };
44
+ work_completed?: string[];
45
+ work_remaining?: string[];
46
+ decisions?: string[];
47
+ blockers?: string[];
48
+ human_actions_pending?: string[];
49
+ }
50
+
51
+ export interface ResumeHandlerResult {
52
+ handled: boolean;
53
+ transformedText?: string;
54
+ }
55
+
56
+ function parseHandoff(solyDir: string): HandoffJson | null {
57
+ const handoffPath = path.join(solyDir, "HANDOFF.json");
58
+ const raw = readIfExists(handoffPath);
59
+ if (!raw) return null;
60
+ try {
61
+ const parsed = JSON.parse(raw);
62
+ return parsed as HandoffJson;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function formatListSection(label: string, items: string[] | undefined): string {
69
+ if (!items || items.length === 0) return ` (none)\n`;
70
+ return ` - ${items.join("\n - ")}\n`;
71
+ }
72
+
73
+ function formatHandoffSummary(h: HandoffJson): string {
74
+ const lines: string[] = [];
75
+
76
+ lines.push("=== From .soly/HANDOFF.json ===");
77
+ if (h.generated_at) lines.push(` generated_at: ${h.generated_at}`);
78
+ if (h.milestone) lines.push(` milestone: ${h.milestone}${h.milestone_name ? ` — ${h.milestone_name}` : ""}`);
79
+ if (h.status) lines.push(` status: ${h.status}`);
80
+
81
+ if (h.position) {
82
+ lines.push(` position: phase=${h.position.phase ?? "?"}, plan=${h.position.plan ?? "?"}, status=${h.position.status ?? "?"}`);
83
+ }
84
+ if (h.current_phase) {
85
+ lines.push(
86
+ ` current_phase: #${h.current_phase.number ?? "?"} ${h.current_phase.name ?? ""} (${h.current_phase.plan_count ?? 0} plans)`,
87
+ );
88
+ }
89
+ if (h.progress) {
90
+ lines.push(
91
+ ` progress: ${h.progress.completed_phases ?? 0}/${h.progress.total_phases ?? 0} phases, ${h.progress.completed_plans ?? 0}/${h.progress.total_plans ?? 0} plans, ${h.progress.percent ?? 0}%`,
92
+ );
93
+ }
94
+
95
+ lines.push("");
96
+ lines.push("Work completed (from last session):");
97
+ lines.push(formatListSection("", h.work_completed));
98
+ lines.push("Work remaining:");
99
+ lines.push(formatListSection("", h.work_remaining));
100
+ lines.push("Decisions logged:");
101
+ lines.push(formatListSection("", h.decisions));
102
+ lines.push("Blockers / open questions:");
103
+ lines.push(formatListSection("", h.blockers));
104
+ lines.push("Human actions pending:");
105
+ lines.push(formatListSection("", h.human_actions_pending));
106
+
107
+ return lines.join("\n");
108
+ }
109
+
110
+ export function buildResumeTransform(cmd: SolyCommand, state: SolyState): ResumeHandlerResult {
111
+ if (!state.exists) {
112
+ return {
113
+ handled: true,
114
+ transformedText:
115
+ `soly resume: no .soly/ directory in cwd (${state.solyDir || "<cwd>"}) — nothing to resume.\n` +
116
+ `Initialize a soly project first, or run \`soly pause\` later to create handoff files.`,
117
+ };
118
+ }
119
+
120
+ const handoff = parseHandoff(state.solyDir);
121
+ const continueMd = readIfExists(path.join(state.solyDir, ".continue-here.md"));
122
+
123
+ // Optional phase scope: "soly resume 11" → focus kickoff on phase 11
124
+ const phaseArg = cmd.args[0]?.trim();
125
+ let phaseFilter: number | null = null;
126
+ let phaseFilterInvalid = false;
127
+ if (phaseArg) {
128
+ const m = phaseArg.match(/^(\d+)$/);
129
+ if (m) {
130
+ phaseFilter = parseInt(m[1], 10);
131
+ // Validate that the phase actually exists
132
+ if (state.phases.length > 0 && !state.phases.find((p) => p.number === phaseFilter)) {
133
+ phaseFilterInvalid = true;
134
+ }
135
+ } else {
136
+ phaseFilterInvalid = true;
137
+ }
138
+ }
139
+ if (phaseFilterInvalid) {
140
+ const known = state.phases.map((p) => p.number).join(", ") || "(none)";
141
+ const argStr = cmd.args[0] ?? "";
142
+ return {
143
+ handled: true,
144
+ transformedText:
145
+ `soly resume: invalid or unknown phase "${argStr}".\n` +
146
+ `Usage: soly resume [N] (e.g. "soly resume 5")\n` +
147
+ `Known phases: ${known}`,
148
+ };
149
+ }
150
+
151
+ if (!handoff && !continueMd) {
152
+ // No prior handoff — fall back to loading from .soly/STATE.md directly.
153
+ const fallbackScope = phaseFilter != null
154
+ ? `Focus: phase ${phaseFilter}.`
155
+ : "Scope: full project.";
156
+ return {
157
+ handled: true,
158
+ transformedText:
159
+ `soly resume: no handoff files found (looked for .soly/HANDOFF.json and .soly/.continue-here.md).\n` +
160
+ `No prior \`soly pause\` was run — loading context from .soly/STATE.md and .soly/ROADMAP.md directly.\n\n` +
161
+ `${fallbackScope}\n\n` +
162
+ `Read .soly/STATE.md (Current Position, Decisions, Blockers sections) and .soly/ROADMAP.md.\n` +
163
+ `Summarize: where the project is, what's next, what's blocking. Then ask the user what to focus on first.`,
164
+ };
165
+ }
166
+
167
+ const handoffBlock = handoff ? formatHandoffSummary(handoff) : "(no HANDOFF.json found)";
168
+ const continueBlock = continueMd
169
+ ? `\n=== From .soly/.continue-here.md ===\n${continueMd.trim()}\n`
170
+ : "";
171
+
172
+ const focusLine = phaseFilter != null
173
+ ? `Focus: phase ${phaseFilter} only.`
174
+ : "Focus: pick up exactly where the last session left off.";
175
+
176
+ const instruction = `soly resume — restoring session context from last handoff.
177
+
178
+ ${focusLine}
179
+
180
+ ${handoffBlock}
181
+ ${continueBlock}
182
+
183
+ === Resume protocol ===
184
+
185
+ 1. **Read .soly/docs/ first** — the project's 0-point intent. Pickup is meaningless without knowing what the user is building toward.
186
+ 2. Read .soly/STATE.md to confirm current position (the handoff may be stale).
187
+ 3. Read .soly/ROADMAP.md and any CONTEXT.md / RESEARCH.md for the active phase.
188
+ 4. Compare handoff's "work remaining" with the actual repo state (git status, recent commits, .soly/ files).
189
+ 5. Produce a one-screen "Where we are" summary:
190
+ - current phase + plan
191
+ - what's actually been done (verified via filesystem / git)
192
+ - what's still pending
193
+ - any new blockers discovered since handoff
194
+ 6. Surface any stale handoff data — if the handoff says "remaining: X" but the repo shows X is done, say so.
195
+ 7. Ask the user: "Pick up from <next concrete step> — confirm or change?" before doing any work.
196
+
197
+ Do NOT start coding. Resume is about restoring shared understanding, not action.
198
+ `;
199
+
200
+ return { handled: true, transformedText: instruction };
201
+ }