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
package/tools.ts
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// tools.ts — LLM-callable tools for the soly extension
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Registers three tools the LLM can call:
|
|
6
|
+
// - soly_read — read any .soly/ artifact (state/plan/roadmap/...)
|
|
7
|
+
// - soly_log_decision — append a row to STATE.md Decisions table
|
|
8
|
+
// - soly_list_phases — list all phases with markers
|
|
9
|
+
//
|
|
10
|
+
// All paths are relative to <cwd>/.soly/ (the soly layout — NOT .planning/).
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { readIfExists, splitFrontmatter, atomicWriteFileSync, type SolyState } from "./core.js";
|
|
21
|
+
import { detectEnv, type EnvSummary } from "./env.js";
|
|
22
|
+
import type { SolyConfig } from "./config.js";
|
|
23
|
+
import { buildDocIndex, searchDocs, readSnippet, stripHtml } from "./docs.js";
|
|
24
|
+
import { buildScratchpad, SCRATCHPAD_LIMITS } from "./scratchpad.js";
|
|
25
|
+
import { loadIntentDocs, buildIntentSection, type IntentDoc } from "./intent.js";
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
/** Tools need read/write access to the live state plus a refresh hook. */
|
|
30
|
+
export interface ToolsDeps {
|
|
31
|
+
getState: () => SolyState;
|
|
32
|
+
refreshState: () => void;
|
|
33
|
+
getConfig?: () => SolyConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerTools(pi: ExtensionAPI, deps: ToolsDeps): void {
|
|
37
|
+
const { getState, refreshState, getConfig } = deps;
|
|
38
|
+
|
|
39
|
+
pi.registerTool({
|
|
40
|
+
name: "soly_read",
|
|
41
|
+
label: "soly read",
|
|
42
|
+
description:
|
|
43
|
+
"read a .soly/ artifact. Use when you need to inspect what the project knows — current state, the active plan, phase context/research, the roadmap, requirements, project overview, or milestone. Pass `phase` (number) to target a specific phase's plan/context/research; defaults to the current phase. Returns the file content as text.",
|
|
44
|
+
parameters: Type.Object({
|
|
45
|
+
artifact: StringEnum([
|
|
46
|
+
"state",
|
|
47
|
+
"plan",
|
|
48
|
+
"context",
|
|
49
|
+
"research",
|
|
50
|
+
"roadmap",
|
|
51
|
+
"requirements",
|
|
52
|
+
"project",
|
|
53
|
+
"milestone",
|
|
54
|
+
"task",
|
|
55
|
+
] as const),
|
|
56
|
+
phase: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description:
|
|
59
|
+
"Phase number (for plan/context/research/milestone). Defaults to current phase.",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
taskId: Type.Optional(
|
|
63
|
+
Type.String({
|
|
64
|
+
description:
|
|
65
|
+
"Task ID (for task artifact). E.g. 'auth-be-login-a3f9'. Reads tasks/<id>/PLAN.md.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
70
|
+
const state = getState();
|
|
71
|
+
const { artifact, phase } = params;
|
|
72
|
+
let rel: string;
|
|
73
|
+
let abs: string;
|
|
74
|
+
|
|
75
|
+
if (artifact === "state") {
|
|
76
|
+
rel = "STATE.md";
|
|
77
|
+
abs = path.join(state.solyDir, rel);
|
|
78
|
+
} else if (artifact === "roadmap") {
|
|
79
|
+
rel = "ROADMAP.md";
|
|
80
|
+
abs = path.join(state.solyDir, rel);
|
|
81
|
+
} else if (artifact === "requirements") {
|
|
82
|
+
rel = "REQUIREMENTS.md";
|
|
83
|
+
abs = path.join(state.solyDir, rel);
|
|
84
|
+
} else if (artifact === "project") {
|
|
85
|
+
rel = "PROJECT.md";
|
|
86
|
+
abs = path.join(state.solyDir, rel);
|
|
87
|
+
} else if (artifact === "milestone") {
|
|
88
|
+
rel = state.milestone
|
|
89
|
+
? `milestones/${state.milestone}.md`
|
|
90
|
+
: "MILESTONES.md";
|
|
91
|
+
abs = path.join(state.solyDir, rel);
|
|
92
|
+
} else if (artifact === "task") {
|
|
93
|
+
const taskId = params.taskId;
|
|
94
|
+
if (!taskId) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{ type: "text", text: "soly_read: task artifact requires taskId parameter" },
|
|
98
|
+
],
|
|
99
|
+
details: { error: "missing_task_id" },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const task = state.tasks.find((t) => t.id === taskId);
|
|
103
|
+
if (!task) {
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{ type: "text", text: `soly: task ${taskId} not found in .soly/features/*/tasks/` },
|
|
107
|
+
],
|
|
108
|
+
details: { error: "task_not_found", taskId },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
rel = path.join("features", task.feature, "tasks", task.id, "PLAN.md");
|
|
112
|
+
abs = path.join(state.solyDir, rel);
|
|
113
|
+
} else {
|
|
114
|
+
const targetNum = phase ? parseInt(phase, 10) : state.currentPhase?.number;
|
|
115
|
+
const target = targetNum
|
|
116
|
+
? state.phases.find((p) => p.number === targetNum)
|
|
117
|
+
: state.currentPhase;
|
|
118
|
+
if (!target) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `soly: no phase found${phase ? ` for number ${phase}` : " (no current phase)"}`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
details: { error: "no_phase" },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (artifact === "plan") {
|
|
130
|
+
const planFile = target.plans[0];
|
|
131
|
+
if (!planFile) {
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{ type: "text", text: `soly: no plans in phase ${target.number}` },
|
|
135
|
+
],
|
|
136
|
+
details: { error: "no_plan" },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
rel = path.join("phases", target.slug, planFile);
|
|
140
|
+
} else if (artifact === "context") {
|
|
141
|
+
rel = path.join("phases", target.slug, `${target.slug}-CONTEXT.md`);
|
|
142
|
+
} else {
|
|
143
|
+
rel = path.join("phases", target.slug, `${target.slug}-RESEARCH.md`);
|
|
144
|
+
}
|
|
145
|
+
abs = path.join(state.solyDir, rel);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const content = readIfExists(abs);
|
|
149
|
+
if (!content) {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: `soly: file not found: ${rel}` }],
|
|
152
|
+
details: { error: "not_found", path: rel },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text", text: content }],
|
|
157
|
+
details: { path: rel, length: content.length },
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
pi.registerTool({
|
|
163
|
+
name: "soly_log_decision",
|
|
164
|
+
label: "soly log decision",
|
|
165
|
+
description:
|
|
166
|
+
"append a row to the Decisions table in .soly/STATE.md (creates the table if missing). Use this for meaningful choices the future-you should know about — scope cuts, library picks, trade-off resolutions. Pass `phase` (number) to attach to a specific phase; defaults to the current phase. Decision and rationale should each be one line.",
|
|
167
|
+
parameters: Type.Object({
|
|
168
|
+
decision: Type.String({ description: "The decision made (one line)." }),
|
|
169
|
+
rationale: Type.String({ description: "Why this decision was made (one line)." }),
|
|
170
|
+
phase: Type.Optional(
|
|
171
|
+
Type.String({
|
|
172
|
+
description: "Phase number this decision relates to. Defaults to current phase.",
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
}),
|
|
176
|
+
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
177
|
+
const state = getState();
|
|
178
|
+
const { decision, rationale } = params;
|
|
179
|
+
const phaseRef =
|
|
180
|
+
params.phase ?? (state.currentPhase ? String(state.currentPhase.number) : "—");
|
|
181
|
+
const statePath = path.join(state.solyDir, "STATE.md");
|
|
182
|
+
const raw = readIfExists(statePath);
|
|
183
|
+
if (!raw) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text", text: "soly: STATE.md not found" }],
|
|
186
|
+
details: { error: "not_found" },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const safeDecision = decision.replace(/\|/g, "\\|");
|
|
191
|
+
const safeRationale = rationale.replace(/\|/g, "\\|");
|
|
192
|
+
const row = `| ${safeDecision} | ${safeRationale} | ${phaseRef} |`;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const lines = raw.split(/\r?\n/);
|
|
196
|
+
const decisionsIdx = lines.findIndex((l) => /^##\s*Decisions\s*$/.test(l));
|
|
197
|
+
|
|
198
|
+
if (decisionsIdx === -1) {
|
|
199
|
+
const header = [
|
|
200
|
+
"",
|
|
201
|
+
"## Decisions",
|
|
202
|
+
"",
|
|
203
|
+
"| Decision | Rationale | Phase |",
|
|
204
|
+
"|----------|-----------|-------|",
|
|
205
|
+
row,
|
|
206
|
+
"",
|
|
207
|
+
].join("\n");
|
|
208
|
+
const updated = raw.endsWith("\n") ? `${raw}${header}` : `${raw}\n${header}`;
|
|
209
|
+
atomicWriteFileSync(statePath, updated, "utf-8");
|
|
210
|
+
} else {
|
|
211
|
+
let insertAt = decisionsIdx + 1;
|
|
212
|
+
while (insertAt < lines.length && !lines[insertAt].startsWith("|")) {
|
|
213
|
+
insertAt++;
|
|
214
|
+
}
|
|
215
|
+
while (
|
|
216
|
+
insertAt < lines.length &&
|
|
217
|
+
lines[insertAt].startsWith("|") &&
|
|
218
|
+
!/^\|[-\s|]+\|$/.test(lines[insertAt].trim())
|
|
219
|
+
) {
|
|
220
|
+
insertAt++;
|
|
221
|
+
}
|
|
222
|
+
lines.splice(insertAt, 0, row);
|
|
223
|
+
atomicWriteFileSync(statePath, lines.join("\n"), "utf-8");
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{ type: "text", text: `soly: failed to write STATE.md: ${(err as Error).message}` },
|
|
229
|
+
],
|
|
230
|
+
details: { error: "write_failed" },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
refreshState();
|
|
235
|
+
return {
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: `Decision logged to STATE.md (phase ${phaseRef}): ${decision}`,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
details: { decision, phase: phaseRef },
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
pi.registerTool({
|
|
248
|
+
name: "soly_list_tasks",
|
|
249
|
+
label: "soly list tasks",
|
|
250
|
+
description:
|
|
251
|
+
"list all tasks across all features with kind, status, priority, and dependencies. Use when you need an overview of available tasks — e.g. before `soly execute <task-id>` or `soly execute --all`.",
|
|
252
|
+
parameters: Type.Object({}),
|
|
253
|
+
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
254
|
+
const state = getState();
|
|
255
|
+
if (state.tasks.length === 0) {
|
|
256
|
+
return {
|
|
257
|
+
content: [{ type: "text", text: "soly: no tasks found" }],
|
|
258
|
+
details: { count: 0 },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const lines = state.tasks.map((t) => {
|
|
262
|
+
const deps = t.dependsOn.length > 0 ? ` deps=[${t.dependsOn.join(",")}]` : "";
|
|
263
|
+
const par = t.parallelizable ? " \u26a1" : "";
|
|
264
|
+
return `\u2192 ${t.id} [${t.feature}/${t.kind}] status=${t.status} prio=${t.priority}${par}${deps}`;
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
268
|
+
details: { count: state.tasks.length },
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pi.registerTool({
|
|
274
|
+
name: "soly_list_phases",
|
|
275
|
+
label: "soly list phases",
|
|
276
|
+
description:
|
|
277
|
+
"list all phases in .soly/phases/ with plan count, CONTEXT/RESEARCH presence markers (C/R), and the current position marker (→). Use when you need an overview of which phases exist, which have plans, and which have research/context notes — e.g. before suggesting `soly plan <N>` or `soly execute <N>`.",
|
|
278
|
+
parameters: Type.Object({}),
|
|
279
|
+
async execute(_id, _params, _signal, _onUpdate, _ctx) {
|
|
280
|
+
const state = getState();
|
|
281
|
+
if (state.phases.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: "text", text: "soly: no phases found" }],
|
|
284
|
+
details: { count: 0 },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const lines = state.phases.map((p) => {
|
|
288
|
+
const marker = state.currentPhase?.number === p.number ? "→" : " ";
|
|
289
|
+
const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
|
|
290
|
+
return `${marker} Phase ${p.number}: ${p.name} [${cr}] plans=${p.planCount}`;
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
294
|
+
details: {
|
|
295
|
+
count: state.phases.length,
|
|
296
|
+
current: state.currentPhase?.number ?? null,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
pi.registerTool({
|
|
303
|
+
name: "soly_todos",
|
|
304
|
+
label: "soly todos",
|
|
305
|
+
description:
|
|
306
|
+
"scan the working tree for TODO / FIXME / HACK / XXX / NOTE comments, grouped by file. Use for a quick audit of outstanding cleanup work. Scans .ts/.tsx/.js/.jsx/.py/.go/.rs only, excluding node_modules, .git, dist, build, .soly, coverage. Requires `rg` (ripgrep) on PATH — silently returns empty if missing. Pass `paths` to override the scan root, `limit` to cap (default 200).",
|
|
307
|
+
parameters: Type.Object({
|
|
308
|
+
paths: Type.Optional(
|
|
309
|
+
Type.Array(Type.String(), {
|
|
310
|
+
description: "Directories or files to scan. Defaults to cwd.",
|
|
311
|
+
}),
|
|
312
|
+
),
|
|
313
|
+
limit: Type.Optional(
|
|
314
|
+
Type.Number({
|
|
315
|
+
description: "Max matches to return. Default 200.",
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
}),
|
|
319
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
320
|
+
const targets = params.paths && params.paths.length > 0 ? params.paths : [ctx.cwd];
|
|
321
|
+
const limit = params.limit && params.limit > 0 ? params.limit : 200;
|
|
322
|
+
const cwd = ctx.cwd;
|
|
323
|
+
|
|
324
|
+
const rgArgs = [
|
|
325
|
+
"--no-heading",
|
|
326
|
+
"--line-number",
|
|
327
|
+
"--color=never",
|
|
328
|
+
"--hidden",
|
|
329
|
+
"--glob=!.git/**",
|
|
330
|
+
"--glob=!node_modules/**",
|
|
331
|
+
"--glob=!dist/**",
|
|
332
|
+
"--glob=!build/**",
|
|
333
|
+
"--glob=!.soly/**",
|
|
334
|
+
"--glob=!coverage/**",
|
|
335
|
+
"-tts",
|
|
336
|
+
"-tjs",
|
|
337
|
+
"-tpy",
|
|
338
|
+
"-tgo",
|
|
339
|
+
"-trs",
|
|
340
|
+
"-e",
|
|
341
|
+
"\\b(TODO|FIXME|HACK|XXX|NOTE)\\b",
|
|
342
|
+
...targets,
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
type Match = { file: string; line: number; text: string; tag: string };
|
|
346
|
+
const matches: Match[] = [];
|
|
347
|
+
try {
|
|
348
|
+
const { stdout } = await execFileAsync("rg", rgArgs, {
|
|
349
|
+
cwd,
|
|
350
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
351
|
+
encoding: "utf-8",
|
|
352
|
+
windowsHide: true,
|
|
353
|
+
});
|
|
354
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
355
|
+
if (!line) continue;
|
|
356
|
+
const m = line.match(/^(.*?):(\d+):(?:\d+:)?(.*)$/);
|
|
357
|
+
if (!m) continue;
|
|
358
|
+
const file = m[1];
|
|
359
|
+
const lineNum = parseInt(m[2], 10);
|
|
360
|
+
const text = m[3].trim();
|
|
361
|
+
const tagMatch = text.match(/\b(TODO|FIXME|HACK|XXX|NOTE)\b/);
|
|
362
|
+
matches.push({
|
|
363
|
+
file,
|
|
364
|
+
line: lineNum,
|
|
365
|
+
text,
|
|
366
|
+
tag: tagMatch?.[1] ?? "TODO",
|
|
367
|
+
});
|
|
368
|
+
if (matches.length >= limit) break;
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{
|
|
374
|
+
type: "text",
|
|
375
|
+
text:
|
|
376
|
+
`soly_todos: no matches found (or \`rg\` is not on PATH — install ripgrep for full functionality).`,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
details: { count: 0, hint: "install ripgrep" },
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const byFile = new Map<string, Match[]>();
|
|
384
|
+
for (const m of matches) {
|
|
385
|
+
const list = byFile.get(m.file) ?? [];
|
|
386
|
+
list.push(m);
|
|
387
|
+
byFile.set(m.file, list);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const byTag = new Map<string, number>();
|
|
391
|
+
for (const m of matches) {
|
|
392
|
+
byTag.set(m.tag, (byTag.get(m.tag) ?? 0) + 1);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const tagSummary = [...byTag.entries()]
|
|
396
|
+
.sort((a, b) => b[1] - a[1])
|
|
397
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
398
|
+
.join(", ");
|
|
399
|
+
|
|
400
|
+
const out: string[] = [];
|
|
401
|
+
out.push(
|
|
402
|
+
`soly_todos: ${matches.length} match(es) in ${byFile.size} file(s) — ${tagSummary}`,
|
|
403
|
+
);
|
|
404
|
+
out.push("");
|
|
405
|
+
for (const [file, list] of [...byFile.entries()].sort()) {
|
|
406
|
+
out.push(` ${file}`);
|
|
407
|
+
for (const m of list.slice(0, 5)) {
|
|
408
|
+
out.push(` L${m.line} [${m.tag}] ${m.text.slice(0, 120)}`);
|
|
409
|
+
}
|
|
410
|
+
if (list.length > 5) {
|
|
411
|
+
out.push(` ... and ${list.length - 5} more in this file`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (matches.length >= limit) {
|
|
415
|
+
out.push("");
|
|
416
|
+
out.push(`(hit limit of ${limit}; pass higher \`limit\` to see more)`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
content: [{ type: "text", text: out.join("\n") }],
|
|
421
|
+
details: {
|
|
422
|
+
count: matches.length,
|
|
423
|
+
files: byFile.size,
|
|
424
|
+
byTag: Object.fromEntries(byTag),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// soly_env — project environment summary
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
pi.registerTool({
|
|
435
|
+
name: "soly_env",
|
|
436
|
+
label: "soly env",
|
|
437
|
+
description:
|
|
438
|
+
"detect the project's runtime environment and return it as a one-screen summary. Use to answer 'what test runner?', 'is docker used here?', 'what's the package manager?' etc. Detects: package manager, runtimes (node/bun), key dependencies, available scripts (dev/build/test/lint/...), services (postgres/redis/etc from compose), tooling flags (ts/tests/docker/ci).",
|
|
439
|
+
parameters: Type.Object({}),
|
|
440
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
441
|
+
const env = detectEnv(ctx.cwd);
|
|
442
|
+
const lines: string[] = [];
|
|
443
|
+
lines.push("=== soly env ===");
|
|
444
|
+
lines.push("");
|
|
445
|
+
if (env.projectName) {
|
|
446
|
+
lines.push(`name: ${env.projectName}${env.projectVersion ? ` @ ${env.projectVersion}` : ""}`);
|
|
447
|
+
}
|
|
448
|
+
if (env.packageManager) lines.push(`pkg manager: ${env.packageManager}`);
|
|
449
|
+
if (env.runtimes.length > 0) lines.push(`runtimes: ${env.runtimes.join(", ")}`);
|
|
450
|
+
if (env.mainDependencies.length > 0)
|
|
451
|
+
lines.push(`key deps: ${env.mainDependencies.join(", ")}`);
|
|
452
|
+
if (env.scripts.length > 0) lines.push(`scripts: ${env.scripts.map((s) => `\`${s}\``).join(" ")}`);
|
|
453
|
+
const flags: string[] = [];
|
|
454
|
+
if (env.hasTypeScript) flags.push("TypeScript");
|
|
455
|
+
if (env.hasTests) flags.push("tests");
|
|
456
|
+
if (env.hasDocker) flags.push("docker");
|
|
457
|
+
if (env.hasCI) flags.push("ci");
|
|
458
|
+
if (flags.length > 0) lines.push(`tooling: ${flags.join(", ")}`);
|
|
459
|
+
if (env.services.length > 0) lines.push(`services: ${env.services.join(", ")}`);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
463
|
+
details: { ...env },
|
|
464
|
+
};
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ============================================================================
|
|
469
|
+
// soly_snippet — bounded file read with line numbers
|
|
470
|
+
// ============================================================================
|
|
471
|
+
|
|
472
|
+
pi.registerTool({
|
|
473
|
+
name: "soly_snippet",
|
|
474
|
+
label: "soly snippet",
|
|
475
|
+
description:
|
|
476
|
+
"read a bounded range of lines from a file with line numbers (lazy context). Use when you need a specific function/class/section without loading the whole file. Path is relative to cwd unless absolute. `offset` is 0-indexed start, `limit` defaults to 100 (cap 500). For .html files, pass `format=\"stripped\"` to strip HTML tags (text-only output, useful for intent docs).",
|
|
477
|
+
parameters: Type.Object({
|
|
478
|
+
path: Type.String({ description: "File path (relative to cwd or absolute)." }),
|
|
479
|
+
offset: Type.Optional(
|
|
480
|
+
Type.Number({ description: "0-indexed start line. Default 0." }),
|
|
481
|
+
),
|
|
482
|
+
limit: Type.Optional(
|
|
483
|
+
Type.Number({ description: "Max lines to return. Default 100, cap 500." }),
|
|
484
|
+
),
|
|
485
|
+
format: Type.Optional(
|
|
486
|
+
StringEnum(["raw", "stripped"] as const, {
|
|
487
|
+
description:
|
|
488
|
+
"Output format. \"raw\" returns content as-is. \"stripped\" removes HTML tags (for .html files). Default \"raw\".",
|
|
489
|
+
}),
|
|
490
|
+
),
|
|
491
|
+
}),
|
|
492
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
493
|
+
const requested = params.path;
|
|
494
|
+
const abs = path.isAbsolute(requested)
|
|
495
|
+
? requested
|
|
496
|
+
: path.resolve(ctx.cwd, requested);
|
|
497
|
+
const offset = params.offset && params.offset > 0 ? params.offset : 0;
|
|
498
|
+
const limit = params.limit && params.limit > 0 ? Math.min(params.limit, 500) : 100;
|
|
499
|
+
const format = params.format ?? "raw";
|
|
500
|
+
|
|
501
|
+
const result = readSnippet(abs, offset, limit);
|
|
502
|
+
if (!result) {
|
|
503
|
+
return {
|
|
504
|
+
content: [
|
|
505
|
+
{ type: "text", text: `soly_snippet: file not found: ${requested}` },
|
|
506
|
+
],
|
|
507
|
+
details: { error: "not_found" },
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Optional HTML strip
|
|
512
|
+
const isHtml = /\.(html?|htm)$/i.test(abs);
|
|
513
|
+
let lines = result.lines;
|
|
514
|
+
if (isHtml && format === "stripped") {
|
|
515
|
+
// Strip whole file (not line-by-line) so block-level tags collapse
|
|
516
|
+
// across line boundaries, then re-split.
|
|
517
|
+
const joined = result.lines.join("\n");
|
|
518
|
+
const stripped = stripHtml(joined);
|
|
519
|
+
lines = stripped.split(/\r?\n/);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const numbered = lines.map((l, i) => {
|
|
523
|
+
const lineNum = offset + i + 1;
|
|
524
|
+
return `${String(lineNum).padStart(5, " ")} ${l}`;
|
|
525
|
+
});
|
|
526
|
+
const header = `=== ${requested}${format === "stripped" ? " (stripped)" : ""} (lines ${offset + 1}–${offset + lines.length} of ${result.totalLines}) ===`;
|
|
527
|
+
const footer = result.outOfRange
|
|
528
|
+
? `\n(…${result.totalLines - (offset + lines.length)} more lines; pass higher \`offset\` to continue)`
|
|
529
|
+
: "";
|
|
530
|
+
return {
|
|
531
|
+
content: [{ type: "text", text: `${header}\n${numbered.join("\n")}${footer}` }],
|
|
532
|
+
details: {
|
|
533
|
+
path: abs,
|
|
534
|
+
offset,
|
|
535
|
+
lines: lines.length,
|
|
536
|
+
totalLines: result.totalLines,
|
|
537
|
+
format,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// soly_doc_search — search .md index for relevant docs
|
|
545
|
+
// ============================================================================
|
|
546
|
+
|
|
547
|
+
pi.registerTool({
|
|
548
|
+
name: "soly_doc_search",
|
|
549
|
+
label: "soly doc search",
|
|
550
|
+
description:
|
|
551
|
+
"search .md and .html files under cwd (excluding node_modules, dist, etc.) for a query. Use to discover relevant docs before loading a specific file with soly_snippet. Intent docs (from `.soly/docs/` and `.soly/phases/<N>/docs/`) are prioritized via a source-priority bonus — hits are tagged `[intent]`, `[phase-intent]`, or `[project]` so you can tell at a glance. `limit` defaults to 10 (cap 50).",
|
|
552
|
+
parameters: Type.Object({
|
|
553
|
+
query: Type.String({ description: "Search query (substring, case-insensitive)." }),
|
|
554
|
+
limit: Type.Optional(
|
|
555
|
+
Type.Number({ description: "Max hits. Default 10, cap 50." }),
|
|
556
|
+
),
|
|
557
|
+
}),
|
|
558
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
559
|
+
const limit = params.limit && params.limit > 0 ? Math.min(params.limit, 50) : 10;
|
|
560
|
+
const index = buildDocIndex(ctx.cwd);
|
|
561
|
+
const hits = searchDocs(index, params.query, limit);
|
|
562
|
+
|
|
563
|
+
if (hits.length === 0) {
|
|
564
|
+
return {
|
|
565
|
+
content: [
|
|
566
|
+
{
|
|
567
|
+
type: "text",
|
|
568
|
+
text: `soly_doc_search: no matches for "${params.query}" in ${index.length} indexed .md/.html file(s).`,
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
details: { count: 0, indexed: index.length },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const out: string[] = [];
|
|
576
|
+
out.push(`soly_doc_search: ${hits.length} hit(s) for "${params.query}" (${index.length} files indexed):`);
|
|
577
|
+
out.push("");
|
|
578
|
+
for (const h of hits) {
|
|
579
|
+
const tag =
|
|
580
|
+
h.entry.sourceKind === "intent"
|
|
581
|
+
? "[intent]"
|
|
582
|
+
: h.entry.sourceKind === "phase-intent"
|
|
583
|
+
? "[phase-intent]"
|
|
584
|
+
: "[project]";
|
|
585
|
+
out.push(` ${tag} ${h.entry.relPath} (score=${h.score})`);
|
|
586
|
+
if (h.entry.title) out.push(` title: ${h.entry.title}`);
|
|
587
|
+
if (h.entry.preview) out.push(` preview: ${h.entry.preview.slice(0, 140)}`);
|
|
588
|
+
for (const ex of h.excerpts) {
|
|
589
|
+
out.push(` match: ${ex}`);
|
|
590
|
+
}
|
|
591
|
+
out.push("");
|
|
592
|
+
}
|
|
593
|
+
out.push("Use soly_snippet(path=\"<relpath>\", offset=N, limit=M) to load a specific range.");
|
|
594
|
+
return {
|
|
595
|
+
content: [{ type: "text", text: out.join("\n") }],
|
|
596
|
+
details: { count: hits.length, indexed: index.length },
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ============================================================================
|
|
602
|
+
// soly_scratchpad — recent conversation summary
|
|
603
|
+
// ============================================================================
|
|
604
|
+
|
|
605
|
+
pi.registerTool({
|
|
606
|
+
name: "soly_scratchpad",
|
|
607
|
+
label: "soly scratchpad",
|
|
608
|
+
description:
|
|
609
|
+
"return a compact summary of the recent conversation (one line per turn: user prompt + first line of assistant response). Use to recover shared context after a long break, or to brief a sibling subagent without sharing the full session history. `limit` defaults to 20 user-turns (cap 50).",
|
|
610
|
+
parameters: Type.Object({
|
|
611
|
+
limit: Type.Optional(
|
|
612
|
+
Type.Number({
|
|
613
|
+
description: "Max user-turns to include. Default 20, cap 50.",
|
|
614
|
+
}),
|
|
615
|
+
),
|
|
616
|
+
}),
|
|
617
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
618
|
+
const limit =
|
|
619
|
+
params.limit && params.limit > 0
|
|
620
|
+
? Math.min(params.limit, SCRATCHPAD_LIMITS.max)
|
|
621
|
+
: SCRATCHPAD_LIMITS.default;
|
|
622
|
+
const branch = ctx.sessionManager.getBranch();
|
|
623
|
+
const pad = buildScratchpad(branch, limit);
|
|
624
|
+
|
|
625
|
+
if (pad.entries.length === 0) {
|
|
626
|
+
return {
|
|
627
|
+
content: [
|
|
628
|
+
{
|
|
629
|
+
type: "text",
|
|
630
|
+
text: `soly_scratchpad: no prior conversation (this is the first turn).`,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
details: { count: 0 },
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const out: string[] = [];
|
|
638
|
+
out.push(`soly_scratchpad: ${pad.turnCount} user-turn(s), ${pad.entries.length} message(s) total:`);
|
|
639
|
+
out.push("");
|
|
640
|
+
for (const e of pad.entries) {
|
|
641
|
+
const prefix = e.role === "user" ? "U" : e.role === "assistant" ? "A" : "T";
|
|
642
|
+
out.push(`[${e.turn}][${prefix}] ${e.summary}`);
|
|
643
|
+
}
|
|
644
|
+
out.push("");
|
|
645
|
+
out.push("Use this to recover context after a long break, or to brief a sibling subagent without sharing the full session history.");
|
|
646
|
+
return {
|
|
647
|
+
content: [{ type: "text", text: out.join("\n") }],
|
|
648
|
+
details: {
|
|
649
|
+
turnCount: pad.turnCount,
|
|
650
|
+
entryCount: pad.entries.length,
|
|
651
|
+
fromTurn: pad.fromTurn,
|
|
652
|
+
branchLength: pad.branchLength,
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ============================================================================
|
|
659
|
+
// soly_intent — refresh / list the project's 0-point intent docs
|
|
660
|
+
// ============================================================================
|
|
661
|
+
|
|
662
|
+
pi.registerTool({
|
|
663
|
+
name: "soly_intent",
|
|
664
|
+
label: "soly intent",
|
|
665
|
+
description:
|
|
666
|
+
"list the project's 0-point intent docs from `.soly/docs/` — the business/domain/tech-intent documents the user wrote BEFORE any soly plans. Use before planning, discussing, or executing a phase to ensure alignment with the user's vision. Supports `.md` and `.html` (parsed for `<title>`/`<h1>`/`<meta description>`). Phase-specific docs under `.soly/phases/<N>/docs/` are also included if present. Pass no arguments — this tool always returns the full list.",
|
|
667
|
+
parameters: Type.Object({}),
|
|
668
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
669
|
+
const docs = loadIntentDocs(ctx.cwd);
|
|
670
|
+
const { section } = buildIntentSection(docs);
|
|
671
|
+
|
|
672
|
+
if (docs.length === 0) {
|
|
673
|
+
return {
|
|
674
|
+
content: [
|
|
675
|
+
{
|
|
676
|
+
type: "text",
|
|
677
|
+
text:
|
|
678
|
+
`soly_intent: no docs found in ${path.join(ctx.cwd, ".soly", "docs")}.\n\n` +
|
|
679
|
+
`Project intent is the user's vision written BEFORE soly plans. If empty, ask the user to drop their intent docs (business glossary, design vision, domain concepts) into .soly/docs/ as .md or .html files, then run soly_intent again.`,
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
details: { count: 0 },
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Render the same section that goes into the system prompt, so the
|
|
687
|
+
// model sees a consistent view whether it asks or not.
|
|
688
|
+
const out: string[] = [];
|
|
689
|
+
out.push(`soly_intent: ${docs.length} document(s) in .soly/docs/:`);
|
|
690
|
+
out.push("");
|
|
691
|
+
out.push(section);
|
|
692
|
+
out.push("");
|
|
693
|
+
out.push(
|
|
694
|
+
"To read a specific doc, use soly_snippet(path=\".soly/docs/<name>\", offset=N, limit=M). For HTML files, pass format=\"stripped\" to get plain text.",
|
|
695
|
+
);
|
|
696
|
+
return {
|
|
697
|
+
content: [{ type: "text", text: out.join("\n") }],
|
|
698
|
+
details: { count: docs.length, paths: docs.map((d) => d.relPath) },
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// ============================================================================
|
|
704
|
+
// soly_ask_user — multiple-choice picker (for `soly discuss` interactive flow)
|
|
705
|
+
// ============================================================================
|
|
706
|
+
|
|
707
|
+
pi.registerTool({
|
|
708
|
+
name: "soly_ask_user",
|
|
709
|
+
label: "soly ask user",
|
|
710
|
+
description:
|
|
711
|
+
"Ask the user a multiple-choice question via pi's UI picker. Use this for progressive Q&A flows (e.g. `soly discuss <N>`). The first option is treated as the recommended answer — mark it with a ⭐ prefix in its label and add a 1-sentence rationale in the `rationale` parameter. Returns the chosen option text (or the custom string if `allowOther: true` and the user picked 'Other…'). The picker has ↑/↓/j/k navigation, Enter to confirm, Esc to cancel (cancelled → returns 'cancelled' in details).",
|
|
712
|
+
parameters: Type.Object({
|
|
713
|
+
title: Type.String({
|
|
714
|
+
description: "Short title shown above the picker (e.g. 'Q1: Session handling').",
|
|
715
|
+
}),
|
|
716
|
+
question: Type.String({
|
|
717
|
+
description: "The question to ask the user (one short sentence).",
|
|
718
|
+
}),
|
|
719
|
+
options: Type.Array(Type.String(), {
|
|
720
|
+
description:
|
|
721
|
+
"2-4 concrete options. Option #1 is the recommended answer (mark with ⭐ prefix in the label).",
|
|
722
|
+
}),
|
|
723
|
+
rationale: Type.Optional(
|
|
724
|
+
Type.String({
|
|
725
|
+
description:
|
|
726
|
+
"1-2 sentence note explaining why option #1 is recommended. Shown above the picker.",
|
|
727
|
+
}),
|
|
728
|
+
),
|
|
729
|
+
allowOther: Type.Optional(
|
|
730
|
+
Type.Boolean({
|
|
731
|
+
description:
|
|
732
|
+
"If true, appends an 'Other…' option that opens a text-input dialog for a custom answer. The custom string is returned in place of the option index.",
|
|
733
|
+
}),
|
|
734
|
+
),
|
|
735
|
+
}),
|
|
736
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
737
|
+
if (!ctx.hasUI) {
|
|
738
|
+
return {
|
|
739
|
+
content: [
|
|
740
|
+
{
|
|
741
|
+
type: "text",
|
|
742
|
+
text: "soly_ask_user requires a UI-capable session (TUI or RPC mode). Run `soly discuss <N>` from the interactive pi TUI.",
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
details: { error: "no_ui", mode: ctx.mode },
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (params.options.length < 2) {
|
|
749
|
+
return {
|
|
750
|
+
content: [
|
|
751
|
+
{ type: "text", text: "soly_ask_user: need at least 2 options" },
|
|
752
|
+
],
|
|
753
|
+
details: { error: "too_few_options" },
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (params.options.length > 4) {
|
|
757
|
+
return {
|
|
758
|
+
content: [
|
|
759
|
+
{
|
|
760
|
+
type: "text",
|
|
761
|
+
text: "soly_ask_user: 2-4 options recommended (>4 hurts the picker UX)",
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
details: { error: "too_many_options" },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Build the picker title — include the question + optional rationale.
|
|
769
|
+
const headerLines: string[] = [params.title];
|
|
770
|
+
headerLines.push("");
|
|
771
|
+
headerLines.push(params.question);
|
|
772
|
+
if (params.rationale) {
|
|
773
|
+
headerLines.push("");
|
|
774
|
+
headerLines.push(`💡 ${params.rationale}`);
|
|
775
|
+
}
|
|
776
|
+
const pickerTitle = headerLines.join("\n");
|
|
777
|
+
|
|
778
|
+
const displayOptions = params.allowOther
|
|
779
|
+
? [...params.options, "Other…"]
|
|
780
|
+
: params.options;
|
|
781
|
+
|
|
782
|
+
const choice = await ctx.ui.select(pickerTitle, displayOptions);
|
|
783
|
+
if (choice === undefined) {
|
|
784
|
+
return {
|
|
785
|
+
content: [
|
|
786
|
+
{
|
|
787
|
+
type: "text",
|
|
788
|
+
text: "(user cancelled the picker — defer this question)",
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
details: { cancelled: true },
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// "Other…" picked → open a text input dialog
|
|
796
|
+
if (params.allowOther && choice === "Other…") {
|
|
797
|
+
const customText = await ctx.ui.input(
|
|
798
|
+
`${params.title} — custom answer`,
|
|
799
|
+
"Type your answer…",
|
|
800
|
+
);
|
|
801
|
+
if (customText === undefined) {
|
|
802
|
+
return {
|
|
803
|
+
content: [
|
|
804
|
+
{
|
|
805
|
+
type: "text",
|
|
806
|
+
text: "(user cancelled custom input — defer this question)",
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
details: { cancelled: true },
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
const trimmed = customText.trim();
|
|
813
|
+
if (trimmed === "") {
|
|
814
|
+
return {
|
|
815
|
+
content: [
|
|
816
|
+
{
|
|
817
|
+
type: "text",
|
|
818
|
+
text: "(user submitted empty input — defer this question)",
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
details: { cancelled: true },
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
content: [
|
|
826
|
+
{
|
|
827
|
+
type: "text",
|
|
828
|
+
text: `User chose [Other]: "${trimmed}"`,
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
details: { choice: "other", customText: trimmed },
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const chosenIndex = displayOptions.indexOf(choice);
|
|
836
|
+
return {
|
|
837
|
+
content: [
|
|
838
|
+
{
|
|
839
|
+
type: "text",
|
|
840
|
+
text: `User chose: ${choice}${chosenIndex === 0 ? " (recommended)" : ""}`,
|
|
841
|
+
},
|
|
842
|
+
],
|
|
843
|
+
details: { choice, chosenIndex, allOptions: displayOptions },
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// ============================================================================
|
|
849
|
+
// soly_finish_discuss — finalize a phase discussion (writes CONTEXT.md)
|
|
850
|
+
// ============================================================================
|
|
851
|
+
|
|
852
|
+
pi.registerTool({
|
|
853
|
+
name: "soly_finish_discuss",
|
|
854
|
+
label: "soly finish discuss",
|
|
855
|
+
description:
|
|
856
|
+
"Finalize a `soly discuss <N>` session: write the canonical `<phase>-CONTEXT.md` with all captured decisions, and delete the checkpoint file (if any). Call this AFTER asking all gray-area questions and recording the user's answers via `soly_ask_user`. Do NOT call this for partial progress — use the checkpoint file (auto-saved by soly_finish_discuss_checkpoint) for resume.",
|
|
857
|
+
parameters: Type.Object({
|
|
858
|
+
phase_number: Type.Number({
|
|
859
|
+
description: "Phase number being discussed (e.g. 5).",
|
|
860
|
+
}),
|
|
861
|
+
domain: Type.String({
|
|
862
|
+
description:
|
|
863
|
+
"1-2 paragraphs: what this phase delivers, grounded in ROADMAP + intent. No implementation details.",
|
|
864
|
+
}),
|
|
865
|
+
decisions: Type.Array(
|
|
866
|
+
Type.Object({
|
|
867
|
+
category: Type.String({
|
|
868
|
+
description:
|
|
869
|
+
"Category of the decision (e.g. 'Session handling', 'Error responses', 'Token storage').",
|
|
870
|
+
}),
|
|
871
|
+
choice: Type.String({ description: "What was chosen." }),
|
|
872
|
+
rationale: Type.Optional(
|
|
873
|
+
Type.String({
|
|
874
|
+
description: "Why this choice — default 'user discretion' if not specified.",
|
|
875
|
+
}),
|
|
876
|
+
),
|
|
877
|
+
}),
|
|
878
|
+
{ description: "All decisions captured during this discussion round." },
|
|
879
|
+
),
|
|
880
|
+
canonical_refs: Type.Optional(
|
|
881
|
+
Type.Array(Type.String(), {
|
|
882
|
+
description:
|
|
883
|
+
"MANDATORY. List of files the planner will need (intent docs, REQUIREMENTS, contracts, prior CONTEXT.md). Full relative paths starting with `.soly/`.",
|
|
884
|
+
}),
|
|
885
|
+
),
|
|
886
|
+
deferred_ideas: Type.Optional(
|
|
887
|
+
Type.Array(Type.String(), {
|
|
888
|
+
description: "Scope-creep items for future phases (or empty array).",
|
|
889
|
+
}),
|
|
890
|
+
),
|
|
891
|
+
codebase_context: Type.Optional(
|
|
892
|
+
Type.Array(Type.String(), {
|
|
893
|
+
description:
|
|
894
|
+
"Reusable assets/patterns the planner should know (e.g. 'src/components/Card.tsx — already has rounded/shadow variants, reuse for consistency').",
|
|
895
|
+
}),
|
|
896
|
+
),
|
|
897
|
+
}),
|
|
898
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
899
|
+
// Locate phase dir
|
|
900
|
+
const solyDir = path.join(ctx.cwd, ".soly");
|
|
901
|
+
if (!fs.existsSync(solyDir)) {
|
|
902
|
+
return {
|
|
903
|
+
content: [{ type: "text", text: "soly_finish_discuss: no .soly/ in cwd" }],
|
|
904
|
+
details: { error: "no_soly" },
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
const phaseNum = params.phase_number;
|
|
908
|
+
const phasesRoot = path.join(solyDir, "phases");
|
|
909
|
+
let phaseDir: string | null = null;
|
|
910
|
+
if (fs.existsSync(phasesRoot)) {
|
|
911
|
+
for (const entry of fs.readdirSync(phasesRoot, { withFileTypes: true })) {
|
|
912
|
+
if (!entry.isDirectory()) continue;
|
|
913
|
+
const m = entry.name.match(/^0*(\d+)/);
|
|
914
|
+
if (m && parseInt(m[1]!, 10) === phaseNum) {
|
|
915
|
+
phaseDir = path.join(phasesRoot, entry.name);
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!phaseDir) {
|
|
921
|
+
return {
|
|
922
|
+
content: [
|
|
923
|
+
{
|
|
924
|
+
type: "text",
|
|
925
|
+
text: `soly_finish_discuss: phase ${phaseNum} not found in .soly/phases/`,
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
details: { error: "no_phase", phase: phaseNum },
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const padded = String(phaseNum).padStart(2, "0");
|
|
933
|
+
const slug = path
|
|
934
|
+
.basename(phaseDir)
|
|
935
|
+
.replace(/^\d+-?/, "")
|
|
936
|
+
.trim();
|
|
937
|
+
const generatedAt = new Date().toISOString();
|
|
938
|
+
|
|
939
|
+
// Build CONTEXT.md
|
|
940
|
+
const lines: string[] = [];
|
|
941
|
+
lines.push("---");
|
|
942
|
+
lines.push(`phase: ${phaseNum} phase_slug: ${slug} generated: ${generatedAt}`);
|
|
943
|
+
lines.push(
|
|
944
|
+
`areas_completed: ${params.decisions.length} areas_deferred: ${(params.deferred_ideas ?? []).length}`,
|
|
945
|
+
);
|
|
946
|
+
lines.push("---");
|
|
947
|
+
lines.push("");
|
|
948
|
+
lines.push(`# ${phaseNum}: ${slug || "Phase"} — Discussion Context`);
|
|
949
|
+
lines.push("");
|
|
950
|
+
lines.push(`<domain>${params.domain}</domain>`);
|
|
951
|
+
lines.push("");
|
|
952
|
+
|
|
953
|
+
if (params.decisions.length > 0) {
|
|
954
|
+
lines.push("<decisions>");
|
|
955
|
+
// Group by category
|
|
956
|
+
const byCategory = new Map<string, typeof params.decisions>();
|
|
957
|
+
for (const d of params.decisions) {
|
|
958
|
+
const list = byCategory.get(d.category) ?? [];
|
|
959
|
+
list.push(d);
|
|
960
|
+
byCategory.set(d.category, list);
|
|
961
|
+
}
|
|
962
|
+
for (const [cat, list] of byCategory) {
|
|
963
|
+
lines.push(`### ${cat}`);
|
|
964
|
+
for (const d of list) {
|
|
965
|
+
lines.push(`- **Decision:** ${d.choice}`);
|
|
966
|
+
lines.push(` **Rationale:** ${d.rationale ?? "user discretion"}`);
|
|
967
|
+
lines.push(` **Source:** soly discuss ${phaseNum} (soly_finish_discuss)`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
lines.push("</decisions>");
|
|
971
|
+
lines.push("");
|
|
972
|
+
} else {
|
|
973
|
+
lines.push(
|
|
974
|
+
"<decisions>_(No decisions captured — discussion may have been deferred. See <deferred_ideas>.)_</decisions>",
|
|
975
|
+
);
|
|
976
|
+
lines.push("");
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
lines.push("<canonical_refs> <!-- MANDATORY -->");
|
|
980
|
+
const refs = params.canonical_refs ?? [];
|
|
981
|
+
if (refs.length > 0) {
|
|
982
|
+
for (const ref of refs) {
|
|
983
|
+
lines.push(`- \`${ref}\` — referenced from discuss`);
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
lines.push("- (no external docs referenced)");
|
|
987
|
+
}
|
|
988
|
+
lines.push("</canonical_refs>");
|
|
989
|
+
lines.push("");
|
|
990
|
+
|
|
991
|
+
if ((params.codebase_context ?? []).length > 0) {
|
|
992
|
+
lines.push("<codebase_context>");
|
|
993
|
+
lines.push("Reusable assets/patterns the planner should know:");
|
|
994
|
+
for (const c of params.codebase_context!) {
|
|
995
|
+
lines.push(`- ${c}`);
|
|
996
|
+
}
|
|
997
|
+
lines.push("</codebase_context>");
|
|
998
|
+
lines.push("");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if ((params.deferred_ideas ?? []).length > 0) {
|
|
1002
|
+
lines.push("<deferred_ideas>");
|
|
1003
|
+
for (const d of params.deferred_ideas!) {
|
|
1004
|
+
lines.push(`- ${d}`);
|
|
1005
|
+
}
|
|
1006
|
+
lines.push("</deferred_ideas>");
|
|
1007
|
+
lines.push("");
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const contextPath = path.join(phaseDir, `${padded}-CONTEXT.md`);
|
|
1011
|
+
atomicWriteFileSync(contextPath, lines.join("\n"), "utf-8");
|
|
1012
|
+
|
|
1013
|
+
// Delete checkpoint if exists
|
|
1014
|
+
const checkpointPath = path.join(phaseDir, `${padded}-DISCUSS-CHECKPOINT.json`);
|
|
1015
|
+
let deletedCheckpoint = false;
|
|
1016
|
+
if (fs.existsSync(checkpointPath)) {
|
|
1017
|
+
try {
|
|
1018
|
+
fs.unlinkSync(checkpointPath);
|
|
1019
|
+
deletedCheckpoint = true;
|
|
1020
|
+
} catch {
|
|
1021
|
+
// best effort
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return {
|
|
1026
|
+
content: [
|
|
1027
|
+
{
|
|
1028
|
+
type: "text",
|
|
1029
|
+
text: `Discussion complete for phase ${phaseNum} (${slug || "phase"}).\n\nWrote: \`${path.relative(ctx.cwd, contextPath)}\`\nDecisions: ${params.decisions.length}\nDeferred: ${(params.deferred_ideas ?? []).length}\nCheckpoint cleaned up: ${deletedCheckpoint}\n\nNext step: \`soly plan ${phaseNum}\``,
|
|
1030
|
+
},
|
|
1031
|
+
],
|
|
1032
|
+
details: {
|
|
1033
|
+
contextPath,
|
|
1034
|
+
decisionsCount: params.decisions.length,
|
|
1035
|
+
deferredCount: (params.deferred_ideas ?? []).length,
|
|
1036
|
+
deletedCheckpoint,
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// ============================================================================
|
|
1043
|
+
// soly_save_discuss_checkpoint — partial progress, for resume after a quit
|
|
1044
|
+
// ============================================================================
|
|
1045
|
+
|
|
1046
|
+
pi.registerTool({
|
|
1047
|
+
name: "soly_save_discuss_checkpoint",
|
|
1048
|
+
label: "soly save discuss checkpoint",
|
|
1049
|
+
description:
|
|
1050
|
+
"Save a partial-progress checkpoint for `soly discuss <N>`. Call after each captured decision (so a session quit doesn't lose progress). The next `soly discuss <N>` invocation will pick up from the checkpoint. After all decisions captured, call `soly_finish_discuss` (which deletes the checkpoint and writes CONTEXT.md).",
|
|
1051
|
+
parameters: Type.Object({
|
|
1052
|
+
phase_number: Type.Number({ description: "Phase number being discussed." }),
|
|
1053
|
+
decisions: Type.Array(
|
|
1054
|
+
Type.Object({
|
|
1055
|
+
category: Type.String({ description: "Decision category." }),
|
|
1056
|
+
choice: Type.String({ description: "What was chosen." }),
|
|
1057
|
+
rationale: Type.Optional(Type.String()),
|
|
1058
|
+
}),
|
|
1059
|
+
{ description: "Decisions captured so far this session." },
|
|
1060
|
+
),
|
|
1061
|
+
areas_total: Type.Optional(
|
|
1062
|
+
Type.Number({ description: "Total gray areas planned for this discussion (for progress display)." }),
|
|
1063
|
+
),
|
|
1064
|
+
areas_completed: Type.Optional(
|
|
1065
|
+
Type.Array(Type.Number(), { description: "0-based indices of completed areas." }),
|
|
1066
|
+
),
|
|
1067
|
+
}),
|
|
1068
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1069
|
+
const solyDir = path.join(ctx.cwd, ".soly");
|
|
1070
|
+
const phasesRoot = path.join(solyDir, "phases");
|
|
1071
|
+
if (!fs.existsSync(phasesRoot)) {
|
|
1072
|
+
return {
|
|
1073
|
+
content: [{ type: "text", text: "soly_save_discuss_checkpoint: no .soly/phases/ in cwd" }],
|
|
1074
|
+
details: { error: "no_phases" },
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
const phaseNum = params.phase_number;
|
|
1078
|
+
let phaseDir: string | null = null;
|
|
1079
|
+
let phaseSlug = "";
|
|
1080
|
+
for (const entry of fs.readdirSync(phasesRoot, { withFileTypes: true })) {
|
|
1081
|
+
if (!entry.isDirectory()) continue;
|
|
1082
|
+
const m = entry.name.match(/^0*(\d+)-?(.*)$/);
|
|
1083
|
+
if (m && parseInt(m[1]!, 10) === phaseNum) {
|
|
1084
|
+
phaseDir = path.join(phasesRoot, entry.name);
|
|
1085
|
+
phaseSlug = m[2] ?? "";
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (!phaseDir) {
|
|
1090
|
+
return {
|
|
1091
|
+
content: [
|
|
1092
|
+
{ type: "text", text: `soly_save_discuss_checkpoint: phase ${phaseNum} not found` },
|
|
1093
|
+
],
|
|
1094
|
+
details: { error: "no_phase", phase: phaseNum },
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const padded = String(phaseNum).padStart(2, "0");
|
|
1099
|
+
const checkpointPath = path.join(phaseDir, `${padded}-DISCUSS-CHECKPOINT.json`);
|
|
1100
|
+
const checkpoint = {
|
|
1101
|
+
version: "1.0",
|
|
1102
|
+
phase: phaseNum,
|
|
1103
|
+
padded_phase: padded,
|
|
1104
|
+
phase_slug: phaseSlug,
|
|
1105
|
+
phase_dir: phaseDir,
|
|
1106
|
+
round: 1,
|
|
1107
|
+
areas_total: params.areas_total ?? null,
|
|
1108
|
+
areas_completed: params.areas_completed ?? [],
|
|
1109
|
+
areas_deferred: [],
|
|
1110
|
+
decisions: params.decisions,
|
|
1111
|
+
generated_at: new Date().toISOString(),
|
|
1112
|
+
next_action: "await_user_answers",
|
|
1113
|
+
};
|
|
1114
|
+
atomicWriteFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2), "utf-8");
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
content: [
|
|
1118
|
+
{
|
|
1119
|
+
type: "text",
|
|
1120
|
+
text: `Checkpoint saved (${params.decisions.length} decision(s)). Next \`soly discuss ${phaseNum}\` will resume from here.`,
|
|
1121
|
+
},
|
|
1122
|
+
],
|
|
1123
|
+
details: {
|
|
1124
|
+
checkpointPath,
|
|
1125
|
+
decisionsCount: params.decisions.length,
|
|
1126
|
+
areasCompleted: params.areas_completed?.length ?? 0,
|
|
1127
|
+
areasTotal: params.areas_total ?? null,
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
}
|