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.
- package/README.md +372 -0
- package/agents/soly-debugger.md +60 -0
- package/agents/soly-documenter.md +82 -0
- package/agents/soly-oracle.md +69 -0
- package/agents/soly-refactor.md +65 -0
- package/agents/soly-reviewer.md +107 -0
- package/agents/soly-tester.md +56 -0
- package/agents/soly-worker.md +84 -0
- package/agents-install.ts +105 -0
- package/commands.ts +778 -0
- package/config.ts +228 -0
- package/core.ts +1599 -0
- package/docs.ts +235 -0
- package/env.ts +196 -0
- package/git.ts +95 -0
- package/html.ts +157 -0
- package/index.ts +718 -0
- package/integrations.ts +64 -0
- package/intent.ts +303 -0
- package/iteration.ts +712 -0
- package/nudge.ts +123 -0
- package/package.json +66 -0
- package/scratchpad.ts +117 -0
- package/tools.ts +1132 -0
- package/workflows/execute.ts +401 -0
- package/workflows/index.ts +235 -0
- package/workflows/inspect.ts +492 -0
- package/workflows/parser.ts +268 -0
- package/workflows/pause.ts +150 -0
- package/workflows/planning.ts +624 -0
- package/workflows/quick.ts +258 -0
- package/workflows/resume.ts +201 -0
- package/workflows-data/discuss-phase.md +292 -0
- package/workflows-data/execute-phase.md +200 -0
- package/workflows-data/execute-plan.md +251 -0
- package/workflows-data/execute-task.md +116 -0
- package/workflows-data/pause-work.md +142 -0
- package/workflows-data/plan-phase.md +199 -0
- package/workflows-data/plan-task.md +185 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// workflows/inspect.ts — Inspect / cleanup commands (soly doctor, iterations,
|
|
3
|
+
// phase delete). Direct-response — no LLM round-trip, no transforms.
|
|
4
|
+
// =============================================================================
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { SolyState } from "../core.js";
|
|
10
|
+
import type { SolyConfig } from "../config.js";
|
|
11
|
+
|
|
12
|
+
interface InspectUI {
|
|
13
|
+
notify: (text: string, kind?: "info" | "warning" | "error") => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// soly doctor — health check
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface DoctorCheck {
|
|
21
|
+
name: string;
|
|
22
|
+
/** pass / warn / fail affect the count. `info` is purely informational
|
|
23
|
+
* (e.g. "optional extension not installed") and doesn't change the totals. */
|
|
24
|
+
status: "pass" | "warn" | "fail" | "info";
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, config: SolyConfig, activeTools: string[] = []): void {
|
|
29
|
+
const checks: DoctorCheck[] = [];
|
|
30
|
+
|
|
31
|
+
// 1. .soly/ exists
|
|
32
|
+
checks.push({
|
|
33
|
+
name: ".soly/ directory",
|
|
34
|
+
status: state.exists ? "pass" : "fail",
|
|
35
|
+
detail: state.exists ? state.solyDir : "no .soly/ found in cwd",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 2. STATE.md exists + has frontmatter
|
|
39
|
+
if (state.exists) {
|
|
40
|
+
const statePath = path.join(state.solyDir, "STATE.md");
|
|
41
|
+
if (fs.existsSync(statePath)) {
|
|
42
|
+
const raw = fs.readFileSync(statePath, "utf-8");
|
|
43
|
+
if (raw.startsWith("---\n") || raw.startsWith("---\r\n")) {
|
|
44
|
+
checks.push({ name: "STATE.md frontmatter", status: "pass", detail: "valid YAML block" });
|
|
45
|
+
} else {
|
|
46
|
+
checks.push({ name: "STATE.md frontmatter", status: "warn", detail: "missing top-level frontmatter" });
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
checks.push({ name: "STATE.md", status: "fail", detail: "missing" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. ROADMAP.md exists
|
|
54
|
+
if (state.exists) {
|
|
55
|
+
const roadmapPath = path.join(state.solyDir, "ROADMAP.md");
|
|
56
|
+
if (fs.existsSync(roadmapPath)) {
|
|
57
|
+
checks.push({ name: "ROADMAP.md", status: "pass", detail: "present" });
|
|
58
|
+
} else {
|
|
59
|
+
// Symmetric with STATE.md: both required for soly workflows to function
|
|
60
|
+
checks.push({ name: "ROADMAP.md", status: "fail", detail: "missing — `soly plan N` needs phase context" });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. At least one phase
|
|
65
|
+
if (state.exists) {
|
|
66
|
+
const phasesDir = path.join(state.solyDir, "phases");
|
|
67
|
+
if (fs.existsSync(phasesDir)) {
|
|
68
|
+
const dirCount = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
69
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
70
|
+
.length;
|
|
71
|
+
checks.push({
|
|
72
|
+
name: "phase directories",
|
|
73
|
+
status: dirCount > 0 ? "pass" : "warn",
|
|
74
|
+
detail: dirCount > 0 ? `${dirCount} phase(s)` : "none — run `soly plan <N>` to start",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 5. Iteration file count
|
|
80
|
+
if (state.exists) {
|
|
81
|
+
const iterDir = path.join(state.solyDir, "iterations");
|
|
82
|
+
if (fs.existsSync(iterDir)) {
|
|
83
|
+
const files = fs.readdirSync(iterDir).filter((f) => f.endsWith(".md"));
|
|
84
|
+
let warnDetail: string | null = null;
|
|
85
|
+
if (files.length > 50) {
|
|
86
|
+
warnDetail = `${files.length} files — consider enabling iteration.retentionDays in config`;
|
|
87
|
+
} else if (files.length > 0) {
|
|
88
|
+
warnDetail = `${files.length} files`;
|
|
89
|
+
} else {
|
|
90
|
+
warnDetail = "none";
|
|
91
|
+
}
|
|
92
|
+
checks.push({
|
|
93
|
+
name: "iteration files",
|
|
94
|
+
status: files.length > 50 ? "warn" : "pass",
|
|
95
|
+
detail: warnDetail,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
checks.push({ name: "iteration files", status: "pass", detail: "no iterations yet" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 6. Phase dir naming convention
|
|
103
|
+
if (state.exists) {
|
|
104
|
+
const phasesDir = path.join(state.solyDir, "phases");
|
|
105
|
+
if (fs.existsSync(phasesDir)) {
|
|
106
|
+
const bad: string[] = [];
|
|
107
|
+
for (const e of fs.readdirSync(phasesDir, { withFileTypes: true })) {
|
|
108
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
109
|
+
if (!/^\d+(-|_).+/.test(e.name)) bad.push(e.name);
|
|
110
|
+
}
|
|
111
|
+
checks.push({
|
|
112
|
+
name: "phase dir naming",
|
|
113
|
+
status: bad.length === 0 ? "pass" : "warn",
|
|
114
|
+
detail: bad.length === 0
|
|
115
|
+
? "all match `<NN>-<slug>` convention"
|
|
116
|
+
: `${bad.length} don't match convention: ${bad.join(", ")}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 7. Plan files have frontmatter
|
|
122
|
+
if (state.exists) {
|
|
123
|
+
const phasesDir = path.join(state.solyDir, "phases");
|
|
124
|
+
if (fs.existsSync(phasesDir)) {
|
|
125
|
+
let totalPlans = 0;
|
|
126
|
+
let badPlans = 0;
|
|
127
|
+
for (const p of fs.readdirSync(phasesDir, { withFileTypes: true })) {
|
|
128
|
+
if (!p.isDirectory() || p.name.startsWith(".")) continue;
|
|
129
|
+
for (const f of fs.readdirSync(path.join(phasesDir, p.name))) {
|
|
130
|
+
if (/-PLAN\.md$/.test(f)) {
|
|
131
|
+
totalPlans++;
|
|
132
|
+
const raw = fs.readFileSync(path.join(phasesDir, p.name, f), "utf-8");
|
|
133
|
+
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) badPlans++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
checks.push({
|
|
138
|
+
name: "PLAN.md frontmatter",
|
|
139
|
+
status: badPlans === 0 ? "pass" : totalPlans > 0 ? "warn" : "pass",
|
|
140
|
+
detail: totalPlans === 0
|
|
141
|
+
? "no plans yet"
|
|
142
|
+
: badPlans === 0
|
|
143
|
+
? `all ${totalPlans} plans have valid frontmatter`
|
|
144
|
+
: `${badPlans}/${totalPlans} plans missing frontmatter`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 8. Stale iteration files (if retention config > 0)
|
|
150
|
+
if (state.exists && config.iteration.retentionDays > 0) {
|
|
151
|
+
const iterDir = path.join(state.solyDir, "iterations");
|
|
152
|
+
if (fs.existsSync(iterDir)) {
|
|
153
|
+
const cutoff = Date.now() - config.iteration.retentionDays * 86400_000;
|
|
154
|
+
let stale = 0;
|
|
155
|
+
for (const f of fs.readdirSync(iterDir)) {
|
|
156
|
+
try {
|
|
157
|
+
const stat = fs.statSync(path.join(iterDir, f));
|
|
158
|
+
if (stat.isFile() && stat.mtimeMs < cutoff) stale++;
|
|
159
|
+
} catch { /* skip */ }
|
|
160
|
+
}
|
|
161
|
+
checks.push({
|
|
162
|
+
name: "iteration retention",
|
|
163
|
+
status: stale === 0 ? "pass" : "warn",
|
|
164
|
+
detail: stale === 0
|
|
165
|
+
? `no files older than ${pluralDays(config.iteration.retentionDays)}`
|
|
166
|
+
: `${stale} file(s) older than ${pluralDays(config.iteration.retentionDays)} (will auto-prune on next session_start)`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 9. .soly/rules/ exists if state says rules are loaded
|
|
172
|
+
if (state.exists) {
|
|
173
|
+
const rulesDir = path.join(state.solyDir, "rules");
|
|
174
|
+
checks.push({
|
|
175
|
+
name: ".soly/rules/ directory",
|
|
176
|
+
status: fs.existsSync(rulesDir) ? "pass" : "warn",
|
|
177
|
+
detail: fs.existsSync(rulesDir) ? "present" : "no rules directory — soly will fall back to ~/.soly/rules/",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 10. pi-todo cross-extension (optional, but recommended for long agentic flows)
|
|
182
|
+
if (state.exists) {
|
|
183
|
+
const hasPiTodo = activeTools.includes("todo_update");
|
|
184
|
+
checks.push({
|
|
185
|
+
name: "pi-todo extension (cross-extension)",
|
|
186
|
+
status: hasPiTodo ? "pass" : "info",
|
|
187
|
+
detail: hasPiTodo
|
|
188
|
+
? "todo_update tool loaded — plan execution will show live progress in footer"
|
|
189
|
+
: "not detected (optional) — install pi-ask sibling for live progress on multi-step plans",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 11. soly-aware subagents (opt-in via `agent.useSolyWorkerSubagents`).
|
|
194
|
+
// Reports current state — not a failure if disabled.
|
|
195
|
+
if (state.exists) {
|
|
196
|
+
const userAgentsDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
197
|
+
const hasWorker = fs.existsSync(path.join(userAgentsDir, "soly-worker.md"));
|
|
198
|
+
const hasOracle = fs.existsSync(path.join(userAgentsDir, "soly-oracle.md"));
|
|
199
|
+
const both = hasWorker && hasOracle;
|
|
200
|
+
const flag = config.agent.useSolyWorkerSubagents;
|
|
201
|
+
if (flag) {
|
|
202
|
+
// User opted in: both files MUST be present
|
|
203
|
+
checks.push({
|
|
204
|
+
name: "soly-aware subagents (opt-in enabled)",
|
|
205
|
+
status: both ? "pass" : "warn",
|
|
206
|
+
detail: both
|
|
207
|
+
? "installed in ~/.pi/agent/agents/ — soly execute uses soly-worker"
|
|
208
|
+
: `useSolyWorkerSubagents=true but ${hasWorker ? "soly-oracle.md" : "soly-worker.md"} missing — check session_start install errors`,
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
// Opt-in disabled: informational only
|
|
212
|
+
checks.push({
|
|
213
|
+
name: "soly-aware subagents (opt-in available, not enabled)",
|
|
214
|
+
status: "info",
|
|
215
|
+
detail: both
|
|
216
|
+
? "soly-worker.md and soly-oracle.md present in ~/.pi/agent/agents/ — set `agent.useSolyWorkerSubagents: true` in .soly/config.json to use them in soly execute"
|
|
217
|
+
: "set `agent.useSolyWorkerSubagents: true` in .soly/config.json to auto-install soly-worker + soly-oracle (specialized for soly paths/plans/todo)",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Render
|
|
223
|
+
const symbol = { pass: "✓", warn: "⚠", fail: "✗", info: "ℹ" };
|
|
224
|
+
const color = { pass: "pass", warn: "warning", fail: "fail", info: "info" } as const;
|
|
225
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
226
|
+
for (const c of checks) counts[c.status]++;
|
|
227
|
+
|
|
228
|
+
const out: string[] = [];
|
|
229
|
+
out.push("=== soly doctor ===");
|
|
230
|
+
out.push("");
|
|
231
|
+
for (const c of checks) {
|
|
232
|
+
out.push(` ${symbol[c.status]} ${c.name} (${color[c.status]})`);
|
|
233
|
+
out.push(` ${c.detail}`);
|
|
234
|
+
}
|
|
235
|
+
out.push("");
|
|
236
|
+
out.push(`Total: ${counts.pass} pass, ${counts.warn} warn, ${counts.fail} fail`);
|
|
237
|
+
if (counts.fail > 0) {
|
|
238
|
+
out.push("");
|
|
239
|
+
out.push("Critical issues found — soly workflows may not work correctly until fixed.");
|
|
240
|
+
} else if (counts.warn > 0) {
|
|
241
|
+
out.push("");
|
|
242
|
+
out.push("Warnings present — run `/soly iterations` and `/soly config` to review.");
|
|
243
|
+
}
|
|
244
|
+
ui.notify(out.join("\n"), counts.fail > 0 ? "error" : counts.warn > 0 ? "warning" : "info");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/** "1 day" / "2 days" / "0 days" — small grammar helper used in doctor output. */
|
|
250
|
+
function pluralDays(n: number): string {
|
|
251
|
+
return n === 1 ? "1 day" : `${n} days`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// soly todos — read .soly/todos.json or .pi-todos.json and show as a notify.
|
|
256
|
+
// Mirrors what pi-todo would render in the footer, so the user can see
|
|
257
|
+
// todos even when pi-todo extension isn't loaded (e.g. just-installed).
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/** Try to find a todo file in cwd. Returns the path or null. */
|
|
261
|
+
function findTodosFile(cwd: string): string | null {
|
|
262
|
+
const candidates = [
|
|
263
|
+
path.join(cwd, ".soly", "todos.json"),
|
|
264
|
+
path.join(cwd, ".pi-todos.json"),
|
|
265
|
+
];
|
|
266
|
+
for (const c of candidates) {
|
|
267
|
+
if (fs.existsSync(c)) return c;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function showTodos(
|
|
273
|
+
_cmd: { verb: string; args: string[]; raw: string },
|
|
274
|
+
state: SolyState,
|
|
275
|
+
ui: InspectUI,
|
|
276
|
+
): void {
|
|
277
|
+
if (!state.exists) {
|
|
278
|
+
ui.notify("soly todos: no .soly/ directory in cwd", "error");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const file = findTodosFile(state.solyDir);
|
|
282
|
+
if (!file) {
|
|
283
|
+
ui.notify(
|
|
284
|
+
"soly todos: no todo file found. Install the `pi-todo` extension or write `.soly/todos.json` manually.",
|
|
285
|
+
"info",
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let parsed: { todos?: Array<{ content?: string; status?: string; activeForm?: string }> } | null = null;
|
|
290
|
+
try {
|
|
291
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
292
|
+
} catch {
|
|
293
|
+
ui.notify(`soly todos: failed to parse ${path.basename(file)} (corrupt JSON?)`, "error");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!parsed || !Array.isArray(parsed.todos) || parsed.todos.length === 0) {
|
|
297
|
+
ui.notify("soly todos: list is empty. Use the LLM's `todo_update` tool to add items.", "info");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const todos = parsed.todos;
|
|
301
|
+
const total = todos.length;
|
|
302
|
+
const done = todos.filter((t) => t.status === "completed").length;
|
|
303
|
+
const lines: string[] = [];
|
|
304
|
+
lines.push(`=== soly todos (${done}/${total} done) — from ${path.basename(file)} ===`);
|
|
305
|
+
lines.push("");
|
|
306
|
+
for (const t of todos) {
|
|
307
|
+
if (typeof t.content !== "string" || typeof t.status !== "string") continue;
|
|
308
|
+
const mark = t.status === "completed" ? "✓" : t.status === "in_progress" ? "⋯" : "○";
|
|
309
|
+
const suffix = t.status === "in_progress" && typeof t.activeForm === "string" ? ` (${t.activeForm})` : "";
|
|
310
|
+
lines.push(` ${mark} ${t.content}${suffix}`);
|
|
311
|
+
}
|
|
312
|
+
ui.notify(lines.join("\n"), "info");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// soly iterations [N] — list recent iteration files (sorted mtime desc)
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
export function showIterations(
|
|
320
|
+
cmd: { args: string[]; verb: string; raw: string },
|
|
321
|
+
state: SolyState,
|
|
322
|
+
ui: InspectUI,
|
|
323
|
+
limitDefault: number = 10,
|
|
324
|
+
): void {
|
|
325
|
+
if (!state.exists) {
|
|
326
|
+
ui.notify("soly iterations: no .soly/ directory in cwd", "error");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const iterDir = path.join(state.solyDir, "iterations");
|
|
330
|
+
if (!fs.existsSync(iterDir)) {
|
|
331
|
+
ui.notify("soly iterations: no iterations yet (run soly plan or soly execute first)", "info");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Optional N
|
|
336
|
+
const nArg = cmd.args[0]?.trim();
|
|
337
|
+
let limit = limitDefault;
|
|
338
|
+
if (nArg) {
|
|
339
|
+
const parsed = parseInt(nArg, 10);
|
|
340
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
341
|
+
ui.notify(`soly iterations: invalid count "${nArg}"`, "error");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
limit = parsed;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const files = fs.readdirSync(iterDir)
|
|
348
|
+
.filter((f) => f.endsWith(".md"))
|
|
349
|
+
.map((f) => {
|
|
350
|
+
const full = path.join(iterDir, f);
|
|
351
|
+
const stat = fs.statSync(full);
|
|
352
|
+
return { name: f, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
353
|
+
})
|
|
354
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
355
|
+
.slice(0, limit);
|
|
356
|
+
|
|
357
|
+
if (files.length === 0) {
|
|
358
|
+
ui.notify("soly iterations: no iteration files found", "info");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const out: string[] = [];
|
|
363
|
+
out.push(`=== soly iterations (last ${files.length}) ===`);
|
|
364
|
+
out.push("");
|
|
365
|
+
for (const f of files) {
|
|
366
|
+
const ago = humanizeAge(Date.now() - f.mtimeMs);
|
|
367
|
+
const sizeKb = (f.size / 1024).toFixed(1);
|
|
368
|
+
out.push(` ${f.name} (${sizeKb}k, ${ago})`);
|
|
369
|
+
}
|
|
370
|
+
if (limitDefault === 10 && cmd.args[0] === undefined) {
|
|
371
|
+
out.push("");
|
|
372
|
+
out.push("Tip: `soly iterations 20` for more, `soly diff iterations <a> <b>` to compare two.");
|
|
373
|
+
}
|
|
374
|
+
ui.notify(out.join("\n"), "info");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function humanizeAge(ms: number): string {
|
|
378
|
+
if (ms < 60_000) return "just now";
|
|
379
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
|
|
380
|
+
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
|
|
381
|
+
if (ms < 30 * 86_400_000) return `${Math.round(ms / 86_400_000)}d ago`;
|
|
382
|
+
return `${Math.round(ms / (30 * 86_400_000))}mo ago`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// soly diff iterations <a> <b> — compare two iteration files
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
export function showDiffIterations(
|
|
390
|
+
cmd: { args: string[]; verb: string; raw: string },
|
|
391
|
+
state: SolyState,
|
|
392
|
+
ui: InspectUI,
|
|
393
|
+
): void {
|
|
394
|
+
if (!state.exists) {
|
|
395
|
+
ui.notify("soly diff iterations: no .soly/ directory in cwd", "error");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const iterDir = path.join(state.solyDir, "iterations");
|
|
399
|
+
if (cmd.args.length < 2) {
|
|
400
|
+
ui.notify(
|
|
401
|
+
`soly diff iterations: need two file arguments (e.g. "soly diff iterations 05-02-exec-T1.md 05-02-exec-T2.md")`,
|
|
402
|
+
"error",
|
|
403
|
+
);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const [a, b] = [cmd.args[0]!, cmd.args[1]!];
|
|
407
|
+
const pathA = path.isAbsolute(a) ? a : path.join(iterDir, a);
|
|
408
|
+
const pathB = path.isAbsolute(b) ? b : path.join(iterDir, b);
|
|
409
|
+
|
|
410
|
+
if (!fs.existsSync(pathA)) {
|
|
411
|
+
ui.notify(`soly diff iterations: file not found: ${a}`, "error");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!fs.existsSync(pathB)) {
|
|
415
|
+
ui.notify(`soly diff iterations: file not found: ${b}`, "error");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const bodyA = fs.readFileSync(pathA, "utf-8");
|
|
420
|
+
const bodyB = fs.readFileSync(pathB, "utf-8");
|
|
421
|
+
|
|
422
|
+
const out: string[] = [];
|
|
423
|
+
out.push(`=== soly diff iterations ===`);
|
|
424
|
+
out.push(` A: ${a} (${(bodyA.length / 1024).toFixed(1)}k)`);
|
|
425
|
+
out.push(` B: ${b} (${(bodyB.length / 1024).toFixed(1)}k)`);
|
|
426
|
+
out.push("");
|
|
427
|
+
if (bodyA === bodyB) {
|
|
428
|
+
out.push("Files are identical.");
|
|
429
|
+
} else {
|
|
430
|
+
out.push("Files differ. Showing the LLM-friendly view (both full bodies — model can diff mentally):");
|
|
431
|
+
out.push("");
|
|
432
|
+
out.push("--- BEGIN A ---");
|
|
433
|
+
out.push(bodyA);
|
|
434
|
+
out.push("--- END A ---");
|
|
435
|
+
out.push("");
|
|
436
|
+
out.push("--- BEGIN B ---");
|
|
437
|
+
out.push(bodyB);
|
|
438
|
+
out.push("--- END B ---");
|
|
439
|
+
}
|
|
440
|
+
ui.notify(out.join("\n"), "info");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// soly phase delete <N> — soft-delete a phase (move to .trash)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
export function showPhaseDelete(
|
|
448
|
+
cmd: { args: string[]; verb: string; raw: string },
|
|
449
|
+
state: SolyState,
|
|
450
|
+
ui: InspectUI,
|
|
451
|
+
): void {
|
|
452
|
+
if (!state.exists) {
|
|
453
|
+
ui.notify("soly phase delete: no .soly/ directory in cwd", "error");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (cmd.args.length < 1) {
|
|
457
|
+
ui.notify("soly phase delete: need a phase number (e.g. `soly phase delete 5`)", "error");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const phaseNum = parseInt(cmd.args[0]!, 10);
|
|
461
|
+
if (!Number.isFinite(phaseNum)) {
|
|
462
|
+
ui.notify(`soly phase delete: invalid phase number "${cmd.args[0]}"`, "error");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const phase = state.phases.find((p) => p.number === phaseNum);
|
|
467
|
+
if (!phase) {
|
|
468
|
+
const known = state.phases.map((p) => p.number).join(", ") || "(none)";
|
|
469
|
+
ui.notify(`soly phase delete: phase ${phaseNum} not found. Known: ${known}`, "error");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const trashDir = path.join(state.solyDir, "phases", ".trash");
|
|
474
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
475
|
+
const dest = path.join(trashDir, `${phase.slug}-${stamp}`);
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
479
|
+
fs.renameSync(phase.dir, dest);
|
|
480
|
+
} catch (e) {
|
|
481
|
+
ui.notify(`soly phase delete: failed to move phase (${(e as Error).message})`, "error");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const out: string[] = [];
|
|
486
|
+
out.push(`✓ Phase ${phaseNum} (${phase.name}) moved to .trash/`);
|
|
487
|
+
out.push(` ${phase.dir} → ${dest}`);
|
|
488
|
+
out.push("");
|
|
489
|
+
out.push("To restore: `mv` it back to .soly/phases/");
|
|
490
|
+
out.push("To permanently delete: `rm -rf " + dest + "`");
|
|
491
|
+
ui.notify(out.join("\n"), "info");
|
|
492
|
+
}
|