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/iteration.ts
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// iteration.ts — Per-iteration context bundle (B2 of the soly design)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Writes a self-contained .md file under `.soly/iterations/` that bundles
|
|
6
|
+
// everything a worker needs for ONE iteration of a plan / task / phase:
|
|
7
|
+
// intent docs, STATE.md, ROADMAP row, phase CONTEXT, phase RESEARCH, up
|
|
8
|
+
// to 3 prior SUMMARYs, and the current PLAN.md (for exec).
|
|
9
|
+
//
|
|
10
|
+
// Why a file (and not just system-prompt injection):
|
|
11
|
+
// - The bundle is too large for system prompt (intent + CONTEXT +
|
|
12
|
+
// RESEARCH + prior SUMMARYs + current PLAN ≈ 6–8k tokens)
|
|
13
|
+
// - File is auditable — humans can inspect what context a worker had
|
|
14
|
+
// - Resume after `soly pause` is reliable: file persists across sessions
|
|
15
|
+
// - Worker can re-read sections it forgot without bothering the user
|
|
16
|
+
//
|
|
17
|
+
// Plus a small inline summary (must_haves + anti-patterns) is included in
|
|
18
|
+
// the worker's task string itself, so the worker has the most critical
|
|
19
|
+
// bits available even before opening the file.
|
|
20
|
+
//
|
|
21
|
+
// All worktree writes go to `.soly/iterations/` (under the project's
|
|
22
|
+
// `.soly/`), never to the project root. See workflow markdown templates
|
|
23
|
+
// for the hard rule.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { atomicWriteFileSync, readIfExists } from "./core.js";
|
|
29
|
+
|
|
30
|
+
export type IterationKind = "exec" | "plan" | "discuss" | "pause";
|
|
31
|
+
|
|
32
|
+
export interface IterationInput {
|
|
33
|
+
solyDir: string;
|
|
34
|
+
projectRoot: string;
|
|
35
|
+
kind: IterationKind;
|
|
36
|
+
/** Phase number (for plan / discuss / exec-phase). */
|
|
37
|
+
phaseNumber?: number;
|
|
38
|
+
/** Plan number within phase (only for kind=exec of a phase plan). */
|
|
39
|
+
planNumber?: number;
|
|
40
|
+
/** Task id (only for task-mode execution / planning). */
|
|
41
|
+
taskId?: string;
|
|
42
|
+
/** Feature name (only for task-mode). Backfilled from disk if omitted. */
|
|
43
|
+
feature?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IterationOutput {
|
|
47
|
+
/** Absolute path to the written file. */
|
|
48
|
+
filePath: string;
|
|
49
|
+
/** Path relative to projectRoot (for display in prompts). */
|
|
50
|
+
relPath: string;
|
|
51
|
+
/** Filename only. */
|
|
52
|
+
fileName: string;
|
|
53
|
+
/** File size in bytes. */
|
|
54
|
+
bytes: number;
|
|
55
|
+
/** Estimated tokens. */
|
|
56
|
+
tokens: number;
|
|
57
|
+
/** Generation timestamp (ISO 8601). */
|
|
58
|
+
generatedAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ----------------------------------------------------------------------------
|
|
62
|
+
// Section token budgets. Total target: ~6500 tokens (fits comfortably in
|
|
63
|
+
// worker's context window; worker can re-read source files for full content).
|
|
64
|
+
// ----------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const SECTION_BUDGETS = {
|
|
67
|
+
intent: 500,
|
|
68
|
+
state: 400,
|
|
69
|
+
roadmap: 400,
|
|
70
|
+
context: 1500,
|
|
71
|
+
research: 1500,
|
|
72
|
+
summaries: 1800, // up to 3 × 600
|
|
73
|
+
plan: 2500,
|
|
74
|
+
antipatterns: 500,
|
|
75
|
+
featureReadme: 800,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/** Slug-shaped ISO timestamp suitable for filenames (e.g. 20260614T201530Z). */
|
|
79
|
+
export function timestampSlug(): string {
|
|
80
|
+
return new Date()
|
|
81
|
+
.toISOString()
|
|
82
|
+
.replace(/[-:]/g, "")
|
|
83
|
+
.replace(/\.\d+Z$/, "Z");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Truncate text to roughly N tokens (1 token ≈ 4 chars). */
|
|
87
|
+
function truncate(text: string, maxTokens: number, note?: string): string {
|
|
88
|
+
const maxChars = maxTokens * 4;
|
|
89
|
+
if (text.length <= maxChars) return text;
|
|
90
|
+
const footer = note
|
|
91
|
+
? `\n\n<!-- ${note}; truncated at ${maxTokens} tokens (${text.length} chars) -->`
|
|
92
|
+
: `\n\n<!-- truncated at ${maxTokens} tokens (${text.length} chars) -->`;
|
|
93
|
+
return text.slice(0, maxChars) + footer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Make an absolute path relative to projectRoot for display. */
|
|
97
|
+
function rel(projectRoot: string, abs: string): string {
|
|
98
|
+
const r = path.relative(projectRoot, abs);
|
|
99
|
+
return r.startsWith("..") ? abs : r;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ----------------------------------------------------------------------------
|
|
103
|
+
// Disk lookups
|
|
104
|
+
// ----------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** Find the phase directory `<solyDir>/phases/<NN>-<slug>/` matching phaseNumber. */
|
|
107
|
+
export function findPhaseDir(solyDir: string, phaseNumber: number): string | null {
|
|
108
|
+
const phasesRoot = path.join(solyDir, "phases");
|
|
109
|
+
if (!fs.existsSync(phasesRoot)) return null;
|
|
110
|
+
for (const entry of fs.readdirSync(phasesRoot, { withFileTypes: true })) {
|
|
111
|
+
if (!entry.isDirectory()) continue;
|
|
112
|
+
const m = entry.name.match(/^(\d+)/);
|
|
113
|
+
if (m && parseInt(m[1], 10) === phaseNumber) {
|
|
114
|
+
return path.join(phasesRoot, entry.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Find the task directory for a given task id. */
|
|
121
|
+
export function findTaskDir(
|
|
122
|
+
solyDir: string,
|
|
123
|
+
taskId: string,
|
|
124
|
+
): { dir: string; feature: string } | null {
|
|
125
|
+
const featuresRoot = path.join(solyDir, "features");
|
|
126
|
+
if (!fs.existsSync(featuresRoot)) return null;
|
|
127
|
+
for (const f of fs.readdirSync(featuresRoot, { withFileTypes: true })) {
|
|
128
|
+
if (!f.isDirectory()) continue;
|
|
129
|
+
const taskDir = path.join(featuresRoot, f.name, "tasks", taskId);
|
|
130
|
+
if (fs.existsSync(taskDir)) return { dir: taskDir, feature: f.name };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Find the PLAN.md file for a given plan number within a phase dir. */
|
|
136
|
+
export function findPlanFile(phaseDir: string, planNumber: number): string | null {
|
|
137
|
+
const padded = String(planNumber).padStart(2, "0");
|
|
138
|
+
let files: string[];
|
|
139
|
+
try {
|
|
140
|
+
files = fs.readdirSync(phaseDir);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const re = new RegExp(`^\\d+-${padded}-.+-PLAN\\.md$`);
|
|
145
|
+
const match = files.find((f) => re.test(f));
|
|
146
|
+
return match ? path.join(phaseDir, match) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Find the most recent N SUMMARY.md files in a phase dir, oldest-first. */
|
|
150
|
+
export function findRecentSummaries(dir: string, n: number): string[] {
|
|
151
|
+
let files: string[];
|
|
152
|
+
try {
|
|
153
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith("-SUMMARY.md"));
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
files.sort();
|
|
158
|
+
return files.slice(-n).map((f) => path.join(dir, f));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Find a phase's `<NN>-CONTEXT.md` path (or null).
|
|
162
|
+
* Convention: filename starts with the padded phase number, not the full
|
|
163
|
+
* slug. e.g. `.soly/phases/05-auth/05-CONTEXT.md`, not `05-auth-CONTEXT.md`. */
|
|
164
|
+
export function findPhaseContextPath(phaseDir: string): string | null {
|
|
165
|
+
const slug = path.basename(phaseDir);
|
|
166
|
+
const numMatch = slug.match(/^(\d+)/);
|
|
167
|
+
if (!numMatch) return null;
|
|
168
|
+
const padded = numMatch[1]!.padStart(2, "0");
|
|
169
|
+
const p = path.join(phaseDir, `${padded}-CONTEXT.md`);
|
|
170
|
+
return fs.existsSync(p) ? p : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Find a phase's `<NN>-RESEARCH.md` path (or null). */
|
|
174
|
+
export function findPhaseResearchPath(phaseDir: string): string | null {
|
|
175
|
+
const slug = path.basename(phaseDir);
|
|
176
|
+
const numMatch = slug.match(/^(\d+)/);
|
|
177
|
+
if (!numMatch) return null;
|
|
178
|
+
const padded = numMatch[1]!.padStart(2, "0");
|
|
179
|
+
const p = path.join(phaseDir, `${padded}-RESEARCH.md`);
|
|
180
|
+
return fs.existsSync(p) ? p : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Find a phase's `.continue-here.md` path (or null). */
|
|
184
|
+
export function findContinueHerePath(phaseDir: string): string | null {
|
|
185
|
+
const p = path.join(phaseDir, ".continue-here.md");
|
|
186
|
+
return fs.existsSync(p) ? p : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ----------------------------------------------------------------------------
|
|
190
|
+
// Section loaders (each returns the section body, already truncated)
|
|
191
|
+
// ----------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
function loadIntentSummary(solyDir: string, maxTokens: number): string {
|
|
194
|
+
const docsRoot = path.join(solyDir, "docs");
|
|
195
|
+
if (!fs.existsSync(docsRoot)) return "_(no \`.soly/docs/\` directory — drop your 0-point docs there)_";
|
|
196
|
+
|
|
197
|
+
const files: string[] = [];
|
|
198
|
+
for (const e of fs.readdirSync(docsRoot, { withFileTypes: true })) {
|
|
199
|
+
if (e.name.startsWith(".")) continue;
|
|
200
|
+
const full = path.join(docsRoot, e.name);
|
|
201
|
+
if (e.isDirectory()) {
|
|
202
|
+
for (const sub of fs.readdirSync(full, { withFileTypes: true })) {
|
|
203
|
+
if (sub.isFile() && sub.name.endsWith(".md")) files.push(path.join(full, sub.name));
|
|
204
|
+
}
|
|
205
|
+
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
206
|
+
files.push(full);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (files.length === 0) return "_(no \`.md\` files in \`.soly/docs/\`)_";
|
|
210
|
+
|
|
211
|
+
files.sort();
|
|
212
|
+
const out: string[] = [];
|
|
213
|
+
let usedChars = 0;
|
|
214
|
+
const maxChars = maxTokens * 4;
|
|
215
|
+
for (const f of files) {
|
|
216
|
+
const relPath = path.relative(solyDir, f);
|
|
217
|
+
const body = readIfExists(f) ?? "";
|
|
218
|
+
const preview = body
|
|
219
|
+
.replace(/^---[\s\S]*?---\r?\n/, "")
|
|
220
|
+
.split(/\r?\n/)
|
|
221
|
+
.filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("```"))
|
|
222
|
+
.slice(0, 6)
|
|
223
|
+
.join(" ");
|
|
224
|
+
const chunk = `- \`${relPath}\`: ${preview.slice(0, 240)}\n`;
|
|
225
|
+
if (usedChars + chunk.length > maxChars) {
|
|
226
|
+
out.push(`\n_(truncated at ${maxTokens} tokens; full files in \`.soly/docs/\`)_\n`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
out.push(chunk);
|
|
230
|
+
usedChars += chunk.length;
|
|
231
|
+
}
|
|
232
|
+
return out.join("");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function loadStateSection(solyDir: string, maxTokens: number): string {
|
|
236
|
+
const statePath = path.join(solyDir, "STATE.md");
|
|
237
|
+
const raw = readIfExists(statePath);
|
|
238
|
+
if (!raw) return "_(no \`STATE.md\`) — initialize with \`soly init\` or create manually_";
|
|
239
|
+
|
|
240
|
+
// Prefer "Current Position" + "## Decisions" + top 20 lines of frontmatter
|
|
241
|
+
const lines = raw.split(/\r?\n/);
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
let inSection = "";
|
|
244
|
+
let buf: string[] = [];
|
|
245
|
+
|
|
246
|
+
const flush = () => {
|
|
247
|
+
if (buf.length > 0) {
|
|
248
|
+
out.push(buf.join("\n"));
|
|
249
|
+
buf = [];
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
for (const l of lines) {
|
|
254
|
+
if (l.match(/^##\s*Current Position/i)) {
|
|
255
|
+
flush();
|
|
256
|
+
inSection = "current";
|
|
257
|
+
buf.push(l);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (l.match(/^##\s*Decisions/i)) {
|
|
261
|
+
flush();
|
|
262
|
+
inSection = "decisions";
|
|
263
|
+
buf.push(l);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (l.match(/^##\s/) && (inSection === "current" || inSection === "decisions")) {
|
|
267
|
+
flush();
|
|
268
|
+
inSection = "";
|
|
269
|
+
}
|
|
270
|
+
if (inSection) buf.push(l);
|
|
271
|
+
}
|
|
272
|
+
flush();
|
|
273
|
+
|
|
274
|
+
const combined = out.length > 0 ? out.join("\n\n") : truncate(raw, maxTokens);
|
|
275
|
+
return truncate(combined.trim(), maxTokens);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function loadRoadmapRow(solyDir: string, phaseNumber: number, maxTokens: number): string {
|
|
279
|
+
const roadmapPath = path.join(solyDir, "ROADMAP.md");
|
|
280
|
+
const raw = readIfExists(roadmapPath);
|
|
281
|
+
if (!raw) return "_(no \`ROADMAP.md\`)_";
|
|
282
|
+
|
|
283
|
+
const lines = raw.split(/\r?\n/);
|
|
284
|
+
// Match `## Phase <N>`, `## Phase <NN>`, `## <N>`, `## <NN>`, `## <N>-...`
|
|
285
|
+
// The phase number is matched with optional zero-padding, the "Phase"
|
|
286
|
+
// prefix is optional, the separator is `:` / `-` / space.
|
|
287
|
+
const re = new RegExp(
|
|
288
|
+
`^#{2,4}\\s+(?:Phase\\s+)?0*${phaseNumber}(?:[\\s:\\-.]|$)`,
|
|
289
|
+
"i",
|
|
290
|
+
);
|
|
291
|
+
const startIdx = lines.findIndex((l) => re.test(l));
|
|
292
|
+
if (startIdx === -1) return truncate(raw, maxTokens);
|
|
293
|
+
|
|
294
|
+
const out: string[] = [];
|
|
295
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
296
|
+
const l = lines[i]!;
|
|
297
|
+
if (i > startIdx && /^##\s/.test(l)) break;
|
|
298
|
+
out.push(l);
|
|
299
|
+
}
|
|
300
|
+
return truncate(out.join("\n"), maxTokens);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function loadPhaseContext(phaseDir: string, maxTokens: number): string {
|
|
304
|
+
const p = findPhaseContextPath(phaseDir);
|
|
305
|
+
if (!p) return "_(no \`CONTEXT.md\` for this phase — run \`soly discuss <N>\` first if decisions are missing)_";
|
|
306
|
+
return truncate(readIfExists(p) ?? "", maxTokens);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function loadPhaseResearch(phaseDir: string, maxTokens: number): string {
|
|
310
|
+
const p = findPhaseResearchPath(phaseDir);
|
|
311
|
+
if (!p) return "_(no \`RESEARCH.md\` for this phase yet)_";
|
|
312
|
+
return truncate(readIfExists(p) ?? "", maxTokens);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function loadPlanFile(planFile: string, maxTokens: number): string {
|
|
316
|
+
return truncate(readIfExists(planFile) ?? "", maxTokens);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function loadPhaseSummaries(phaseDir: string, n: number, perSummaryTokens: number): string {
|
|
320
|
+
const files = findRecentSummaries(phaseDir, n);
|
|
321
|
+
if (files.length === 0) return "_(no prior \`SUMMARY.md\` files in this phase)_";
|
|
322
|
+
return files
|
|
323
|
+
.map((f) => `### ${path.basename(f)}\n\n${truncate(readIfExists(f) ?? "", perSummaryTokens)}`)
|
|
324
|
+
.join("\n\n---\n\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function loadFeatureTaskSummaries(
|
|
328
|
+
taskDir: string,
|
|
329
|
+
n: number,
|
|
330
|
+
perSummaryTokens: number,
|
|
331
|
+
): string {
|
|
332
|
+
const featureDir = path.dirname(path.dirname(taskDir));
|
|
333
|
+
const tasksDir = path.join(featureDir, "tasks");
|
|
334
|
+
if (!fs.existsSync(tasksDir)) return "_(no prior task \`SUMMARY.md\` files in this feature)_";
|
|
335
|
+
const all: string[] = [];
|
|
336
|
+
for (const t of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
337
|
+
if (!t.isDirectory()) continue;
|
|
338
|
+
const s = path.join(tasksDir, t.name, "SUMMARY.md");
|
|
339
|
+
if (fs.existsSync(s) && s !== path.join(taskDir, "SUMMARY.md")) all.push(s);
|
|
340
|
+
}
|
|
341
|
+
all.sort();
|
|
342
|
+
const recent = all.slice(-n);
|
|
343
|
+
if (recent.length === 0) return "_(no prior task \`SUMMARY.md\` files in this feature)_";
|
|
344
|
+
return recent
|
|
345
|
+
.map((f) => `### ${path.basename(path.dirname(f))}\n\n${truncate(readIfExists(f) ?? "", perSummaryTokens)}`)
|
|
346
|
+
.join("\n\n---\n\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function loadAntiPatterns(phaseDir: string, maxTokens: number): string {
|
|
350
|
+
const cont = findContinueHerePath(phaseDir);
|
|
351
|
+
if (!cont) return "_(no \`.continue-here.md\` for this phase)_";
|
|
352
|
+
const body = readIfExists(cont) ?? "";
|
|
353
|
+
const lines = body.split(/\r?\n/);
|
|
354
|
+
const out: string[] = [];
|
|
355
|
+
let inSection = false;
|
|
356
|
+
for (const l of lines) {
|
|
357
|
+
if (l.match(/^##\s*Critical Anti-Patterns/i)) {
|
|
358
|
+
inSection = true;
|
|
359
|
+
out.push(l);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (inSection) {
|
|
363
|
+
if (l.match(/^##\s/) && !l.match(/Critical Anti-Patterns/i)) break;
|
|
364
|
+
out.push(l);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (out.length === 0) return "_(no \`## Critical Anti-Patterns\` section in \`.continue-here.md\`) — safe to proceed_";
|
|
368
|
+
return truncate(out.join("\n"), maxTokens, "blocking rows only");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function loadFeatureReadme(feature: string, solyDir: string, maxTokens: number): string {
|
|
372
|
+
const readme = path.join(solyDir, "features", feature, "README.md");
|
|
373
|
+
if (!fs.existsSync(readme)) return `_(no \`.soly/features/${feature}/README.md\`)_`;
|
|
374
|
+
return truncate(readIfExists(readme) ?? "", maxTokens);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ----------------------------------------------------------------------------
|
|
378
|
+
// Plan summary extraction (for inline task-string cache)
|
|
379
|
+
// ----------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
export interface PlanSummary {
|
|
382
|
+
id: string;
|
|
383
|
+
title: string;
|
|
384
|
+
wave: number;
|
|
385
|
+
requirements: string[];
|
|
386
|
+
dependsOn: string[];
|
|
387
|
+
mustHaves: {
|
|
388
|
+
truths: string[];
|
|
389
|
+
artifacts: string[];
|
|
390
|
+
keyLinks: string[];
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Extract key fields from a PLAN.md for inline use in the worker task. */
|
|
395
|
+
export function extractPlanSummary(planContent: string): PlanSummary | null {
|
|
396
|
+
const m = planContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
397
|
+
if (!m) return null;
|
|
398
|
+
const yaml = m[1]!;
|
|
399
|
+
const body = m[2]!;
|
|
400
|
+
|
|
401
|
+
const get = (key: string): string => {
|
|
402
|
+
const line = yaml.split(/\r?\n/).find((l) => l.startsWith(`${key}:`));
|
|
403
|
+
return (line?.split(":").slice(1).join(":") ?? "").trim().replace(/^["']|["']$/g, "");
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const id = get("id") || "";
|
|
407
|
+
const title = get("title") || "";
|
|
408
|
+
const wave = parseInt(get("wave") || "1", 10) || 1;
|
|
409
|
+
// Split helper: strip outer [ ], split on comma, trim, strip inner quotes.
|
|
410
|
+
const splitList = (raw: string): string[] =>
|
|
411
|
+
raw
|
|
412
|
+
.replace(/^\[|\]$/g, "")
|
|
413
|
+
.split(",")
|
|
414
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
const requirements = splitList(get("requirements"));
|
|
417
|
+
const dependsOn = splitList(get("depends-on"));
|
|
418
|
+
|
|
419
|
+
// Must Haves
|
|
420
|
+
const truths: string[] = [];
|
|
421
|
+
const artifacts: string[] = [];
|
|
422
|
+
const keyLinks: string[] = [];
|
|
423
|
+
const mhMatch = body.match(/##\s*Must Haves\s*\n([\s\S]*?)(?=\n##\s|\s*$)/);
|
|
424
|
+
if (mhMatch) {
|
|
425
|
+
const mhBody = mhMatch[1]!;
|
|
426
|
+
const truthSec = mhBody.match(/###\s*truths\b[\s\S]*?(?=###\s|$)/i)?.[0];
|
|
427
|
+
if (truthSec) {
|
|
428
|
+
for (const m of truthSec.matchAll(/^- \[[ x]\]\s*(.+)$/gm)) truths.push(m[1]!.trim());
|
|
429
|
+
}
|
|
430
|
+
const artSec = mhBody.match(/###\s*artifacts\b[\s\S]*?(?=###\s|$)/i)?.[0];
|
|
431
|
+
if (artSec) {
|
|
432
|
+
for (const m of artSec.matchAll(/^-\s*(.+)$/gm)) {
|
|
433
|
+
const t = m[1]!.trim();
|
|
434
|
+
if (t && !t.startsWith("###")) artifacts.push(t);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const linkSec = mhBody.match(/###\s*key_links\b[\s\S]*?(?=###\s|$)/i)?.[0];
|
|
438
|
+
if (linkSec) {
|
|
439
|
+
for (const m of linkSec.matchAll(/^-\s*(.+)$/gm)) {
|
|
440
|
+
const t = m[1]!.trim();
|
|
441
|
+
if (t && !t.startsWith("###")) keyLinks.push(t);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { id, title, wave, requirements, dependsOn, mustHaves: { truths, artifacts, keyLinks } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Render a PlanSummary as a compact bullet block for inline use. */
|
|
450
|
+
export function renderPlanSummaryInline(s: PlanSummary): string {
|
|
451
|
+
const out: string[] = [];
|
|
452
|
+
if (s.title) out.push(`- **Plan title**: ${s.title}`);
|
|
453
|
+
out.push(`- **Wave**: ${s.wave}`);
|
|
454
|
+
if (s.requirements.length > 0) out.push(`- **Requirements**: [${s.requirements.join(", ")}]`);
|
|
455
|
+
if (s.dependsOn.length > 0) out.push(`- **Depends-on**: [${s.dependsOn.join(", ")}]`);
|
|
456
|
+
if (s.mustHaves.truths.length > 0) {
|
|
457
|
+
out.push(`- **Must-haves — truths**:`);
|
|
458
|
+
for (const t of s.mustHaves.truths) out.push(` - ${t}`);
|
|
459
|
+
}
|
|
460
|
+
if (s.mustHaves.artifacts.length > 0) {
|
|
461
|
+
out.push(`- **Must-haves — artifacts**:`);
|
|
462
|
+
for (const a of s.mustHaves.artifacts) out.push(` - ${a}`);
|
|
463
|
+
}
|
|
464
|
+
if (s.mustHaves.keyLinks.length > 0) {
|
|
465
|
+
out.push(`- **Must-haves — key_links**:`);
|
|
466
|
+
for (const k of s.mustHaves.keyLinks) out.push(` - ${k}`);
|
|
467
|
+
}
|
|
468
|
+
return out.join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ----------------------------------------------------------------------------
|
|
472
|
+
// Bundle assembly
|
|
473
|
+
// ----------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/** Build the full bundle content (no I/O — pure-ish, only reads from disk). */
|
|
476
|
+
export function buildIterationContent(input: IterationInput): string {
|
|
477
|
+
const sections: string[] = [];
|
|
478
|
+
const generatedAt = new Date().toISOString();
|
|
479
|
+
const projectRoot = input.projectRoot;
|
|
480
|
+
|
|
481
|
+
// ---- Frontmatter ----
|
|
482
|
+
sections.push("---");
|
|
483
|
+
sections.push(`generated: ${generatedAt}`);
|
|
484
|
+
sections.push(`kind: ${input.kind}`);
|
|
485
|
+
if (input.phaseNumber != null) sections.push(`phase: ${input.phaseNumber}`);
|
|
486
|
+
if (input.planNumber != null) sections.push(`plan: ${input.planNumber}`);
|
|
487
|
+
if (input.taskId) sections.push(`task: ${input.taskId}`);
|
|
488
|
+
if (input.feature) sections.push(`feature: ${input.feature}`);
|
|
489
|
+
sections.push(`soly_dir: ${input.solyDir}`);
|
|
490
|
+
sections.push("---");
|
|
491
|
+
sections.push("");
|
|
492
|
+
|
|
493
|
+
// ---- Title ----
|
|
494
|
+
let title: string;
|
|
495
|
+
if (input.kind === "exec") {
|
|
496
|
+
title = input.taskId
|
|
497
|
+
? `# Iteration Context — Execute Task \`${input.taskId}\``
|
|
498
|
+
: `# Iteration Context — Execute Phase ${input.phaseNumber} / Plan ${input.planNumber}`;
|
|
499
|
+
} else if (input.kind === "plan") {
|
|
500
|
+
title = input.taskId
|
|
501
|
+
? `# Iteration Context — Plan Task \`${input.taskId}\``
|
|
502
|
+
: `# Iteration Context — Plan Phase ${input.phaseNumber}`;
|
|
503
|
+
} else if (input.kind === "discuss") {
|
|
504
|
+
title = `# Iteration Context — Discuss Phase ${input.phaseNumber}`;
|
|
505
|
+
} else {
|
|
506
|
+
title = `# Iteration Context — ${input.kind}`;
|
|
507
|
+
}
|
|
508
|
+
sections.push(title);
|
|
509
|
+
sections.push("");
|
|
510
|
+
sections.push(`**Generated**: ${generatedAt}`);
|
|
511
|
+
sections.push(`**Soly dir**: \`${rel(projectRoot, input.solyDir) || ".soly"}\``);
|
|
512
|
+
if (input.taskId) {
|
|
513
|
+
sections.push(`**Task**: \`${input.taskId}\` in feature \`${input.feature ?? "?"}\``);
|
|
514
|
+
} else if (input.phaseNumber != null) {
|
|
515
|
+
sections.push(
|
|
516
|
+
`**Phase**: ${input.phaseNumber}${input.planNumber != null ? ` / Plan ${input.planNumber}` : ""}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
sections.push("");
|
|
520
|
+
sections.push("> **Read this file first.** It contains all the context you need for this iteration.");
|
|
521
|
+
sections.push(
|
|
522
|
+
"> If you need the full unabridged version of any section, the source path is given in the section header.",
|
|
523
|
+
);
|
|
524
|
+
sections.push("");
|
|
525
|
+
|
|
526
|
+
// ---- Section 0: Intent ----
|
|
527
|
+
sections.push("## 0. Project Intent (from `.soly/docs/`)");
|
|
528
|
+
sections.push("");
|
|
529
|
+
sections.push(
|
|
530
|
+
`_Source: \`.soly/docs/\` (full files in \`.soly/docs/\`, see also \`soly_intent\` tool)_`,
|
|
531
|
+
);
|
|
532
|
+
sections.push("");
|
|
533
|
+
sections.push(loadIntentSummary(input.solyDir, SECTION_BUDGETS.intent));
|
|
534
|
+
sections.push("");
|
|
535
|
+
|
|
536
|
+
// ---- Look up phase / task dirs (backfill feature from disk if needed) ----
|
|
537
|
+
let phaseDir: string | null = null;
|
|
538
|
+
if (input.phaseNumber != null) phaseDir = findPhaseDir(input.solyDir, input.phaseNumber);
|
|
539
|
+
|
|
540
|
+
let taskDir: string | null = null;
|
|
541
|
+
if (input.taskId) {
|
|
542
|
+
const found = findTaskDir(input.solyDir, input.taskId);
|
|
543
|
+
if (found) {
|
|
544
|
+
taskDir = found.dir;
|
|
545
|
+
if (!input.feature) input.feature = found.feature;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- Section 1: State (only if there's a project state) ----
|
|
550
|
+
if (input.kind !== "pause") {
|
|
551
|
+
sections.push("## 1. Project State (`.soly/STATE.md` — Current Position + Decisions)");
|
|
552
|
+
sections.push("");
|
|
553
|
+
sections.push(`_Source: \`${rel(projectRoot, path.join(input.solyDir, "STATE.md"))}\`_`);
|
|
554
|
+
sections.push("");
|
|
555
|
+
sections.push(loadStateSection(input.solyDir, SECTION_BUDGETS.state));
|
|
556
|
+
sections.push("");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---- Phase-based sections (intent/plan/exec with a phase number) ----
|
|
560
|
+
if ((input.kind === "plan" || input.kind === "exec" || input.kind === "discuss") && input.phaseNumber != null) {
|
|
561
|
+
// Section 2: ROADMAP row
|
|
562
|
+
sections.push(`## 2. ROADMAP.md — Phase ${input.phaseNumber} row`);
|
|
563
|
+
sections.push("");
|
|
564
|
+
sections.push(`_Source: \`${rel(projectRoot, path.join(input.solyDir, "ROADMAP.md"))}\`_`);
|
|
565
|
+
sections.push("");
|
|
566
|
+
sections.push(loadRoadmapRow(input.solyDir, input.phaseNumber, SECTION_BUDGETS.roadmap));
|
|
567
|
+
sections.push("");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if ((input.kind === "plan" || input.kind === "exec" || input.kind === "discuss") && phaseDir) {
|
|
571
|
+
// Section 3: Phase CONTEXT (also for discuss — facilitator refines, doesn't re-derive)
|
|
572
|
+
const ctxPath = findPhaseContextPath(phaseDir);
|
|
573
|
+
sections.push("## 3. Phase CONTEXT");
|
|
574
|
+
sections.push("");
|
|
575
|
+
sections.push(`_Source: \`${ctxPath ? rel(projectRoot, ctxPath) : "(missing)"}\`_`);
|
|
576
|
+
sections.push("");
|
|
577
|
+
sections.push(loadPhaseContext(phaseDir, SECTION_BUDGETS.context));
|
|
578
|
+
sections.push("");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if ((input.kind === "plan" || input.kind === "exec") && phaseDir) {
|
|
582
|
+
// Section 3: Phase CONTEXT
|
|
583
|
+
const ctxPath = findPhaseContextPath(phaseDir);
|
|
584
|
+
sections.push("## 3. Phase CONTEXT");
|
|
585
|
+
sections.push("");
|
|
586
|
+
sections.push(`_Source: \`${ctxPath ? rel(projectRoot, ctxPath) : "(missing)"}\`_`);
|
|
587
|
+
sections.push("");
|
|
588
|
+
sections.push(loadPhaseContext(phaseDir, SECTION_BUDGETS.context));
|
|
589
|
+
sections.push("");
|
|
590
|
+
|
|
591
|
+
// Section 4: Phase RESEARCH
|
|
592
|
+
const resPath = findPhaseResearchPath(phaseDir);
|
|
593
|
+
sections.push("## 4. Phase RESEARCH");
|
|
594
|
+
sections.push("");
|
|
595
|
+
sections.push(`_Source: \`${resPath ? rel(projectRoot, resPath) : "(missing)"}\`_`);
|
|
596
|
+
sections.push("");
|
|
597
|
+
sections.push(loadPhaseResearch(phaseDir, SECTION_BUDGETS.research));
|
|
598
|
+
sections.push("");
|
|
599
|
+
|
|
600
|
+
// Section 5: Prior SUMMARYs
|
|
601
|
+
sections.push("## 5. Prior SUMMARYs (last 3 in this phase)");
|
|
602
|
+
sections.push("");
|
|
603
|
+
sections.push(loadPhaseSummaries(phaseDir, 3, 600));
|
|
604
|
+
sections.push("");
|
|
605
|
+
|
|
606
|
+
// For exec, also include the current PLAN
|
|
607
|
+
if (input.kind === "exec" && input.planNumber != null) {
|
|
608
|
+
const planFile = findPlanFile(phaseDir, input.planNumber);
|
|
609
|
+
if (planFile) {
|
|
610
|
+
sections.push(`## 6. Current PLAN (\`${path.basename(planFile)}\`)`);
|
|
611
|
+
sections.push("");
|
|
612
|
+
sections.push(`_Source: \`${rel(projectRoot, planFile)}\`_`);
|
|
613
|
+
sections.push("");
|
|
614
|
+
sections.push(loadPlanFile(planFile, SECTION_BUDGETS.plan));
|
|
615
|
+
sections.push("");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Section 7: Anti-patterns (from .continue-here.md)
|
|
620
|
+
if (input.kind === "exec") {
|
|
621
|
+
sections.push("## 7. Critical Anti-Patterns (from `.continue-here.md`, if present)");
|
|
622
|
+
sections.push("");
|
|
623
|
+
sections.push(
|
|
624
|
+
"_Treat any `severity = blocking` row as a hard rule. Acknowledge before proceeding._",
|
|
625
|
+
);
|
|
626
|
+
sections.push("");
|
|
627
|
+
sections.push(loadAntiPatterns(phaseDir, SECTION_BUDGETS.antipatterns));
|
|
628
|
+
sections.push("");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---- Task-mode sections ----
|
|
633
|
+
if (input.taskId && taskDir) {
|
|
634
|
+
const featureName = input.feature ?? "unknown";
|
|
635
|
+
const readmeRel = path.join(input.solyDir, "features", featureName, "README.md");
|
|
636
|
+
|
|
637
|
+
// Section 2 (replaces phase sections): Feature README
|
|
638
|
+
sections.push("## 2. Feature README");
|
|
639
|
+
sections.push("");
|
|
640
|
+
sections.push(`_Source: \`${rel(projectRoot, readmeRel)}\`_`);
|
|
641
|
+
sections.push("");
|
|
642
|
+
sections.push(loadFeatureReadme(featureName, input.solyDir, SECTION_BUDGETS.featureReadme));
|
|
643
|
+
sections.push("");
|
|
644
|
+
|
|
645
|
+
// Section 3: Prior task SUMMARYs
|
|
646
|
+
sections.push("## 3. Prior task SUMMARYs (last 3 in this feature)");
|
|
647
|
+
sections.push("");
|
|
648
|
+
sections.push(loadFeatureTaskSummaries(taskDir, 3, 600));
|
|
649
|
+
sections.push("");
|
|
650
|
+
|
|
651
|
+
// Section 4: Current task PLAN
|
|
652
|
+
if (input.kind === "exec" || input.kind === "plan") {
|
|
653
|
+
const planFile = path.join(taskDir, "PLAN.md");
|
|
654
|
+
if (fs.existsSync(planFile)) {
|
|
655
|
+
sections.push("## 4. Current task PLAN");
|
|
656
|
+
sections.push("");
|
|
657
|
+
sections.push(`_Source: \`${rel(projectRoot, planFile)}\`_`);
|
|
658
|
+
sections.push("");
|
|
659
|
+
sections.push(loadPlanFile(planFile, SECTION_BUDGETS.plan));
|
|
660
|
+
sections.push("");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
sections.push("---");
|
|
666
|
+
sections.push("");
|
|
667
|
+
sections.push(
|
|
668
|
+
"**End of iteration context.** Continue with the workflow instructions in the task prompt.",
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
return sections.join("\n");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ----------------------------------------------------------------------------
|
|
675
|
+
// File writer
|
|
676
|
+
// ----------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
/** Compute the bundle file path (does not write). */
|
|
679
|
+
export function iterationFilePath(input: IterationInput): string {
|
|
680
|
+
const iterationsDir = path.join(input.solyDir, "iterations");
|
|
681
|
+
const ts = timestampSlug();
|
|
682
|
+
let fileName: string;
|
|
683
|
+
if (input.taskId) {
|
|
684
|
+
const feat = input.feature ?? "task";
|
|
685
|
+
fileName = `${feat}__${input.taskId}-${input.kind}-${ts}.md`;
|
|
686
|
+
} else if (input.phaseNumber == null) {
|
|
687
|
+
fileName = `${input.kind}-${ts}.md`;
|
|
688
|
+
} else {
|
|
689
|
+
const padded = String(input.phaseNumber).padStart(2, "0");
|
|
690
|
+
const planPart =
|
|
691
|
+
input.planNumber != null ? `-${String(input.planNumber).padStart(2, "0")}` : "";
|
|
692
|
+
fileName = `${padded}${planPart}-${input.kind}-${ts}.md`;
|
|
693
|
+
}
|
|
694
|
+
return path.join(iterationsDir, fileName);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Build the bundle + write it to disk. Returns metadata. */
|
|
698
|
+
export function writeIterationContext(input: IterationInput): IterationOutput {
|
|
699
|
+
const iterationsDir = path.join(input.solyDir, "iterations");
|
|
700
|
+
fs.mkdirSync(iterationsDir, { recursive: true });
|
|
701
|
+
const filePath = iterationFilePath(input);
|
|
702
|
+
const content = buildIterationContent(input);
|
|
703
|
+
atomicWriteFileSync(filePath, content, "utf-8");
|
|
704
|
+
return {
|
|
705
|
+
filePath,
|
|
706
|
+
relPath: rel(input.projectRoot, filePath),
|
|
707
|
+
fileName: path.basename(filePath),
|
|
708
|
+
bytes: content.length,
|
|
709
|
+
tokens: Math.ceil(content.length / 4),
|
|
710
|
+
generatedAt: new Date().toISOString(),
|
|
711
|
+
};
|
|
712
|
+
}
|