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/core.ts
ADDED
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// core.ts — Core data types, loaders, and builders for the soly extension
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Owns:
|
|
6
|
+
// - Rule loading from .soly/rules/ (project + global)
|
|
7
|
+
// - Soly project state loading from .soly/ (STATE.md, ROADMAP.md, phases/)
|
|
8
|
+
// - Status line construction
|
|
9
|
+
// - Shared utility functions and constants
|
|
10
|
+
//
|
|
11
|
+
// Path convention: <cwd>/.soly/. Pi itself loads AGENTS.md / CLAUDE.md
|
|
12
|
+
// from ancestor directories through its own resource loader, so soly
|
|
13
|
+
// stays out of that path.
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
// ---- Rules ----
|
|
25
|
+
|
|
26
|
+
export type RuleSource =
|
|
27
|
+
| "project-soly"
|
|
28
|
+
| "global-soly"
|
|
29
|
+
| "phase-soly";
|
|
30
|
+
|
|
31
|
+
export interface RuleFrontmatter {
|
|
32
|
+
description?: string;
|
|
33
|
+
globs?: string[];
|
|
34
|
+
always?: boolean;
|
|
35
|
+
priority?: "high" | "medium" | "low";
|
|
36
|
+
/** If true, the rule is loaded for interactive LLM sessions but NOT
|
|
37
|
+
* passed to subagent workers. Use for meta-rules like "ask before
|
|
38
|
+
* acting", "use background subagents", or rules that describe how
|
|
39
|
+
* the user-facing conversation should go. */
|
|
40
|
+
interactive?: boolean;
|
|
41
|
+
/** Other rule relPaths to inherit from. Their body is prepended to
|
|
42
|
+
* this rule's body at render time. Cycles are detected. */
|
|
43
|
+
extends?: string[];
|
|
44
|
+
/** Other rule relPaths to disable when this rule is loaded. Takes
|
|
45
|
+
* precedence over implicit collision-based overriding. */
|
|
46
|
+
overrides?: string[];
|
|
47
|
+
/** Inline this rule's body into the system prompt (opt-in for
|
|
48
|
+
* short, critical rules). */
|
|
49
|
+
inline?: boolean;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RuleFile {
|
|
54
|
+
relPath: string;
|
|
55
|
+
absPath: string;
|
|
56
|
+
meta: RuleFrontmatter;
|
|
57
|
+
body: string;
|
|
58
|
+
raw: string;
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
mtimeMs: number;
|
|
61
|
+
source: RuleSource;
|
|
62
|
+
sourceLabel: "soly" | "phase" | "local";
|
|
63
|
+
priority: number; // higher wins on relPath collision
|
|
64
|
+
/** Phase number for phase-scoped rules; undefined otherwise. */
|
|
65
|
+
phaseNumber?: number;
|
|
66
|
+
/** True if the rule is interactive-only (filtered out for subagent workers). */
|
|
67
|
+
interactiveOnly: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SourceSpec {
|
|
71
|
+
dir: string;
|
|
72
|
+
source: RuleSource;
|
|
73
|
+
sourceLabel: "soly" | "phase" | "local";
|
|
74
|
+
priority: number; // higher wins on relPath collision
|
|
75
|
+
/** Optional phase number (for phase-scoped sources). */
|
|
76
|
+
phaseNumber?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Project state ----
|
|
80
|
+
|
|
81
|
+
export interface ProgressInfo {
|
|
82
|
+
totalPhases: number;
|
|
83
|
+
completedPhases: number;
|
|
84
|
+
totalPlans: number;
|
|
85
|
+
completedPlans: number;
|
|
86
|
+
percent: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SolyPosition {
|
|
90
|
+
phase: string;
|
|
91
|
+
plan: string;
|
|
92
|
+
status: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PhaseInfo {
|
|
96
|
+
number: number;
|
|
97
|
+
name: string;
|
|
98
|
+
slug: string;
|
|
99
|
+
dir: string;
|
|
100
|
+
planCount: number;
|
|
101
|
+
contextExists: boolean;
|
|
102
|
+
researchExists: boolean;
|
|
103
|
+
plans: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A feature is a logical grouping of tasks (e.g. "auth", "orders").
|
|
108
|
+
* Dual-mode with phases: features live under `.soly/features/`, phases
|
|
109
|
+
* under `.soly/phases/`. soly supports both simultaneously.
|
|
110
|
+
*/
|
|
111
|
+
export interface FeatureInfo {
|
|
112
|
+
name: string;
|
|
113
|
+
slug: string;
|
|
114
|
+
dir: string;
|
|
115
|
+
taskCount: number;
|
|
116
|
+
readmeExists: boolean;
|
|
117
|
+
tasks: string[]; // task ids under this feature
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A task is a single atomic unit of work (dual-mode with phases).
|
|
122
|
+
* Frontmatter (parsed from PLAN.md):
|
|
123
|
+
* id, kind, feature, status, priority, parallelizable, depends-on
|
|
124
|
+
*/
|
|
125
|
+
export interface TaskInfo {
|
|
126
|
+
id: string;
|
|
127
|
+
feature: string;
|
|
128
|
+
kind: string;
|
|
129
|
+
status: string;
|
|
130
|
+
priority: string;
|
|
131
|
+
parallelizable: boolean;
|
|
132
|
+
dependsOn: string[];
|
|
133
|
+
dir: string;
|
|
134
|
+
planExists: boolean;
|
|
135
|
+
contextExists: boolean;
|
|
136
|
+
summaryExists: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface SolyState {
|
|
140
|
+
solyDir: string;
|
|
141
|
+
exists: boolean;
|
|
142
|
+
milestone: string;
|
|
143
|
+
milestoneName: string;
|
|
144
|
+
status: string;
|
|
145
|
+
lastUpdated: string;
|
|
146
|
+
progress: ProgressInfo;
|
|
147
|
+
position: SolyPosition | null;
|
|
148
|
+
currentPhase: PhaseInfo | null;
|
|
149
|
+
currentPlanPath: string | null;
|
|
150
|
+
stateBody: string;
|
|
151
|
+
roadmapBody: string;
|
|
152
|
+
phases: PhaseInfo[];
|
|
153
|
+
// Dual-mode: tasks live alongside phases. soly reads both directories;
|
|
154
|
+
// they do not interfere with each other. Empty arrays when the project
|
|
155
|
+
// uses phases only.
|
|
156
|
+
features: FeatureInfo[];
|
|
157
|
+
tasks: TaskInfo[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Constants
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
export const DEFAULT_PROGRESS: ProgressInfo = {
|
|
165
|
+
totalPhases: 0,
|
|
166
|
+
completedPhases: 0,
|
|
167
|
+
totalPlans: 0,
|
|
168
|
+
completedPlans: 0,
|
|
169
|
+
percent: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const STATUS_ID = "soly";
|
|
173
|
+
export const STATUS_BAR_WIDTH = 10;
|
|
174
|
+
|
|
175
|
+
// Default model context window for analytics %-of-context calculation.
|
|
176
|
+
// M3 Plus tier = 524288 (512k). If you run a different model / tier, adjust.
|
|
177
|
+
export const CONTEXT_WINDOW_TOKENS = 524288;
|
|
178
|
+
|
|
179
|
+
// ANSI colors — used only in the footer status line.
|
|
180
|
+
// "lower register" palette: dim gray for everything, white only for the
|
|
181
|
+
// progress bar (the single focal point). No loud accents.
|
|
182
|
+
export const C = {
|
|
183
|
+
dim: "\x1b[2m",
|
|
184
|
+
white: "\x1b[37m",
|
|
185
|
+
reset: "\x1b[0m",
|
|
186
|
+
} as const;
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Frontmatter parsers
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
// Simple parser for .soly/rules/ frontmatter.
|
|
193
|
+
export function parseRuleFrontmatter(raw: string): {
|
|
194
|
+
meta: RuleFrontmatter;
|
|
195
|
+
body: string;
|
|
196
|
+
} {
|
|
197
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
198
|
+
if (!match) return { meta: {}, body: raw };
|
|
199
|
+
|
|
200
|
+
const yamlText = match[1];
|
|
201
|
+
const body = match[2];
|
|
202
|
+
const meta: RuleFrontmatter = {};
|
|
203
|
+
|
|
204
|
+
for (const line of yamlText.split(/\r?\n/)) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
207
|
+
|
|
208
|
+
const colonIdx = trimmed.indexOf(":");
|
|
209
|
+
if (colonIdx === -1) continue;
|
|
210
|
+
|
|
211
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
212
|
+
let value: string | string[] | boolean = trimmed.slice(colonIdx + 1).trim();
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
typeof value === "string" &&
|
|
216
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
217
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
218
|
+
) {
|
|
219
|
+
value = value.slice(1, -1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
typeof value === "string" &&
|
|
224
|
+
value.startsWith("[") &&
|
|
225
|
+
value.endsWith("]")
|
|
226
|
+
) {
|
|
227
|
+
const inner = value.slice(1, -1).trim();
|
|
228
|
+
value =
|
|
229
|
+
inner.length === 0
|
|
230
|
+
? []
|
|
231
|
+
: inner.split(",").map((v) => v.trim().replace(/^["']|["']$/g, ""));
|
|
232
|
+
} else if (value === "true") {
|
|
233
|
+
value = true;
|
|
234
|
+
} else if (value === "false") {
|
|
235
|
+
value = false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
(meta as Record<string, unknown>)[key] = value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { meta, body };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// YAML-ish parser for .soly/STATE.md. Handles 2-level nested objects (for `progress:`).
|
|
245
|
+
export function parseStateFrontmatter(yaml: string): {
|
|
246
|
+
meta: Record<string, unknown>;
|
|
247
|
+
progress: ProgressInfo;
|
|
248
|
+
} {
|
|
249
|
+
const root: Record<string, unknown> = {};
|
|
250
|
+
const stack: { indent: number; obj: Record<string, unknown> }[] = [
|
|
251
|
+
{ indent: -1, obj: root },
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
for (const rawLine of yaml.split(/\r?\n/)) {
|
|
255
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
256
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
257
|
+
|
|
258
|
+
const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
259
|
+
const content = line.trim();
|
|
260
|
+
|
|
261
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
262
|
+
stack.pop();
|
|
263
|
+
}
|
|
264
|
+
const parent = stack[stack.length - 1].obj as Record<string, unknown>;
|
|
265
|
+
|
|
266
|
+
const colonIdx = content.indexOf(":");
|
|
267
|
+
if (colonIdx === -1) continue;
|
|
268
|
+
const key = content.slice(0, colonIdx).trim();
|
|
269
|
+
let value: unknown = content.slice(colonIdx + 1).trim();
|
|
270
|
+
|
|
271
|
+
if (value === "") {
|
|
272
|
+
const newObj: Record<string, unknown> = {};
|
|
273
|
+
parent[key] = newObj;
|
|
274
|
+
stack.push({ indent, obj: newObj });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (typeof value === "string") {
|
|
279
|
+
if (
|
|
280
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
281
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
282
|
+
) {
|
|
283
|
+
value = value.slice(1, -1);
|
|
284
|
+
} else if (value === "true") {
|
|
285
|
+
value = true;
|
|
286
|
+
} else if (value === "false") {
|
|
287
|
+
value = false;
|
|
288
|
+
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
289
|
+
const n = Number(value);
|
|
290
|
+
if (!Number.isNaN(n)) value = n;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
parent[key] = value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const progressObj =
|
|
297
|
+
(root.progress as Record<string, unknown> | undefined) ?? {};
|
|
298
|
+
return {
|
|
299
|
+
meta: root,
|
|
300
|
+
progress: {
|
|
301
|
+
totalPhases: Number(progressObj.total_phases ?? 0),
|
|
302
|
+
completedPhases: Number(progressObj.completed_phases ?? 0),
|
|
303
|
+
totalPlans: Number(progressObj.total_plans ?? 0),
|
|
304
|
+
completedPlans: Number(progressObj.completed_plans ?? 0),
|
|
305
|
+
percent: Number(progressObj.percent ?? 0),
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function splitFrontmatter(
|
|
311
|
+
raw: string,
|
|
312
|
+
): { yaml: string; body: string } | null {
|
|
313
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
314
|
+
return m ? { yaml: m[1], body: m[2] } : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================================
|
|
318
|
+
// File helpers
|
|
319
|
+
// ============================================================================
|
|
320
|
+
|
|
321
|
+
export function readIfExists(p: string): string | null {
|
|
322
|
+
try {
|
|
323
|
+
return fs.readFileSync(p, "utf-8");
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Atomic file write: write to a tmp file in the same directory, then rename
|
|
331
|
+
* to the target. Avoids partial writes when concurrent readers/processes
|
|
332
|
+
* (hot reload, another tool, git status) would otherwise see a half-written
|
|
333
|
+
* file. Best-effort: if rename fails, falls back to a direct write.
|
|
334
|
+
*/
|
|
335
|
+
export function atomicWriteFileSync(
|
|
336
|
+
target: string,
|
|
337
|
+
content: string,
|
|
338
|
+
encoding: BufferEncoding = "utf-8",
|
|
339
|
+
): void {
|
|
340
|
+
const dir = path.dirname(target);
|
|
341
|
+
const base = path.basename(target);
|
|
342
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
343
|
+
try {
|
|
344
|
+
fs.writeFileSync(tmp, content, encoding);
|
|
345
|
+
fs.renameSync(tmp, target);
|
|
346
|
+
} catch {
|
|
347
|
+
// Fallback: direct write (e.g. cross-device rename on some systems)
|
|
348
|
+
try {
|
|
349
|
+
fs.writeFileSync(target, content, encoding);
|
|
350
|
+
} catch {
|
|
351
|
+
// best effort — caller handles errors
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function findMarkdownFiles(dir: string, basePath = ""): string[] {
|
|
357
|
+
const results: string[] = [];
|
|
358
|
+
if (!fs.existsSync(dir)) return results;
|
|
359
|
+
|
|
360
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (entry.name.startsWith(".")) continue;
|
|
363
|
+
if (entry.name === "node_modules") continue;
|
|
364
|
+
const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
365
|
+
const fullPath = path.join(dir, entry.name);
|
|
366
|
+
if (entry.isDirectory()) {
|
|
367
|
+
results.push(...findMarkdownFiles(fullPath, relPath));
|
|
368
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
369
|
+
results.push(relPath);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return results;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function globToRegExp(glob: string): RegExp {
|
|
376
|
+
let re = "";
|
|
377
|
+
for (let i = 0; i < glob.length; i++) {
|
|
378
|
+
const c = glob[i];
|
|
379
|
+
if (c === "*") {
|
|
380
|
+
if (glob[i + 1] === "*") {
|
|
381
|
+
re += ".*";
|
|
382
|
+
i++;
|
|
383
|
+
if (glob[i + 1] === "/") i++;
|
|
384
|
+
} else {
|
|
385
|
+
re += "[^/]*";
|
|
386
|
+
}
|
|
387
|
+
} else if (c === "?") {
|
|
388
|
+
re += "[^/]";
|
|
389
|
+
} else if (".()+|^${}\\".includes(c)) {
|
|
390
|
+
re += "\\" + c;
|
|
391
|
+
} else {
|
|
392
|
+
re += c;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return new RegExp("^" + re + "$");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function matchesGlob(pathStr: string, glob: string): boolean {
|
|
399
|
+
return globToRegExp(glob).test(pathStr);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function extractFilePathsFromPrompt(prompt: string): string[] {
|
|
403
|
+
// Only match paths that look like real file references: must contain a slash
|
|
404
|
+
// or start with ./ and end with a short extension. Avoids catching "1.5",
|
|
405
|
+
// "i.e.", etc.
|
|
406
|
+
const matches =
|
|
407
|
+
prompt.match(
|
|
408
|
+
/(?:\.{0,2}\/)?(?:[A-Za-z0-9_\-]+\/)+[A-Za-z0-9_\-.]+\.[A-Za-z0-9]{1,5}/g,
|
|
409
|
+
) ||
|
|
410
|
+
prompt.match(/[A-Za-z0-9_\-.]+\.[a-z]{1,5}/g) ||
|
|
411
|
+
[];
|
|
412
|
+
return matches;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function estimateTokens(text: string): number {
|
|
416
|
+
return Math.ceil(text.length / 4);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function formatTok(n: number): string {
|
|
420
|
+
if (n <= 0) return "0";
|
|
421
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
422
|
+
return String(n);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ============================================================================
|
|
426
|
+
// Rules
|
|
427
|
+
// ============================================================================
|
|
428
|
+
|
|
429
|
+
function loadRulesFromSource(spec: SourceSpec): RuleFile[] {
|
|
430
|
+
const files = findMarkdownFiles(spec.dir);
|
|
431
|
+
const rules: RuleFile[] = [];
|
|
432
|
+
|
|
433
|
+
for (const relPath of files) {
|
|
434
|
+
const absPath = path.join(spec.dir, relPath);
|
|
435
|
+
try {
|
|
436
|
+
const stat = fs.statSync(absPath);
|
|
437
|
+
if (!stat.isFile()) continue;
|
|
438
|
+
const raw = fs.readFileSync(absPath, "utf-8");
|
|
439
|
+
const { meta, body } = parseRuleFrontmatter(raw);
|
|
440
|
+
rules.push({
|
|
441
|
+
relPath,
|
|
442
|
+
absPath,
|
|
443
|
+
meta,
|
|
444
|
+
body: body.trim(),
|
|
445
|
+
raw,
|
|
446
|
+
enabled: true,
|
|
447
|
+
mtimeMs: stat.mtimeMs,
|
|
448
|
+
source: spec.source,
|
|
449
|
+
sourceLabel: spec.sourceLabel,
|
|
450
|
+
priority: spec.priority,
|
|
451
|
+
interactiveOnly: meta.interactive === true,
|
|
452
|
+
});
|
|
453
|
+
} catch {
|
|
454
|
+
// Skip unreadable files
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return rules;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function loadAllRules(sources: SourceSpec[]): {
|
|
462
|
+
rules: RuleFile[];
|
|
463
|
+
overridden: string[];
|
|
464
|
+
explicitOverrides: string[];
|
|
465
|
+
} {
|
|
466
|
+
const all: RuleFile[] = [];
|
|
467
|
+
for (const spec of sources) {
|
|
468
|
+
for (const rule of loadRulesFromSource(spec)) {
|
|
469
|
+
rule.priority = spec.priority;
|
|
470
|
+
all.push(rule);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Sort by priority desc (highest first), then by relPath asc for stable order.
|
|
474
|
+
// After this sort, the first occurrence of a given relPath in the list is
|
|
475
|
+
// the highest-priority version, so a single dedup pass below is enough.
|
|
476
|
+
all.sort((a, b) => {
|
|
477
|
+
if (a.priority !== b.priority) return b.priority - a.priority;
|
|
478
|
+
return a.relPath.localeCompare(b.relPath);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Build a lookup for the highest-priority version of each relPath
|
|
482
|
+
const firstByPath = new Map<string, RuleFile>();
|
|
483
|
+
for (const r of all) {
|
|
484
|
+
if (!firstByPath.has(r.relPath)) firstByPath.set(r.relPath, r);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const seen = new Set<string>();
|
|
488
|
+
const result: RuleFile[] = [];
|
|
489
|
+
const overridden: string[] = [];
|
|
490
|
+
const explicitOverrides: string[] = [];
|
|
491
|
+
for (const rule of all) {
|
|
492
|
+
if (seen.has(rule.relPath)) {
|
|
493
|
+
overridden.push(rule.relPath);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
seen.add(rule.relPath);
|
|
497
|
+
result.push(rule);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Apply explicit `overrides:` from frontmatter. Each override either
|
|
501
|
+
// targets a specific relPath or a glob. When a rule with overrides is
|
|
502
|
+
// loaded, the matched targets are disabled (enabled=false) — unless
|
|
503
|
+
// the override is itself disabled.
|
|
504
|
+
const explicitOverridePaths = new Set<string>();
|
|
505
|
+
for (const rule of result) {
|
|
506
|
+
if (!rule.enabled) continue;
|
|
507
|
+
const targets = rule.meta.overrides;
|
|
508
|
+
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
509
|
+
for (const t of targets) {
|
|
510
|
+
// Try exact match first, then glob match
|
|
511
|
+
for (const other of result) {
|
|
512
|
+
if (other === rule) continue;
|
|
513
|
+
if (!other.enabled) continue;
|
|
514
|
+
const match =
|
|
515
|
+
other.relPath === t ||
|
|
516
|
+
other.relPath.endsWith(t) ||
|
|
517
|
+
(other.relPath.includes("/") &&
|
|
518
|
+
t === other.relPath.split("/").pop()) ||
|
|
519
|
+
matchesGlob(other.relPath, t);
|
|
520
|
+
if (match) {
|
|
521
|
+
other.enabled = false;
|
|
522
|
+
explicitOverridePaths.add(other.relPath);
|
|
523
|
+
explicitOverrides.push(`${rule.relPath} → ${other.relPath}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Apply `extends:` from frontmatter. Each rule's body is prepended with
|
|
530
|
+
// the bodies of its parent rules (recursively, with cycle detection).
|
|
531
|
+
// The result is recomputed body, kept on the rule.
|
|
532
|
+
for (const rule of result) {
|
|
533
|
+
const extendsList = rule.meta.extends;
|
|
534
|
+
if (!Array.isArray(extendsList) || extendsList.length === 0) continue;
|
|
535
|
+
const parts: string[] = [];
|
|
536
|
+
const visited = new Set<string>([rule.absPath]);
|
|
537
|
+
const collect = (ref: string): boolean => {
|
|
538
|
+
// Resolve ref to a loaded rule
|
|
539
|
+
let parent: RuleFile | undefined;
|
|
540
|
+
for (const r of result) {
|
|
541
|
+
if (r.relPath === ref || r.relPath.endsWith(ref)) {
|
|
542
|
+
parent = r;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!parent) {
|
|
547
|
+
parts.push(`<!-- extends: not found: ${ref} -->`);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (visited.has(parent.absPath)) {
|
|
551
|
+
parts.push(`<!-- extends: cycle detected: ${ref} -->`);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
visited.add(parent.absPath);
|
|
555
|
+
// Recurse first (so parents come first)
|
|
556
|
+
const parentExtends = parent.meta.extends;
|
|
557
|
+
if (Array.isArray(parentExtends)) {
|
|
558
|
+
for (const pe of parentExtends) {
|
|
559
|
+
collect(pe);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
parts.push(`### from: ${parent.relPath}\n\n${parent.body}`);
|
|
563
|
+
return true;
|
|
564
|
+
};
|
|
565
|
+
for (const ref of extendsList) collect(ref);
|
|
566
|
+
rule.body = [...parts, `\n---\n\n${rule.body}`].join("\n");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return { rules: result, overridden, explicitOverrides };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function ruleKey(rule: RuleFile): string {
|
|
573
|
+
return `${rule.source}::${rule.relPath}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ============================================================================
|
|
577
|
+
// Inline @see resolution
|
|
578
|
+
// ============================================================================
|
|
579
|
+
//
|
|
580
|
+
// A rule body can reference another rule with a standalone `@see <relpath>`
|
|
581
|
+
// line (soly convention). We resolve those references inline
|
|
582
|
+
// — the referenced rule's body is appended under a `> See: <relpath>`
|
|
583
|
+
// sub-block. Cycles and missing references are skipped with a comment.
|
|
584
|
+
//
|
|
585
|
+
// Reference path semantics:
|
|
586
|
+
// @see ./sibling.md — relative to the current rule's dir
|
|
587
|
+
// @see ../other/note.md — relative (parent dir)
|
|
588
|
+
// @see ~/rules/foo.md — under $HOME
|
|
589
|
+
// @see /abs/path.md — absolute
|
|
590
|
+
//
|
|
591
|
+
// We cap recursion at 2 levels to avoid blowing up the prompt.
|
|
592
|
+
|
|
593
|
+
const SEE_PATTERN = /^\s*@see\s+((?:\.{0,2}\/|~\/|\/)[^\s]+\.md)\s*$/;
|
|
594
|
+
const SEE_MAX_DEPTH = 2;
|
|
595
|
+
|
|
596
|
+
function resolveSeeReferences(
|
|
597
|
+
body: string,
|
|
598
|
+
ruleAbsPath: string,
|
|
599
|
+
allRulesByPath: Map<string, RuleFile>,
|
|
600
|
+
depth: number,
|
|
601
|
+
visited: Set<string>,
|
|
602
|
+
): string {
|
|
603
|
+
if (depth >= SEE_MAX_DEPTH) return body;
|
|
604
|
+
const fileDir = path.dirname(ruleAbsPath);
|
|
605
|
+
const lines = body.split(/\r?\n/);
|
|
606
|
+
const result: string[] = [];
|
|
607
|
+
const seen = new Set<string>(visited);
|
|
608
|
+
seen.add(path.resolve(ruleAbsPath));
|
|
609
|
+
|
|
610
|
+
for (const line of lines) {
|
|
611
|
+
const m = line.match(SEE_PATTERN);
|
|
612
|
+
if (!m) {
|
|
613
|
+
result.push(line);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const ref = m[1];
|
|
617
|
+
let target: string;
|
|
618
|
+
if (ref.startsWith("/")) {
|
|
619
|
+
target = ref;
|
|
620
|
+
} else if (ref.startsWith("~/")) {
|
|
621
|
+
target = path.join(os.homedir(), ref.slice(2));
|
|
622
|
+
} else {
|
|
623
|
+
target = path.resolve(fileDir, ref);
|
|
624
|
+
}
|
|
625
|
+
const resolved = path.resolve(target);
|
|
626
|
+
if (seen.has(resolved)) {
|
|
627
|
+
result.push(`<!-- @see skipped (cycle): ${ref} -->`);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
seen.add(resolved);
|
|
631
|
+
const refRule = allRulesByPath.get(resolved);
|
|
632
|
+
if (!refRule || !refRule.enabled) {
|
|
633
|
+
result.push(`<!-- @see not found: ${ref} -->`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const refBody = resolveSeeReferences(
|
|
637
|
+
refRule.body,
|
|
638
|
+
refRule.absPath,
|
|
639
|
+
allRulesByPath,
|
|
640
|
+
depth + 1,
|
|
641
|
+
seen,
|
|
642
|
+
);
|
|
643
|
+
result.push(`> See: ${refRule.relPath}\n`);
|
|
644
|
+
result.push(refBody);
|
|
645
|
+
result.push("\n---");
|
|
646
|
+
}
|
|
647
|
+
return result.join("\n");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function buildRulesSection(
|
|
651
|
+
rules: RuleFile[],
|
|
652
|
+
activeGlobs?: string[],
|
|
653
|
+
options?: {
|
|
654
|
+
phaseNumber?: number;
|
|
655
|
+
groupByPhase?: boolean;
|
|
656
|
+
/** Filter out rules with `interactive: true` frontmatter.
|
|
657
|
+
* Use for subagent workers — those rules describe how the user-facing
|
|
658
|
+
* conversation should go, not how to execute work. */
|
|
659
|
+
excludeInteractive?: boolean;
|
|
660
|
+
},
|
|
661
|
+
): { section: string; loaded: string[]; interactive: string[] } {
|
|
662
|
+
const applicable: RuleFile[] = [];
|
|
663
|
+
const skipped: { rule: RuleFile; reason: string }[] = [];
|
|
664
|
+
const interactive: string[] = [];
|
|
665
|
+
|
|
666
|
+
for (const rule of rules) {
|
|
667
|
+
if (!rule.enabled) {
|
|
668
|
+
skipped.push({ rule, reason: "disabled" });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const globs = rule.meta.globs;
|
|
673
|
+
const always = rule.meta.always === true;
|
|
674
|
+
|
|
675
|
+
if (always || !globs || globs.length === 0) {
|
|
676
|
+
applicable.push(rule);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (activeGlobs && activeGlobs.length > 0) {
|
|
681
|
+
const matches = globs.some((g) =>
|
|
682
|
+
activeGlobs.some((p) => matchesGlob(p, g)),
|
|
683
|
+
);
|
|
684
|
+
if (matches) {
|
|
685
|
+
applicable.push(rule);
|
|
686
|
+
} else {
|
|
687
|
+
skipped.push({ rule, reason: `globs ${JSON.stringify(globs)}` });
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
applicable.push(rule);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (applicable.length === 0) {
|
|
695
|
+
return { section: "", loaded: [], interactive };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Filter out interactive-only rules if requested (for subagent workers)
|
|
699
|
+
if (options?.excludeInteractive) {
|
|
700
|
+
const before = applicable.length;
|
|
701
|
+
const filtered = applicable.filter((r) => !r.interactiveOnly);
|
|
702
|
+
applicable.length = 0;
|
|
703
|
+
applicable.push(...filtered);
|
|
704
|
+
if (applicable.length === 0) {
|
|
705
|
+
return { section: "", loaded: [], interactive };
|
|
706
|
+
}
|
|
707
|
+
// If filtering removed everything, fall back to the original set with
|
|
708
|
+
// a note. (Better to give worker some rules than none.)
|
|
709
|
+
if (applicable.length === 0) {
|
|
710
|
+
// unreachable
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Build a lookup map of all loaded rules (including disabled — @see can
|
|
715
|
+
// reference them, but only enabled ones get inlined).
|
|
716
|
+
const rulesByPath = new Map<string, RuleFile>();
|
|
717
|
+
for (const r of rules) rulesByPath.set(path.resolve(r.absPath), r);
|
|
718
|
+
|
|
719
|
+
const render = (r: RuleFile) => {
|
|
720
|
+
const desc = r.meta.description ? ` — ${r.meta.description}` : "";
|
|
721
|
+
const pri = r.meta.priority ? ` {${r.meta.priority}}` : "";
|
|
722
|
+
const interactiveTag = r.interactiveOnly ? " {interactive-only}" : "";
|
|
723
|
+
const body = resolveSeeReferences(
|
|
724
|
+
r.body,
|
|
725
|
+
r.absPath,
|
|
726
|
+
rulesByPath,
|
|
727
|
+
0,
|
|
728
|
+
new Set(),
|
|
729
|
+
);
|
|
730
|
+
return `### [${r.sourceLabel}${pri}${interactiveTag}] ${r.relPath}${desc}\n\n${body}`;
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// Track which rules are interactive (for the returned list, so callers
|
|
734
|
+
// can pass them to a subagent task as "do NOT include these").
|
|
735
|
+
for (const r of rules) {
|
|
736
|
+
if (r.interactiveOnly) interactive.push(r.relPath);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Optional grouping: phase rules in their own group, then everything else.
|
|
740
|
+
let blocks: string[];
|
|
741
|
+
let headerHint: string;
|
|
742
|
+
if (options?.groupByPhase) {
|
|
743
|
+
const phase = options.phaseNumber;
|
|
744
|
+
const phaseRules = applicable.filter((r) => r.phaseNumber === phase);
|
|
745
|
+
const otherRules = applicable.filter((r) => r.phaseNumber !== phase);
|
|
746
|
+
blocks = [...phaseRules.map(render), ...otherRules.map(render)];
|
|
747
|
+
headerHint = `Phase ${phase} rules are loaded for the currently active phase; all other rules are always-on. Inline @see references are resolved recursively.`;
|
|
748
|
+
} else {
|
|
749
|
+
blocks = applicable.map(render);
|
|
750
|
+
headerHint = `The following rules are loaded from \`.soly/rules/\` and \`~/.soly/rules/\` and are mandatory. Follow them strictly. Inline @see references are resolved recursively.`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const skippedNote = skipped.length
|
|
754
|
+
? `\n\n_Skipped (not applicable or disabled): ${skipped
|
|
755
|
+
.map((s) => `${s.rule.sourceLabel}/${s.rule.relPath} (${s.reason})`)
|
|
756
|
+
.join(", ")}_`
|
|
757
|
+
: "";
|
|
758
|
+
|
|
759
|
+
const section = `
|
|
760
|
+
|
|
761
|
+
## soly project rules
|
|
762
|
+
|
|
763
|
+
${headerHint}
|
|
764
|
+
|
|
765
|
+
${blocks.join("\n\n---\n\n")}${skippedNote}
|
|
766
|
+
`;
|
|
767
|
+
|
|
768
|
+
return { section, loaded: applicable.map(ruleKey), interactive };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ============================================================================
|
|
772
|
+
// Phase-scoped rules loader
|
|
773
|
+
// ============================================================================
|
|
774
|
+
//
|
|
775
|
+
// Phase rules live under <phase-dir>/rules/<anything>.md and are loaded
|
|
776
|
+
// alongside the always-on rules when the matching phase is active. They
|
|
777
|
+
// receive priority 5 (above project rules) so they always win on relPath
|
|
778
|
+
// collision within a phase, but don't shadow global rules in other phases.
|
|
779
|
+
|
|
780
|
+
export function loadPhaseRules(
|
|
781
|
+
phaseDir: string,
|
|
782
|
+
phaseNumber: number,
|
|
783
|
+
): RuleFile[] {
|
|
784
|
+
const rulesDir = path.join(phaseDir, "rules");
|
|
785
|
+
if (!fs.existsSync(rulesDir)) return [];
|
|
786
|
+
const files = findMarkdownFiles(rulesDir);
|
|
787
|
+
const out: RuleFile[] = [];
|
|
788
|
+
for (const relPath of files) {
|
|
789
|
+
const absPath = path.join(rulesDir, relPath);
|
|
790
|
+
try {
|
|
791
|
+
const stat = fs.statSync(absPath);
|
|
792
|
+
if (!stat.isFile()) continue;
|
|
793
|
+
const raw = fs.readFileSync(absPath, "utf-8");
|
|
794
|
+
const { meta, body } = parseRuleFrontmatter(raw);
|
|
795
|
+
out.push({
|
|
796
|
+
relPath: `phase-${phaseNumber}/${relPath}`,
|
|
797
|
+
absPath,
|
|
798
|
+
meta,
|
|
799
|
+
body: body.trim(),
|
|
800
|
+
raw,
|
|
801
|
+
enabled: true,
|
|
802
|
+
mtimeMs: stat.mtimeMs,
|
|
803
|
+
source: "phase-soly",
|
|
804
|
+
sourceLabel: "phase",
|
|
805
|
+
priority: 5,
|
|
806
|
+
phaseNumber,
|
|
807
|
+
interactiveOnly: meta.interactive === true,
|
|
808
|
+
});
|
|
809
|
+
} catch {
|
|
810
|
+
// skip unreadable
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ============================================================================
|
|
817
|
+
// Rule analytics
|
|
818
|
+
// ============================================================================
|
|
819
|
+
|
|
820
|
+
const RULE_WARN_THRESHOLD_TOKENS = 5000;
|
|
821
|
+
const DUPLICATE_NORMALIZE_RE = /\s+/g;
|
|
822
|
+
|
|
823
|
+
export interface RuleAnalytics {
|
|
824
|
+
fileCount: number;
|
|
825
|
+
totalTokens: number;
|
|
826
|
+
contextWindowTokens: number;
|
|
827
|
+
contextBudgetPct: number; // totalTokens / contextWindowTokens * 100
|
|
828
|
+
topFiles: { relPath: string; tokens: number; sourceLabel: string }[];
|
|
829
|
+
warnings: string[];
|
|
830
|
+
duplicates: string[][];
|
|
831
|
+
/** Lint-style issues: missing frontmatter fields, invalid priority, etc. */
|
|
832
|
+
lint: { relPath: string; message: string }[];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function analyzeRules(
|
|
836
|
+
rules: RuleFile[],
|
|
837
|
+
contextWindowTokens: number,
|
|
838
|
+
): RuleAnalytics {
|
|
839
|
+
const enabled = rules.filter((r) => r.enabled);
|
|
840
|
+
const fileCount = enabled.length;
|
|
841
|
+
|
|
842
|
+
const tokensByPath = new Map<string, number>();
|
|
843
|
+
for (const rule of enabled) {
|
|
844
|
+
tokensByPath.set(rule.relPath, estimateTokens(rule.body));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const totalTokens = Array.from(tokensByPath.values()).reduce(
|
|
848
|
+
(a, b) => a + b,
|
|
849
|
+
0,
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const topFiles = enabled
|
|
853
|
+
.map((r) => ({
|
|
854
|
+
relPath: r.relPath,
|
|
855
|
+
tokens: tokensByPath.get(r.relPath) ?? 0,
|
|
856
|
+
sourceLabel: r.sourceLabel,
|
|
857
|
+
}))
|
|
858
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
859
|
+
.slice(0, 5);
|
|
860
|
+
|
|
861
|
+
const warnings: string[] = [];
|
|
862
|
+
const lint: { relPath: string; message: string }[] = [];
|
|
863
|
+
|
|
864
|
+
// Oversized files
|
|
865
|
+
for (const file of topFiles) {
|
|
866
|
+
if (file.tokens > RULE_WARN_THRESHOLD_TOKENS) {
|
|
867
|
+
warnings.push(
|
|
868
|
+
`${file.relPath}: ${formatTok(file.tokens)} (oversized, consider splitting)`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Missing frontmatter description
|
|
874
|
+
for (const rule of enabled) {
|
|
875
|
+
if (!rule.meta.description) {
|
|
876
|
+
lint.push({
|
|
877
|
+
relPath: rule.relPath,
|
|
878
|
+
message: "missing frontmatter description",
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Validate priority field
|
|
883
|
+
if (rule.meta.priority != null) {
|
|
884
|
+
const valid = ["high", "medium", "low"];
|
|
885
|
+
if (!valid.includes(String(rule.meta.priority))) {
|
|
886
|
+
lint.push({
|
|
887
|
+
relPath: rule.relPath,
|
|
888
|
+
message: `invalid priority "${rule.meta.priority}" (expected: high | medium | low)`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Validate globs
|
|
894
|
+
if (rule.meta.globs != null && !Array.isArray(rule.meta.globs)) {
|
|
895
|
+
lint.push({
|
|
896
|
+
relPath: rule.relPath,
|
|
897
|
+
message: `globs must be an array, got ${typeof rule.meta.globs}`,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Empty body warning
|
|
902
|
+
if (rule.body.trim().length === 0) {
|
|
903
|
+
lint.push({
|
|
904
|
+
relPath: rule.relPath,
|
|
905
|
+
message: "empty body",
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Duplicate content (normalized whitespace)
|
|
911
|
+
const normalizedToPaths = new Map<string, string[]>();
|
|
912
|
+
for (const rule of enabled) {
|
|
913
|
+
const normalized = rule.body.replace(DUPLICATE_NORMALIZE_RE, " ").trim();
|
|
914
|
+
if (normalized.length === 0) continue;
|
|
915
|
+
const list = normalizedToPaths.get(normalized) ?? [];
|
|
916
|
+
list.push(rule.relPath);
|
|
917
|
+
normalizedToPaths.set(normalized, list);
|
|
918
|
+
}
|
|
919
|
+
const duplicates: string[][] = [];
|
|
920
|
+
for (const list of normalizedToPaths.values()) {
|
|
921
|
+
if (list.length > 1) duplicates.push(list);
|
|
922
|
+
}
|
|
923
|
+
for (const dup of duplicates) {
|
|
924
|
+
warnings.push(`duplicate content: ${dup.join(", ")}`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Promote lint to warnings so existing analytics output surfaces them
|
|
928
|
+
for (const l of lint) {
|
|
929
|
+
warnings.push(`${l.relPath}: ${l.message}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
fileCount,
|
|
934
|
+
totalTokens,
|
|
935
|
+
contextWindowTokens,
|
|
936
|
+
contextBudgetPct:
|
|
937
|
+
contextWindowTokens > 0 ? (totalTokens / contextWindowTokens) * 100 : 0,
|
|
938
|
+
topFiles,
|
|
939
|
+
warnings,
|
|
940
|
+
duplicates,
|
|
941
|
+
lint,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function formatAnalyticsCompact(analytics: RuleAnalytics): string {
|
|
946
|
+
if (analytics.fileCount === 0) return "";
|
|
947
|
+
const pct = (
|
|
948
|
+
(analytics.totalTokens / analytics.contextWindowTokens) *
|
|
949
|
+
100
|
|
950
|
+
).toFixed(2);
|
|
951
|
+
const parts: string[] = [
|
|
952
|
+
`${analytics.fileCount} file(s), ${formatTok(analytics.totalTokens)} (${pct}% of context)`,
|
|
953
|
+
];
|
|
954
|
+
if (analytics.warnings.length > 0) {
|
|
955
|
+
parts.push(`⚠ ${analytics.warnings.length} warning(s)`);
|
|
956
|
+
}
|
|
957
|
+
return parts.join(" · ");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function formatAnalyticsFull(analytics: RuleAnalytics): string {
|
|
961
|
+
const pct = (
|
|
962
|
+
(analytics.totalTokens / analytics.contextWindowTokens) *
|
|
963
|
+
100
|
|
964
|
+
).toFixed(2);
|
|
965
|
+
const lines: string[] = [];
|
|
966
|
+
lines.push(`soly rules analytics:`);
|
|
967
|
+
lines.push(
|
|
968
|
+
` ${analytics.fileCount} file(s), ${formatTok(analytics.totalTokens)} (${pct}% of context)`,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
if (analytics.topFiles.length > 0) {
|
|
972
|
+
const topStr = analytics.topFiles
|
|
973
|
+
.slice(0, 5)
|
|
974
|
+
.map((f) => `${f.relPath} (${formatTok(f.tokens)})`)
|
|
975
|
+
.join(", ");
|
|
976
|
+
lines.push(` top: ${topStr}`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (analytics.warnings.length > 0) {
|
|
980
|
+
lines.push(` ⚠ ${analytics.warnings.length} warning(s):`);
|
|
981
|
+
for (const w of analytics.warnings.slice(0, 10)) {
|
|
982
|
+
lines.push(` - ${w}`);
|
|
983
|
+
}
|
|
984
|
+
if (analytics.warnings.length > 10) {
|
|
985
|
+
lines.push(` - ... and ${analytics.warnings.length - 10} more`);
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
lines.push(` ✓ no issues detected`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return lines.join("\n");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ============================================================================
|
|
995
|
+
// @import resolver (markdown only)
|
|
996
|
+
// ============================================================================
|
|
997
|
+
//
|
|
998
|
+
// Used by intent docs (`.soly/docs/*.md`) to inline other markdown files
|
|
999
|
+
// via `@import path/to/file.md` lines. Cycles and > MAX_IMPORT_DEPTH
|
|
1000
|
+
// are skipped with a comment.
|
|
1001
|
+
//
|
|
1002
|
+
// Supports:
|
|
1003
|
+
// @./relative.md — relative to the current file
|
|
1004
|
+
// @../parent.md — relative (parent dir)
|
|
1005
|
+
// @/abs/path.md — absolute
|
|
1006
|
+
// @~/user/path.md — under $HOME
|
|
1007
|
+
// @./file.md:LSTART-LEND — line range within a file (1-indexed, inclusive)
|
|
1008
|
+
const MAX_IMPORT_DEPTH = 5;
|
|
1009
|
+
|
|
1010
|
+
// Three forms: with-range, plain
|
|
1011
|
+
const IMPORT_RANGE_PATTERN =
|
|
1012
|
+
/^\s*@((?:\.{0,2}\/|~\/|\/)[^\s]+?\.[A-Za-z0-9]{1,5}):(\d+)(?:-(\d+))?\s*$/;
|
|
1013
|
+
const IMPORT_PATTERN =
|
|
1014
|
+
/^\s*@((?:\.{0,2}\/|~\/|\/)[^\s]+\.[A-Za-z0-9]{1,5})\s*$/;
|
|
1015
|
+
|
|
1016
|
+
/** Read a line range [start, end] (1-indexed, inclusive) from a file. */
|
|
1017
|
+
function readLineRange(file: string, start: number, end: number): string | null {
|
|
1018
|
+
try {
|
|
1019
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
1020
|
+
const lines = raw.split(/\r?\n/);
|
|
1021
|
+
if (start < 1 || start > lines.length) return null;
|
|
1022
|
+
const last = Math.min(end, lines.length);
|
|
1023
|
+
return lines.slice(start - 1, last).join("\n");
|
|
1024
|
+
} catch {
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Recursively resolve @import lines in a markdown document.
|
|
1030
|
+
* - Cycles and > MAX_IMPORT_DEPTH are skipped with a comment.
|
|
1031
|
+
* - Already-imported files are tracked in globalSeen (caller-owned). */
|
|
1032
|
+
export function resolveImports(
|
|
1033
|
+
raw: string,
|
|
1034
|
+
filePath: string,
|
|
1035
|
+
globalSeen: Set<string>,
|
|
1036
|
+
depth: number,
|
|
1037
|
+
out: { imported: string[] },
|
|
1038
|
+
): string {
|
|
1039
|
+
if (depth > MAX_IMPORT_DEPTH) {
|
|
1040
|
+
return raw + `\n<!-- import depth ${MAX_IMPORT_DEPTH} exceeded -->\n`;
|
|
1041
|
+
}
|
|
1042
|
+
const fileDir = path.dirname(filePath);
|
|
1043
|
+
const lines = raw.split(/\r?\n/);
|
|
1044
|
+
const result: string[] = [];
|
|
1045
|
+
const localSeen = new Set<string>();
|
|
1046
|
+
|
|
1047
|
+
for (const line of lines) {
|
|
1048
|
+
// @<file>:START-END (line range)
|
|
1049
|
+
const rangeMatch = line.match(IMPORT_RANGE_PATTERN);
|
|
1050
|
+
if (rangeMatch) {
|
|
1051
|
+
const ref = rangeMatch[1];
|
|
1052
|
+
const start = parseInt(rangeMatch[2], 10);
|
|
1053
|
+
const end = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : start;
|
|
1054
|
+
let target: string;
|
|
1055
|
+
if (ref.startsWith("/")) {
|
|
1056
|
+
target = ref;
|
|
1057
|
+
} else if (ref.startsWith("~/")) {
|
|
1058
|
+
target = path.join(os.homedir(), ref.slice(2));
|
|
1059
|
+
} else {
|
|
1060
|
+
target = path.resolve(fileDir, ref);
|
|
1061
|
+
}
|
|
1062
|
+
const targetResolved = path.resolve(target);
|
|
1063
|
+
if (localSeen.has(targetResolved) || globalSeen.has(targetResolved)) {
|
|
1064
|
+
result.push(
|
|
1065
|
+
`<!-- import skipped (cycle or already loaded): ${ref}:${start}-${end} -->`,
|
|
1066
|
+
);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
localSeen.add(targetResolved);
|
|
1070
|
+
globalSeen.add(targetResolved);
|
|
1071
|
+
out.imported.push(`${ref}:${start}-${end}`);
|
|
1072
|
+
if (!fs.existsSync(targetResolved)) {
|
|
1073
|
+
result.push(`<!-- import not found: ${ref}:${start}-${end} -->`);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const range = readLineRange(targetResolved, start, end);
|
|
1077
|
+
if (range === null) {
|
|
1078
|
+
result.push(`<!-- import read error: ${ref}:${start}-${end} -->`);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
result.push(`<!-- imported from ${ref} (lines ${start}-${end}) -->`);
|
|
1082
|
+
result.push(range);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const m = line.match(IMPORT_PATTERN);
|
|
1087
|
+
if (!m) {
|
|
1088
|
+
result.push(line);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const ref = m[1];
|
|
1093
|
+
let target: string;
|
|
1094
|
+
if (ref.startsWith("/")) {
|
|
1095
|
+
target = ref;
|
|
1096
|
+
} else if (ref.startsWith("~/")) {
|
|
1097
|
+
target = path.join(os.homedir(), ref.slice(2));
|
|
1098
|
+
} else {
|
|
1099
|
+
target = path.resolve(fileDir, ref);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const targetResolved = path.resolve(target);
|
|
1103
|
+
if (localSeen.has(targetResolved) || globalSeen.has(targetResolved)) {
|
|
1104
|
+
result.push(`<!-- import skipped (cycle or already loaded): ${ref} -->`);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
localSeen.add(targetResolved);
|
|
1108
|
+
globalSeen.add(targetResolved);
|
|
1109
|
+
out.imported.push(ref);
|
|
1110
|
+
|
|
1111
|
+
if (!fs.existsSync(targetResolved)) {
|
|
1112
|
+
result.push(`<!-- import not found: ${ref} -->`);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
const importedRaw = fs.readFileSync(targetResolved, "utf-8");
|
|
1118
|
+
const importedResolved = resolveImports(
|
|
1119
|
+
importedRaw,
|
|
1120
|
+
targetResolved,
|
|
1121
|
+
globalSeen,
|
|
1122
|
+
depth + 1,
|
|
1123
|
+
out,
|
|
1124
|
+
);
|
|
1125
|
+
result.push(`<!-- imported from ${ref} -->`);
|
|
1126
|
+
result.push(importedResolved);
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
result.push(
|
|
1129
|
+
`<!-- import read error: ${ref} (${(err as Error).message}) -->`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return result.join("\n");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ============================================================================
|
|
1138
|
+
// Project state (.soly/)
|
|
1139
|
+
// ============================================================================
|
|
1140
|
+
|
|
1141
|
+
function extractCurrentPosition(body: string): SolyPosition | null {
|
|
1142
|
+
const m = body.match(/##\s*Current Position\s*\n+([\s\S]*?)(?=\n##\s|\s*$)/);
|
|
1143
|
+
if (!m) return null;
|
|
1144
|
+
const section = m[1];
|
|
1145
|
+
const phase = section.match(/Phase:\s*([^\n]+)/)?.[1]?.trim();
|
|
1146
|
+
const plan = section.match(/Plan:\s*([^\n]+)/)?.[1]?.trim();
|
|
1147
|
+
const status = section.match(/Status:\s*([^\n]+)/)?.[1]?.trim();
|
|
1148
|
+
if (!phase) return null;
|
|
1149
|
+
return { phase, plan: plan ?? "?", status: status ?? "unknown" };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function loadPhaseDir(phaseDir: string): PhaseInfo {
|
|
1153
|
+
const slug = path.basename(phaseDir);
|
|
1154
|
+
const numMatch = slug.match(/^(\d+)-?(.*)$/);
|
|
1155
|
+
const number = numMatch?.[1] ? parseInt(numMatch[1], 10) : 0;
|
|
1156
|
+
const name = numMatch?.[2]?.replace(/-/g, " ").trim() ?? slug;
|
|
1157
|
+
|
|
1158
|
+
const files = findMarkdownFiles(phaseDir);
|
|
1159
|
+
// Soly layout: <phase>-<plan>-PLAN.md (e.g. "01-02-PLAN.md")
|
|
1160
|
+
const plans = files.filter((f) => /-\d{2,}-PLAN\.md$/.test(f)).sort();
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
number,
|
|
1164
|
+
name,
|
|
1165
|
+
slug,
|
|
1166
|
+
dir: phaseDir,
|
|
1167
|
+
planCount: plans.length,
|
|
1168
|
+
contextExists: files.some((f) => /-CONTEXT\.md$/.test(f)),
|
|
1169
|
+
researchExists: files.some((f) => /-RESEARCH\.md$/.test(f)),
|
|
1170
|
+
plans,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function listPhases(solyDir: string): PhaseInfo[] {
|
|
1175
|
+
const phasesDir = path.join(solyDir, "phases");
|
|
1176
|
+
if (!fs.existsSync(phasesDir)) return [];
|
|
1177
|
+
return fs
|
|
1178
|
+
.readdirSync(phasesDir, { withFileTypes: true })
|
|
1179
|
+
.filter((e) => e.isDirectory())
|
|
1180
|
+
.map((e) => loadPhaseDir(path.join(phasesDir, e.name)))
|
|
1181
|
+
.filter((p) => p.number > 0)
|
|
1182
|
+
.sort((a, b) => a.number - b.number);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ----------------------------------------------------------------------------
|
|
1186
|
+
// Tasks (dual-mode with phases). Discovery + frontmatter parsing.
|
|
1187
|
+
// ----------------------------------------------------------------------------
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Parse the subset of YAML frontmatter we use for tasks. Lightweight — no
|
|
1191
|
+
* full YAML parser. Supports scalar values and JSON-style arrays.
|
|
1192
|
+
*
|
|
1193
|
+
* ---
|
|
1194
|
+
* id: auth-be-login-a3f9
|
|
1195
|
+
* kind: be
|
|
1196
|
+
* feature: auth
|
|
1197
|
+
* status: ready
|
|
1198
|
+
* priority: high
|
|
1199
|
+
* parallelizable: true
|
|
1200
|
+
* depends-on: ["other-task-id"]
|
|
1201
|
+
* ---
|
|
1202
|
+
*/
|
|
1203
|
+
function parseTaskFrontmatter(raw: string): {
|
|
1204
|
+
kind: string;
|
|
1205
|
+
feature: string;
|
|
1206
|
+
status: string;
|
|
1207
|
+
priority: string;
|
|
1208
|
+
parallelizable: boolean;
|
|
1209
|
+
dependsOn: string[];
|
|
1210
|
+
} | null {
|
|
1211
|
+
const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
1212
|
+
if (!m) return null;
|
|
1213
|
+
const yaml = m[1];
|
|
1214
|
+
const get = (key: string): string | undefined => {
|
|
1215
|
+
const line = yaml.split("\n").find((l) => l.startsWith(`${key}:`));
|
|
1216
|
+
return line?.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, "");
|
|
1217
|
+
};
|
|
1218
|
+
const kind = get("kind") ?? "be";
|
|
1219
|
+
const feature = get("feature") ?? "";
|
|
1220
|
+
const status = get("status") ?? "ready";
|
|
1221
|
+
const priority = get("priority") ?? "medium";
|
|
1222
|
+
const parallelizable = get("parallelizable") === "true";
|
|
1223
|
+
const depsRaw = get("depends-on") ?? "[]";
|
|
1224
|
+
let dependsOn: string[] = [];
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = JSON.parse(depsRaw.replace(/'/g, '"'));
|
|
1227
|
+
if (Array.isArray(parsed)) dependsOn = parsed.map(String);
|
|
1228
|
+
} catch {
|
|
1229
|
+
// Fallback: comma-separated
|
|
1230
|
+
dependsOn = depsRaw
|
|
1231
|
+
.replace(/[\[\]]/g, "")
|
|
1232
|
+
.split(",")
|
|
1233
|
+
.map((s) => s.trim())
|
|
1234
|
+
.filter(Boolean);
|
|
1235
|
+
}
|
|
1236
|
+
return { kind, feature, status, priority, parallelizable, dependsOn };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function loadFeatureDir(featureDir: string): FeatureInfo | null {
|
|
1240
|
+
const name = path.basename(featureDir);
|
|
1241
|
+
const tasksDir = path.join(featureDir, "tasks");
|
|
1242
|
+
const taskIds: string[] = [];
|
|
1243
|
+
if (fs.existsSync(tasksDir)) {
|
|
1244
|
+
taskIds.push(
|
|
1245
|
+
...fs
|
|
1246
|
+
.readdirSync(tasksDir, { withFileTypes: true })
|
|
1247
|
+
.filter((e) => e.isDirectory())
|
|
1248
|
+
.map((e) => e.name)
|
|
1249
|
+
.sort(),
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
return {
|
|
1253
|
+
name,
|
|
1254
|
+
slug: name,
|
|
1255
|
+
dir: featureDir,
|
|
1256
|
+
taskCount: taskIds.length,
|
|
1257
|
+
readmeExists: fs.existsSync(path.join(featureDir, "README.md")),
|
|
1258
|
+
tasks: taskIds,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function listFeatures(solyDir: string): FeatureInfo[] {
|
|
1263
|
+
const featuresDir = path.join(solyDir, "features");
|
|
1264
|
+
if (!fs.existsSync(featuresDir)) return [];
|
|
1265
|
+
return fs
|
|
1266
|
+
.readdirSync(featuresDir, { withFileTypes: true })
|
|
1267
|
+
.filter((e) => e.isDirectory())
|
|
1268
|
+
.map((e) => loadFeatureDir(path.join(featuresDir, e.name)))
|
|
1269
|
+
.filter((f): f is FeatureInfo => f !== null)
|
|
1270
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function loadTaskDir(taskDir: string): TaskInfo | null {
|
|
1274
|
+
const id = path.basename(taskDir);
|
|
1275
|
+
const planPath = path.join(taskDir, "PLAN.md");
|
|
1276
|
+
const planRaw = readIfExists(planPath) ?? "";
|
|
1277
|
+
const fm = parseTaskFrontmatter(planRaw);
|
|
1278
|
+
if (!fm) return null; // No frontmatter — malformed task, skip silently
|
|
1279
|
+
const files = findMarkdownFiles(taskDir);
|
|
1280
|
+
return {
|
|
1281
|
+
id,
|
|
1282
|
+
feature: fm.feature || path.basename(path.dirname(path.dirname(taskDir))),
|
|
1283
|
+
kind: fm.kind,
|
|
1284
|
+
status: fm.status,
|
|
1285
|
+
priority: fm.priority,
|
|
1286
|
+
parallelizable: fm.parallelizable,
|
|
1287
|
+
dependsOn: fm.dependsOn,
|
|
1288
|
+
dir: taskDir,
|
|
1289
|
+
planExists: files.some((f) => f === "PLAN.md"),
|
|
1290
|
+
contextExists: files.some((f) => f === "CONTEXT.md"),
|
|
1291
|
+
summaryExists: files.some((f) => f === "SUMMARY.md"),
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function listTasks(solyDir: string): TaskInfo[] {
|
|
1296
|
+
const tasks: TaskInfo[] = [];
|
|
1297
|
+
const featuresDir = path.join(solyDir, "features");
|
|
1298
|
+
if (!fs.existsSync(featuresDir)) return tasks;
|
|
1299
|
+
for (const featureEntry of fs.readdirSync(featuresDir, { withFileTypes: true })) {
|
|
1300
|
+
if (!featureEntry.isDirectory()) continue;
|
|
1301
|
+
const tasksDir = path.join(featuresDir, featureEntry.name, "tasks");
|
|
1302
|
+
if (!fs.existsSync(tasksDir)) continue;
|
|
1303
|
+
for (const taskEntry of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
1304
|
+
if (!taskEntry.isDirectory()) continue;
|
|
1305
|
+
const task = loadTaskDir(path.join(tasksDir, taskEntry.name));
|
|
1306
|
+
if (task) tasks.push(task);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function findCurrentPhase(
|
|
1313
|
+
position: SolyPosition | null,
|
|
1314
|
+
phases: PhaseInfo[],
|
|
1315
|
+
): PhaseInfo | null {
|
|
1316
|
+
if (!position) return null;
|
|
1317
|
+
const numMatch = position.phase.match(/(\d+)/);
|
|
1318
|
+
if (!numMatch) return null;
|
|
1319
|
+
const num = parseInt(numMatch[1], 10);
|
|
1320
|
+
return phases.find((p) => p.number === num) ?? null;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function resolveCurrentPlanPath(
|
|
1324
|
+
position: SolyPosition,
|
|
1325
|
+
phase: PhaseInfo,
|
|
1326
|
+
): string | null {
|
|
1327
|
+
const ofMatch = position.plan.match(/(\d+)\s+of\s+\d+/);
|
|
1328
|
+
if (ofMatch) {
|
|
1329
|
+
const idx = parseInt(ofMatch[1], 10);
|
|
1330
|
+
const planRel = phase.plans[idx - 1];
|
|
1331
|
+
if (planRel) return path.join(phase.dir, planRel);
|
|
1332
|
+
}
|
|
1333
|
+
const slugMatch = position.plan.match(/\((\d{2,}-[\w-]+)\)/);
|
|
1334
|
+
if (slugMatch) {
|
|
1335
|
+
const planRel = phase.plans.find((p) => p.startsWith(slugMatch[1]));
|
|
1336
|
+
if (planRel) return path.join(phase.dir, planRel);
|
|
1337
|
+
}
|
|
1338
|
+
return phase.plans[0] ? path.join(phase.dir, phase.plans[0]) : null;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
export function loadProjectState(solyDir: string): SolyState {
|
|
1342
|
+
const statePath = path.join(solyDir, "STATE.md");
|
|
1343
|
+
const roadmapPath = path.join(solyDir, "ROADMAP.md");
|
|
1344
|
+
|
|
1345
|
+
const stateRaw = readIfExists(statePath) ?? "";
|
|
1346
|
+
const roadmapBody = readIfExists(roadmapPath) ?? "";
|
|
1347
|
+
|
|
1348
|
+
const fm = splitFrontmatter(stateRaw);
|
|
1349
|
+
const { meta, progress } = fm
|
|
1350
|
+
? parseStateFrontmatter(fm.yaml)
|
|
1351
|
+
: {
|
|
1352
|
+
meta: {} as Record<string, unknown>,
|
|
1353
|
+
progress: { ...DEFAULT_PROGRESS },
|
|
1354
|
+
};
|
|
1355
|
+
const stateBody = (fm?.body ?? stateRaw).trim();
|
|
1356
|
+
|
|
1357
|
+
const position = extractCurrentPosition(stateBody);
|
|
1358
|
+
const phases = listPhases(solyDir);
|
|
1359
|
+
const features = listFeatures(solyDir);
|
|
1360
|
+
const tasks = listTasks(solyDir);
|
|
1361
|
+
const currentPhase = findCurrentPhase(position, phases);
|
|
1362
|
+
const currentPlanPath =
|
|
1363
|
+
position && currentPhase
|
|
1364
|
+
? resolveCurrentPlanPath(position, currentPhase)
|
|
1365
|
+
: null;
|
|
1366
|
+
|
|
1367
|
+
return {
|
|
1368
|
+
solyDir,
|
|
1369
|
+
exists: fs.existsSync(solyDir),
|
|
1370
|
+
milestone: String(meta.milestone ?? "—"),
|
|
1371
|
+
milestoneName: String(meta.milestone_name ?? ""),
|
|
1372
|
+
status: String(meta.status ?? "unknown"),
|
|
1373
|
+
lastUpdated: String(meta.last_updated ?? ""),
|
|
1374
|
+
progress,
|
|
1375
|
+
position,
|
|
1376
|
+
currentPhase,
|
|
1377
|
+
currentPlanPath,
|
|
1378
|
+
stateBody,
|
|
1379
|
+
roadmapBody,
|
|
1380
|
+
phases,
|
|
1381
|
+
features,
|
|
1382
|
+
tasks,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export function buildProjectStateSection(state: SolyState): string {
|
|
1387
|
+
if (!state.exists) return "";
|
|
1388
|
+
|
|
1389
|
+
const lines: string[] = [
|
|
1390
|
+
"",
|
|
1391
|
+
"## soly project state",
|
|
1392
|
+
"",
|
|
1393
|
+
`- **milestone**: ${state.milestone}${state.milestoneName ? ` — ${state.milestoneName}` : ""}`,
|
|
1394
|
+
`- **status**: ${state.status}`,
|
|
1395
|
+
];
|
|
1396
|
+
if (state.position) {
|
|
1397
|
+
lines.push(`- **phase**: ${state.position.phase}`);
|
|
1398
|
+
lines.push(`- **plan**: ${state.position.plan}`);
|
|
1399
|
+
lines.push(`- **position status**: ${state.position.status}`);
|
|
1400
|
+
}
|
|
1401
|
+
lines.push(
|
|
1402
|
+
`- **progress**: ${state.progress.completedPhases}/${state.progress.totalPhases} phases, ${state.progress.completedPlans}/${state.progress.totalPlans} plans — ${state.progress.percent}%`,
|
|
1403
|
+
);
|
|
1404
|
+
|
|
1405
|
+
if (state.currentPlanPath) {
|
|
1406
|
+
const planContent = readIfExists(state.currentPlanPath);
|
|
1407
|
+
if (planContent) {
|
|
1408
|
+
const { body } = splitFrontmatter(planContent) ?? { body: planContent };
|
|
1409
|
+
const objective = body
|
|
1410
|
+
.match(/<objective>([\s\S]*?)<\/objective>/)?.[1]
|
|
1411
|
+
?.trim();
|
|
1412
|
+
if (objective) {
|
|
1413
|
+
const short =
|
|
1414
|
+
objective.length > 700 ? `${objective.slice(0, 700)}…` : objective;
|
|
1415
|
+
lines.push("", "### current plan objective", "", short);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
lines.push(
|
|
1421
|
+
"",
|
|
1422
|
+
"**working agreement**:",
|
|
1423
|
+
"- Follow the current PLAN.md. Each task has acceptance criteria — implement them exactly.",
|
|
1424
|
+
"- Do not skip ahead. Do not rewrite the plan without discussing.",
|
|
1425
|
+
"- After each task: verify the must_haves.truths from the plan frontmatter still hold.",
|
|
1426
|
+
"- When the plan is complete: update STATE.md progress and create a SUMMARY.md.",
|
|
1427
|
+
"- Full state available via the `soly_read` tool. Decisions loggable via `soly_log_decision`.",
|
|
1428
|
+
);
|
|
1429
|
+
|
|
1430
|
+
return lines.join("\n");
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// ============================================================================
|
|
1434
|
+
// Status bar (combined)
|
|
1435
|
+
// ============================================================================
|
|
1436
|
+
|
|
1437
|
+
export function buildProgressBar(
|
|
1438
|
+
percent: number,
|
|
1439
|
+
width = STATUS_BAR_WIDTH,
|
|
1440
|
+
): string {
|
|
1441
|
+
const filled = Math.max(
|
|
1442
|
+
0,
|
|
1443
|
+
Math.min(width, Math.round((percent / 100) * width)),
|
|
1444
|
+
);
|
|
1445
|
+
const bar = `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
1446
|
+
// Bar is the focal point: always white, framed by brackets.
|
|
1447
|
+
return `${C.white}[${bar}]${C.reset}`;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function dim(text: string): string {
|
|
1451
|
+
return `${C.dim}${text}${C.reset}`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function truncate(s: string, max: number): string {
|
|
1455
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Build a single status line combining project state, rules, and context files.
|
|
1460
|
+
* Returns "" if none has anything to show.
|
|
1461
|
+
*
|
|
1462
|
+
* Layout (two visual groups separated by wide whitespace):
|
|
1463
|
+
*
|
|
1464
|
+
* soly · v1.6.1 p10 0/2 [bar] 0% rules 6 · 2.4k context 2 · 1.1k
|
|
1465
|
+
* ──── ───────────────────────── ──────────────── ──────────────────
|
|
1466
|
+
* prefix project state rules context
|
|
1467
|
+
*
|
|
1468
|
+
* The `·` only appears where it logically connects two pieces (prefix→state,
|
|
1469
|
+
* count→tokens). Whitespace separates unrelated groups.
|
|
1470
|
+
*/
|
|
1471
|
+
export function buildStatusLine(
|
|
1472
|
+
rulesTotal: number,
|
|
1473
|
+
rulesLoadedCount: number,
|
|
1474
|
+
rulesTokens: number,
|
|
1475
|
+
state: SolyState,
|
|
1476
|
+
): string {
|
|
1477
|
+
// ---- Group 1: project state ----
|
|
1478
|
+
const stateParts: string[] = [];
|
|
1479
|
+
if (state.exists) {
|
|
1480
|
+
const milestone =
|
|
1481
|
+
state.milestone && state.milestone !== "—" ? state.milestone : "";
|
|
1482
|
+
if (milestone) stateParts.push(dim(truncate(milestone, 20)));
|
|
1483
|
+
|
|
1484
|
+
const phase = state.currentPhase?.number;
|
|
1485
|
+
if (phase !== undefined && phase !== null) {
|
|
1486
|
+
const planInfo =
|
|
1487
|
+
state.progress.totalPlans > 0
|
|
1488
|
+
? ` ${state.progress.completedPlans}/${state.progress.totalPlans}`
|
|
1489
|
+
: "";
|
|
1490
|
+
stateParts.push(dim(`p${phase}${planInfo}`));
|
|
1491
|
+
}
|
|
1492
|
+
if (state.progress.totalPhases > 0 || state.progress.totalPlans > 0) {
|
|
1493
|
+
// bar is the only white element (focal point); percent stays dim
|
|
1494
|
+
stateParts.push(
|
|
1495
|
+
`${buildProgressBar(state.progress.percent)} ${dim(state.progress.percent + "%")}`,
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// ---- Group 2: counts (rules) ----
|
|
1501
|
+
const countParts: string[] = [];
|
|
1502
|
+
if (rulesTotal > 0) {
|
|
1503
|
+
const n =
|
|
1504
|
+
rulesLoadedCount === rulesTotal
|
|
1505
|
+
? `${rulesTotal}`
|
|
1506
|
+
: `${rulesLoadedCount}/${rulesTotal}`;
|
|
1507
|
+
const tokens = rulesTokens > 0 ? ` · ${formatTok(rulesTokens)}` : "";
|
|
1508
|
+
countParts.push(dim(`rules ${n}${tokens}`));
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// ---- Assemble ----
|
|
1512
|
+
const groups: string[] = [];
|
|
1513
|
+
if (stateParts.length > 0) groups.push(stateParts.join(" "));
|
|
1514
|
+
if (countParts.length > 0) groups.push(countParts.join(" "));
|
|
1515
|
+
|
|
1516
|
+
if (groups.length === 0) return "";
|
|
1517
|
+
return `${dim("soly")} · ${groups.join(" ")}`;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// ============================================================================
|
|
1521
|
+
// Soly dir helper
|
|
1522
|
+
// ============================================================================
|
|
1523
|
+
|
|
1524
|
+
/** Default soly dir relative to cwd. */
|
|
1525
|
+
export const SOLY_DIRNAME = ".soly";
|
|
1526
|
+
|
|
1527
|
+
/** Build the .soly dir path for a given cwd. */
|
|
1528
|
+
export function solyDirFor(cwd: string): string {
|
|
1529
|
+
return path.join(cwd, SOLY_DIRNAME);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// ============================================================================
|
|
1533
|
+
// buildNextHint — "what should the user run next?" footer hint
|
|
1534
|
+
// ============================================================================
|
|
1535
|
+
//
|
|
1536
|
+
// Derives a `→ next: <verb> <args>` suggestion from project state. Returned
|
|
1537
|
+
// string is appended (dimmed) to the status line so the user always sees
|
|
1538
|
+
// the next sensible soly action without needing to read STATE.md.
|
|
1539
|
+
//
|
|
1540
|
+
// Returns null when:
|
|
1541
|
+
// - there is no .soly/ in cwd (nothing to suggest)
|
|
1542
|
+
// - every phase is already complete
|
|
1543
|
+
//
|
|
1544
|
+
// Heuristic priority (first match wins):
|
|
1545
|
+
// 1. state.position is set + status="complete" → suggest the next phase
|
|
1546
|
+
// 2. state.position is set + status="in-progress" → "soly execute N" (continue)
|
|
1547
|
+
// 3. no position + latest phase has no CONTEXT → "soly discuss N" (scope first)
|
|
1548
|
+
// 4. no position + latest phase has CONTEXT but no PLAN → "soly plan N"
|
|
1549
|
+
// 5. no position + phases exist with unfinished plans → "soly execute N"
|
|
1550
|
+
// 6. no phases → "soly plan 1"
|
|
1551
|
+
export function buildNextHint(state: SolyState): string | null {
|
|
1552
|
+
if (!state.exists) return null;
|
|
1553
|
+
|
|
1554
|
+
// Find the most recently numbered phase (whether or not it has plans).
|
|
1555
|
+
const latest = state.phases.length > 0
|
|
1556
|
+
? state.phases[state.phases.length - 1]!
|
|
1557
|
+
: null;
|
|
1558
|
+
|
|
1559
|
+
// Case 1+2: a position is recorded in STATE.md
|
|
1560
|
+
if (state.position) {
|
|
1561
|
+
const n = parseInt(state.position.phase, 10);
|
|
1562
|
+
if (state.position.status === "complete") {
|
|
1563
|
+
// All done — no hint, or suggest next phase.
|
|
1564
|
+
const next = n + 1;
|
|
1565
|
+
if (!Number.isFinite(next) || next > 99) return null;
|
|
1566
|
+
return `→ next: soly plan ${next}`;
|
|
1567
|
+
}
|
|
1568
|
+
// in-progress / ready / blocked — keep going
|
|
1569
|
+
if (Number.isFinite(n)) {
|
|
1570
|
+
return `→ next: soly execute ${n}`;
|
|
1571
|
+
}
|
|
1572
|
+
return `→ next: soly status`;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Case 3-5: no recorded position — derive from phases list
|
|
1576
|
+
if (latest) {
|
|
1577
|
+
const n = latest.number;
|
|
1578
|
+
if (!latest.contextExists) {
|
|
1579
|
+
return `→ next: soly discuss ${n}`;
|
|
1580
|
+
}
|
|
1581
|
+
if (latest.planCount === 0) {
|
|
1582
|
+
return `→ next: soly plan ${n}`;
|
|
1583
|
+
}
|
|
1584
|
+
// Has CONTEXT and at least one plan — assume not all done.
|
|
1585
|
+
return `→ next: soly execute ${n}`;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Case 6: no phases at all — start at 1
|
|
1589
|
+
return `→ next: soly plan 1`;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** Human-friendly reminder line for the "soly drift" nudge. Returns a short
|
|
1593
|
+
* string the LLM can quote in its response, or null if no drift detected. */
|
|
1594
|
+
export function buildDriftReminder(turnsSinceLastVerb: number): string | null {
|
|
1595
|
+
if (turnsSinceLastVerb < 5) return null;
|
|
1596
|
+
const verb = turnsSinceLastVerb >= 10 ? "soly pause" : "soly status";
|
|
1597
|
+
const when = turnsSinceLastVerb === 1 ? "1 turn" : `${turnsSinceLastVerb} turns`;
|
|
1598
|
+
return `soly drift hint: ${when} since last soly verb. Consider \`${verb}\` to sync state (pause saves HANDOFF for resume across compactions).`;
|
|
1599
|
+
}
|