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/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
+ }