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/index.ts ADDED
@@ -0,0 +1,718 @@
1
+ // =============================================================================
2
+ // index.ts — Main soly extension entry point
3
+ // =============================================================================
4
+ //
5
+ // Loads .soly/rules/ and .soly/ project state into the agent's system
6
+ // prompt, and registers:
7
+ // - slash commands /rules /soly /rulewizard /why
8
+ // - LLM tools soly_read soly_log_decision soly_list_phases
9
+ // - input hooks nudge (soft UI hint) + workflow verbs ("soly ...")
10
+ //
11
+ // All heavy logic lives in submodules:
12
+ // - core.ts data types, loaders, builders
13
+ // - nudge.ts behavioral nudge (pre-action gate + subagent preference)
14
+ // - commands.ts /rules /soly /rulewizard /why
15
+ // - tools.ts soly_read soly_log_decision soly_list_phases
16
+ // - workflows/ soly execute / pause / compact (plain-text input only)
17
+ //
18
+ // To add a new workflow verb: edit workflows/parser.ts + workflows/<verb>.ts,
19
+ // then re-export the handler in workflows/index.ts. No changes needed here.
20
+ // =============================================================================
21
+
22
+ import * as os from "node:os";
23
+ import * as path from "node:path";
24
+ import * as fs from "node:fs";
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+
27
+ import {
28
+ analyzeRules,
29
+ buildProjectStateSection,
30
+ buildRulesSection,
31
+ buildStatusLine,
32
+ CONTEXT_WINDOW_TOKENS,
33
+ DEFAULT_PROGRESS,
34
+ extractFilePathsFromPrompt,
35
+ formatTok,
36
+ loadAllRules,
37
+ loadPhaseRules,
38
+ loadProjectState,
39
+ STATUS_ID,
40
+ solyDirFor,
41
+ buildNextHint,
42
+ buildDriftReminder,
43
+ type RuleFile,
44
+ type SolyState,
45
+ type SourceSpec,
46
+ } from "./core.js";
47
+ import { buildIntegrationsSection } from "./integrations.js";
48
+ import { installSolyAgents } from "./agents-install.js";
49
+ import {
50
+ DEFAULT_CONFIG,
51
+ loadConfig,
52
+ pruneOldIterations,
53
+ type SolyConfig,
54
+ } from "./config.js";
55
+ import { classifyTaskHeuristics, buildNudgeSection } from "./nudge.js";
56
+ import { registerCommands, type CommandUI } from "./commands.js";
57
+ import { registerTools } from "./tools.js";
58
+ import { registerWorkflows } from "./workflows/index.js";
59
+ import { readGitContext, buildGitSection, type GitContext } from "./git.js";
60
+ import { startHotReload, type HotReloadHandle } from "./hotreload.js";
61
+ import { detectEnv, buildEnvSection, type EnvSummary } from "./env.js";
62
+ import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.js";
63
+ import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.js";
64
+
65
+ export default function solyExtension(pi: ExtensionAPI) {
66
+ // ============================================================================
67
+ // State (module-local, lives for the duration of one extension instance)
68
+ // ============================================================================
69
+
70
+ // Rules
71
+ let rules: RuleFile[] = [];
72
+ let rulesLoaded: string[] = [];
73
+ let lastRulesTokens = 0;
74
+ let ruleSources: SourceSpec[] = [];
75
+ let overriddenRulePaths: string[] = [];
76
+ let sessionCwd = "";
77
+
78
+ // ============================================================================
79
+ // Agent switcher (Shift+Tab cycles through available subagents)
80
+ // ============================================================================
81
+
82
+ // ============================================================================
83
+ // Agent switcher: REMOVED. The agent cycler is now owned by the
84
+ // separate `pi-switch` extension (header bar + Ctrl+Shift+S + /agent slash).
85
+ // Soly still owns the soly-specific agent files (soly-worker.md etc.) and
86
+ // the auto-install on opt-in. Workflows read the current agent from
87
+ // globalThis.__PI_SWITCH_AGENT__ (set by pi-switch).
88
+ // ============================================================================
89
+
90
+ // Config (per-project + global + defaults). Refreshed on session_start
91
+ // and on each session_start (the LLM can call /soly config to view).
92
+ let activeConfig: SolyConfig = DEFAULT_CONFIG;
93
+ const getActiveConfig = (): SolyConfig => activeConfig;
94
+
95
+ // Drift counter — tracks how many non-soly turns the user has spent
96
+ // before invoking a soly verb. After the threshold, a reminder is
97
+ // injected into the next system prompt so the LLM can suggest a sync
98
+ // (status, pause, etc.). Resets on every parsed soly verb.
99
+ let solyDrift = {
100
+ turnsSinceLastSolyVerb: 0,
101
+ lastReminderAt: 0,
102
+ REMINDER_THRESHOLD: 5,
103
+ };
104
+ function resetSolyDrift() {
105
+ solyDrift.turnsSinceLastSolyVerb = 0;
106
+ solyDrift.lastReminderAt = 0;
107
+ }
108
+
109
+ // Project state
110
+ let state: SolyState = {
111
+ solyDir: "",
112
+ exists: false,
113
+ milestone: "—",
114
+ milestoneName: "",
115
+ status: "unknown",
116
+ lastUpdated: "",
117
+ progress: { ...DEFAULT_PROGRESS },
118
+ position: null,
119
+ currentPhase: null,
120
+ currentPlanPath: null,
121
+ stateBody: "",
122
+ roadmapBody: "",
123
+ phases: [],
124
+ features: [],
125
+ tasks: [],
126
+ };
127
+
128
+ // Status line cache (anti-flicker)
129
+ let lastStatusLine = "";
130
+
131
+ // Behavioral nudge state
132
+ let nudgeActiveForTask = false;
133
+ let lastNudgePromptKey = "";
134
+
135
+ // Git context (cached, refreshed on hot reload + before_agent_start)
136
+ let gitContext: GitContext = { available: false, branch: null, statusShort: null, lastCommits: [] };
137
+ let lastGitSection = "";
138
+
139
+ // Hot reload watcher for rules
140
+ let hotReload: HotReloadHandle | null = null;
141
+
142
+ // Session stats (computed on demand)
143
+ let sessionStats: { turns: number; tokensEstimate: number } = { turns: 0, tokensEstimate: 0 };
144
+
145
+ // Env summary (detected once at session_start, cheap to re-detect)
146
+ let envSummary: EnvSummary | null = null;
147
+ let lastEnvSection = "";
148
+
149
+ // Code map (built once at session_start)
150
+ let codeMap: CodeMap | null = null;
151
+ let lastCodeMapSection = "";
152
+
153
+ // Project intent (zero-point docs from .soly/docs/) — always loaded
154
+ let intentDocs: IntentDoc[] = [];
155
+ let lastIntentSection = "";
156
+
157
+ // ============================================================================
158
+ // Loaders
159
+ // ============================================================================
160
+
161
+ const refreshRules = () => {
162
+ const result = loadAllRules(ruleSources);
163
+ alwaysOnRules = result.rules;
164
+ overriddenRulePaths = result.overridden;
165
+ // Also refresh phase rules — they may have changed
166
+ reloadPhaseRules();
167
+ };
168
+
169
+ const refreshState = () => {
170
+ if (!state.solyDir) return;
171
+ state = loadProjectState(state.solyDir);
172
+ };
173
+
174
+ const refreshIntent = () => {
175
+ intentDocs = loadIntentDocs(sessionCwd, state.currentPhase?.number);
176
+ const { section } = buildIntentSection(intentDocs);
177
+ lastIntentSection = section;
178
+ };
179
+
180
+ // ============================================================================
181
+ // Phase rules + last-session mtime tracking
182
+ // ============================================================================
183
+
184
+ /** Always-on rules (no phase) — reloaded by refreshRules + hot reload. */
185
+ let alwaysOnRules: RuleFile[] = [];
186
+ /** Phase-scoped rules for the currently active phase. */
187
+ let phaseRules: RuleFile[] = [];
188
+
189
+ /** Combined view consumed by buildRulesSection / status. */
190
+ const combinedRules = (): RuleFile[] => [...alwaysOnRules, ...phaseRules];
191
+
192
+ /** Reload phase rules for the current state's currentPhase. */
193
+ const reloadPhaseRules = () => {
194
+ const phase = state.currentPhase;
195
+ if (!phase) {
196
+ phaseRules = [];
197
+ return;
198
+ }
199
+ phaseRules = loadPhaseRules(phase.dir, phase.number);
200
+ };
201
+
202
+ /**
203
+ * Persistent storage of rule mtimes from the previous session, so we can
204
+ * show a "rules changed since last session" diff at startup.
205
+ * Stored in <solyDir>/.soly-rule-mtimes.json (project) or
206
+ * <homedir>/.soly/rule-mtimes.json (global fallback).
207
+ */
208
+ let lastSessionMtimes: Record<string, number> = {};
209
+ const mtimeStorePath = (): string => {
210
+ const base = state.solyDir && fs.existsSync(state.solyDir)
211
+ ? state.solyDir
212
+ : path.join(os.homedir(), ".soly");
213
+ try {
214
+ fs.mkdirSync(base, { recursive: true });
215
+ } catch {}
216
+ return path.join(base, "rule-mtimes.json");
217
+ };
218
+ const captureLastSessionRuleMtimes = () => {
219
+ const filePath = mtimeStorePath();
220
+ try {
221
+ const raw = fs.readFileSync(filePath, "utf-8");
222
+ lastSessionMtimes = JSON.parse(raw);
223
+ } catch {
224
+ lastSessionMtimes = {};
225
+ }
226
+ };
227
+ const persistRuleMtimes = () => {
228
+ const mtimes: Record<string, number> = {};
229
+ for (const r of alwaysOnRules) mtimes[`${r.source}::${r.absPath}`] = r.mtimeMs;
230
+ try {
231
+ fs.writeFileSync(mtimeStorePath(), JSON.stringify(mtimes, null, 2));
232
+ } catch {
233
+ // best effort
234
+ }
235
+ };
236
+
237
+ // ============================================================================
238
+ // Status
239
+ // ============================================================================
240
+
241
+ const updateStatus = (ui: CommandUI | { ui: { setStatus: (id: string, text: string | undefined) => void } }) => {
242
+ const setStatus = (ui as { ui: { setStatus: (id: string, text: string | undefined) => void } }).ui.setStatus;
243
+ const line = buildStatusLine(
244
+ combinedRules().length,
245
+ rulesLoaded.length,
246
+ lastRulesTokens,
247
+ state,
248
+ );
249
+ // Append session stats if non-zero (cheap; one short group)
250
+ const sessionGroup =
251
+ sessionStats.turns > 0
252
+ ? `${"\x1b[2m"}session ${sessionStats.turns}t${sessionStats.tokensEstimate > 0 ? ` ${formatTok(sessionStats.tokensEstimate)}` : ""}${"\x1b[0m"}`
253
+ : "";
254
+ // Smart "next:" hint from project state (e.g. "→ next: soly execute 10")
255
+ const hint = buildNextHint(state);
256
+ const hintGroup = hint ? `${"\x1b[2m"}${hint}${"\x1b[0m"}` : "";
257
+
258
+ // Agent badge — owned by pi-switch extension (header bar + status line).
259
+ // Soly doesn't render the agent badge itself.
260
+ const agentGroup = "";
261
+
262
+ // Cross-extension: show pi-todo progress if either .soly/todos.json
263
+ // (soly-integration mode) OR .pi-todos.json (standalone mode) exists.
264
+ // Cheap (one stat + one small JSON read); cached only for the
265
+ // lifetime of one updateStatus call.
266
+ let todoGroup = "";
267
+ if (state.exists) {
268
+ const todoCandidates = [
269
+ path.join(state.solyDir, "todos.json"),
270
+ path.join(state.solyDir, ".pi-todos.json"),
271
+ ];
272
+ for (const todoFile of todoCandidates) {
273
+ try {
274
+ if (!fs.existsSync(todoFile)) continue;
275
+ const raw = fs.readFileSync(todoFile, "utf-8");
276
+ const parsed = JSON.parse(raw) as { todos?: Array<{ status: string; activeForm?: string }> };
277
+ if (!Array.isArray(parsed.todos) || parsed.todos.length === 0) continue;
278
+ const total = parsed.todos.length;
279
+ const done = parsed.todos.filter((t) => t.status === "completed").length;
280
+ const inProgress = parsed.todos.find((t) => t.status === "in_progress");
281
+ if (inProgress?.activeForm) {
282
+ todoGroup = `${"\x1b[2m"}todos ${done}/${total} \u22ef ${inProgress.activeForm}${"\x1b[0m"}`;
283
+ } else {
284
+ todoGroup = `${"\x1b[2m"}todos ${done}/${total}${"\x1b[0m"}`;
285
+ }
286
+ break; // first match wins
287
+ } catch {
288
+ /* corrupt file — silently skip; pi-todo will rewrite on next update */
289
+ }
290
+ }
291
+ }
292
+
293
+ const groups = [line, sessionGroup, todoGroup, agentGroup, hintGroup].filter((g) => g.length > 0);
294
+ const fullLine = groups.join(" ");
295
+ if (fullLine !== lastStatusLine) {
296
+ setStatus(STATUS_ID, fullLine || undefined);
297
+ lastStatusLine = fullLine;
298
+ }
299
+ };
300
+
301
+ // ============================================================================
302
+ // Register sub-features
303
+ // ============================================================================
304
+
305
+ registerCommands(pi, {
306
+ getRules: () => combinedRules(),
307
+ getOverridden: () => overriddenRulePaths,
308
+ refreshRules: () => refreshRules(),
309
+ getState: () => state,
310
+ refreshState: () => refreshState(),
311
+ updateStatus: (ui) => updateStatus(ui),
312
+ getConfig: getActiveConfig,
313
+ });
314
+
315
+ registerTools(pi, {
316
+ getState: () => state,
317
+ refreshState: () => refreshState(),
318
+ getConfig: getActiveConfig,
319
+ });
320
+
321
+ // ============================================================================
322
+ // Agent switcher: Ctrl+Shift+A cycles through available subagents.
323
+ // (Shift+Tab is taken by pi's thinking-level cycler; Ctrl+Shift+A is unused
324
+ // and mnemonic for "A"gent.)
325
+ // ============================================================================
326
+ // Agent switcher REMOVED — moved to the separate `pi-switch` extension.
327
+ // Soly no longer owns Ctrl+Shift+S, the header bar, or /agent slash.
328
+ // The current agent is read by soly workflows from
329
+ // globalThis.__PI_SWITCH_AGENT__ (set by pi-switch), with a fallback
330
+ // to "worker" if pi-switch isn't installed.
331
+ // ============================================================================
332
+
333
+ registerWorkflows(pi, {
334
+ getState: () => state,
335
+ getInteractiveRules: () =>
336
+ combinedRules()
337
+ .filter((r) => r.interactiveOnly)
338
+ .map((r) => r.relPath),
339
+ getActiveTools: () => pi.getActiveTools(),
340
+ getConfig: getActiveConfig,
341
+ onWorkflowUsed: resetSolyDrift,
342
+ });
343
+
344
+ // ============================================================================
345
+ // Events
346
+ // ============================================================================
347
+
348
+ pi.on("session_start", async (event, ctx) => {
349
+ // Rules sources (priority order, higher wins on relPath collision).
350
+ // Project rules always beat global rules. .soly/rules.local/ is
351
+ // gitignored — for personal overrides on top of the project's rules.
352
+ ruleSources = [
353
+ { dir: path.join(ctx.cwd, ".soly", "rules.local"), source: "project-soly", sourceLabel: "local", priority: 5 },
354
+ { dir: path.join(ctx.cwd, ".soly", "rules"), source: "project-soly", sourceLabel: "soly", priority: 4 },
355
+ { dir: path.join(os.homedir(), ".soly", "rules"), source: "global-soly", sourceLabel: "soly", priority: 2 },
356
+ ];
357
+ refreshRules();
358
+
359
+ // Project state — soly owns .soly/ at the project root
360
+ state.solyDir = solyDirFor(ctx.cwd);
361
+ refreshState();
362
+
363
+ // Config: per-project overrides global overrides defaults
364
+ const cfgResult = loadConfig(ctx.cwd);
365
+ activeConfig = cfgResult.config;
366
+ for (const w of cfgResult.warnings) {
367
+ ctx.ui.notify(`soly config: ${w}`, "warning");
368
+ }
369
+ if (cfgResult.sources.global || cfgResult.sources.project) {
370
+ const sources = [
371
+ cfgResult.sources.global ? `global: ${cfgResult.sources.global}` : null,
372
+ cfgResult.sources.project ? `project: ${cfgResult.sources.project}` : null,
373
+ ].filter(Boolean).join(", ");
374
+ ctx.ui.notify(`soly config loaded (${sources})`, "info");
375
+ }
376
+ // Auto-prune old iteration files per retention config
377
+ if (state.exists) {
378
+ const r = pruneOldIterations(state.solyDir, activeConfig.iteration.retentionDays);
379
+ if (r.pruned > 0) {
380
+ ctx.ui.notify(
381
+ `soly: pruned ${r.pruned} old iteration file(s) (retention ${activeConfig.iteration.retentionDays}d)`,
382
+ "info",
383
+ );
384
+ }
385
+ }
386
+
387
+ // Auto-install soly-aware subagent configs (soly-worker, soly-oracle)
388
+ // to ~/.pi/agent/agents/ on first run. Opt-in via
389
+ // config `agent.useSolyWorkerSubagents` (default false). Idempotent —
390
+ // respects any existing user-customized copies.
391
+ if (activeConfig.agent.useSolyWorkerSubagents) {
392
+ const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
393
+ const installResult = installSolyAgents(extRoot);
394
+ if (installResult.installed.length > 0) {
395
+ ctx.ui.notify(
396
+ `soly: installed subagent configs (${installResult.installed.join(", ")}) — run \`/subagents-doctor\` to verify`,
397
+ "info",
398
+ );
399
+ }
400
+ for (const e of installResult.errors) {
401
+ ctx.ui.notify(`soly: agent install error — ${e}`, "warning");
402
+ }
403
+ }
404
+
405
+ // Phase-scoped rules: if a phase is currently active, load its
406
+ // per-phase rules on top of the always-on rule set.
407
+ reloadPhaseRules();
408
+
409
+ // Restore agent from .soly/agent (if present) — survives session restart
410
+ if (state.exists) {
411
+ const agentFile = path.join(state.solyDir, "agent");
412
+ try {
413
+ if (fs.existsSync(agentFile)) {
414
+ const raw = fs.readFileSync(agentFile, "utf-8").trim();
415
+ if (raw && /^[a-zA-Z0-9_-]{1,64}$/.test(raw)) {
416
+ (globalThis as { __PI_SWITCH_AGENT__?: string }).__PI_SWITCH_AGENT__ = raw;
417
+ }
418
+ }
419
+ } catch { /* best-effort */ }
420
+ }
421
+
422
+ // Capture rule mtimes for the "rules changed since last session" diff
423
+ captureLastSessionRuleMtimes();
424
+
425
+ // Reset derived state
426
+ sessionCwd = ctx.cwd;
427
+ rulesLoaded = [];
428
+ lastRulesTokens = 0;
429
+ nudgeActiveForTask = false;
430
+ lastNudgePromptKey = "";
431
+ sessionStats = { turns: 0, tokensEstimate: 0 };
432
+
433
+ // Read git context (best-effort, silent on failure)
434
+ gitContext = await readGitContext(ctx.cwd);
435
+ lastGitSection = buildGitSection(gitContext);
436
+
437
+ // Detect project env (cheap; ~5 fs reads)
438
+ envSummary = detectEnv(ctx.cwd);
439
+ lastEnvSection = buildEnvSection(envSummary);
440
+
441
+ // Build code map (walk cwd once; cap at 2 levels deep)
442
+ try {
443
+ codeMap = buildCodeMap(ctx.cwd);
444
+ lastCodeMapSection = buildCodeMapSection(codeMap);
445
+ } catch {
446
+ codeMap = null;
447
+ lastCodeMapSection = "";
448
+ }
449
+
450
+ // Load project intent (zero-point docs from .soly/docs/) — always
451
+ refreshIntent();
452
+
453
+ // Start hot-reload watcher on rules dirs
454
+ if (hotReload) hotReload.stop();
455
+ hotReload = startHotReload(ruleSources, {
456
+ onChange: (reason) => {
457
+ refreshRules();
458
+ updateStatus({
459
+ ui: { setStatus: (id, text) => ctx.ui.setStatus(id, text) },
460
+ });
461
+ },
462
+ });
463
+ // Editors save in bursts (write to .tmp, rename, touch). Coalesce
464
+ // those rapid reload events into a single user-visible notify.
465
+ hotReload.setNotifyHandler((reason) => {
466
+ ctx.ui.notify(`soly: rules reloaded (${reason})`, "info");
467
+ });
468
+
469
+ // Notifications (one-shot at startup)
470
+ if (alwaysOnRules.length > 0) {
471
+ const bySource = alwaysOnRules.reduce<Record<string, number>>((acc, r) => {
472
+ acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1;
473
+ return acc;
474
+ }, {});
475
+ const breakdown = Object.entries(bySource)
476
+ .map(([k, v]) => `${v} ${k}`)
477
+ .join(" + ");
478
+ let summary = `soly rules: ${alwaysOnRules.length} (${breakdown})`;
479
+ if (phaseRules.length > 0) {
480
+ summary += ` + ${phaseRules.length} phase-${state.currentPhase?.number}`;
481
+ }
482
+ ctx.ui.notify(summary, "info");
483
+
484
+ if (overriddenRulePaths.length > 0) {
485
+ ctx.ui.notify(
486
+ `soly: ${overriddenRulePaths.length} rule(s) overridden by project (${overriddenRulePaths.join(", ")})`,
487
+ "info",
488
+ );
489
+ }
490
+
491
+ // Rules diff vs last session
492
+ const currentMtimes: Record<string, number> = {};
493
+ for (const r of alwaysOnRules) currentMtimes[`${r.source}::${r.absPath}`] = r.mtimeMs;
494
+ const lastKeys = new Set(Object.keys(lastSessionMtimes));
495
+ const currentKeys = new Set(Object.keys(currentMtimes));
496
+ const added = [...currentKeys].filter((k) => !lastKeys.has(k));
497
+ const removed = [...lastKeys].filter((k) => !currentKeys.has(k));
498
+ const changed: string[] = [];
499
+ for (const k of currentKeys) {
500
+ if (lastKeys.has(k) && lastSessionMtimes[k] !== currentMtimes[k]) {
501
+ changed.push(k);
502
+ }
503
+ }
504
+ if (added.length || removed.length || changed.length) {
505
+ const parts: string[] = [];
506
+ if (added.length) parts.push(`+${added.length}`);
507
+ if (removed.length) parts.push(`-${removed.length}`);
508
+ if (changed.length) parts.push(`~${changed.length}`);
509
+ ctx.ui.notify(`soly: rules changed since last session (${parts.join(" ")})`, "info");
510
+ }
511
+
512
+ // Rule budget analytics
513
+ const analytics = analyzeRules(alwaysOnRules, CONTEXT_WINDOW_TOKENS);
514
+ if (analytics.contextBudgetPct > 5) {
515
+ ctx.ui.notify(
516
+ `soly: rules use ${analytics.contextBudgetPct.toFixed(1)}% of context window (${formatTok(analytics.totalTokens)} across ${analytics.fileCount} files)`,
517
+ "info",
518
+ );
519
+ }
520
+ } else {
521
+ ctx.ui.notify("soly rules: none found in .soly/rules.local, .soly/rules, or ~/.soly/rules", "info");
522
+ }
523
+
524
+ if (state.exists) {
525
+ ctx.ui.notify(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
526
+ }
527
+
528
+ updateStatus(ctx);
529
+ });
530
+
531
+ pi.on("session_shutdown", async (_event, _ctx) => {
532
+ // Stop hot-reload watcher — fs.watch handles hold OS resources
533
+ if (hotReload) {
534
+ hotReload.stop();
535
+ hotReload = null;
536
+ }
537
+ // Persist rule mtimes so the next session can show the diff
538
+ persistRuleMtimes();
539
+ });
540
+
541
+ pi.on("before_agent_start", async (event, ctx) => {
542
+ const sections: string[] = [];
543
+ let totalRulesTokens = 0;
544
+
545
+ // pi's own resource paths (AGENTS.md / CLAUDE.md it already loaded)
546
+ // — used to inform rule globs, not to dedup context (soly doesn't
547
+ // load context files).
548
+ const piPaths = (event.systemPromptOptions.contextFiles ?? []).map((c) => c.path);
549
+
550
+ // 1. Rules section
551
+ const allRules = combinedRules();
552
+ if (allRules.length > 0) {
553
+ const promptFiles = extractFilePathsFromPrompt(event.prompt);
554
+ const activeGlobs = [...new Set([...promptFiles, ...piPaths])];
555
+
556
+ const hasPhase = phaseRules.length > 0;
557
+ const { section, loaded } = buildRulesSection(allRules, activeGlobs, {
558
+ phaseNumber: state.currentPhase?.number,
559
+ groupByPhase: hasPhase,
560
+ });
561
+ rulesLoaded = loaded;
562
+ if (section) {
563
+ sections.push(section);
564
+ totalRulesTokens = Math.ceil(section.length / 4);
565
+ }
566
+ } else {
567
+ rulesLoaded = [];
568
+ }
569
+ lastRulesTokens = totalRulesTokens;
570
+
571
+ // 2. Project state section
572
+ if (state.exists) {
573
+ const section = buildProjectStateSection(state);
574
+ if (section) sections.push(section);
575
+ }
576
+
577
+ // 2.5. Cross-extension integrations: dynamically mention only the
578
+ // sibling pi-extensions that are actually loaded. Driven by
579
+ // `integrations.ts` registry — add new entries there.
580
+ const integrationSection = buildIntegrationsSection(pi.getActiveTools());
581
+ if (integrationSection) {
582
+ sections.push(integrationSection);
583
+ }
584
+
585
+ // 3.5. Project intent (zero-point docs) — always injected when present
586
+ if (lastIntentSection) {
587
+ sections.push(lastIntentSection);
588
+ }
589
+
590
+ // 3.6. Inline intent bodies (opt-in via frontmatter `inline: true`)
591
+ const inlineBodies = loadInlineIntentBodies(intentDocs);
592
+ for (const ib of inlineBodies) {
593
+ sections.push(`\n### intent: ${ib.relPath}\n\n${ib.body}`);
594
+ }
595
+
596
+ // 4. Git context section (always injected when available — cheap, high signal)
597
+ if (lastGitSection) {
598
+ sections.push(lastGitSection);
599
+ }
600
+
601
+ // 5. Project env section (cheap; high signal for tool/script choice)
602
+ if (lastEnvSection) {
603
+ sections.push(lastEnvSection);
604
+ }
605
+
606
+ // 6. Project layout (code map) — always injected when available
607
+ if (lastCodeMapSection) {
608
+ sections.push(lastCodeMapSection);
609
+ }
610
+
611
+ // 7. Behavioral nudge
612
+ const heuristics = classifyTaskHeuristics(event.prompt);
613
+ sections.push(buildNudgeSection(heuristics));
614
+
615
+ // 7.5 Soly drift reminder — injected when the user has been doing
616
+ // non-soly work for several turns. Throttled: at most once per
617
+ // REMINDER_THRESHOLD turns. Resets when a soly verb is parsed.
618
+ if (
619
+ solyDrift.turnsSinceLastSolyVerb >= solyDrift.REMINDER_THRESHOLD &&
620
+ solyDrift.turnsSinceLastSolyVerb - solyDrift.lastReminderAt >= solyDrift.REMINDER_THRESHOLD
621
+ ) {
622
+ const reminder = buildDriftReminder(solyDrift.turnsSinceLastSolyVerb);
623
+ if (reminder) {
624
+ sections.push(`\n## soly drift\n\n${reminder}\n`);
625
+ solyDrift.lastReminderAt = solyDrift.turnsSinceLastSolyVerb;
626
+ }
627
+ }
628
+ if (heuristics.nonTrivial || heuristics.researchHeavy) {
629
+ nudgeActiveForTask = true;
630
+ lastNudgePromptKey = event.prompt.slice(0, 200);
631
+ }
632
+
633
+ // 7. Update status bar
634
+ updateStatus(ctx);
635
+
636
+ if (sections.length === 0) return;
637
+ return {
638
+ systemPrompt: event.systemPrompt + sections.join("\n"),
639
+ };
640
+ });
641
+
642
+ pi.on("input", async (event, ctx) => {
643
+ // Nudge notify — runs BEFORE workflows/* (which may transform).
644
+ // Soft UI hint, never blocks the input.
645
+ if (event.source !== "interactive") return;
646
+ const text = event.text.trim();
647
+ if (!text || text.startsWith("/")) return;
648
+ if (text.startsWith("soly ")) return; // workflow verb — let workflows handle it
649
+ if (text.slice(0, 200) === lastNudgePromptKey && nudgeActiveForTask) return;
650
+
651
+ const heuristics = classifyTaskHeuristics(text);
652
+ if (!heuristics.nonTrivial && !heuristics.researchHeavy) return;
653
+
654
+ const angle =
655
+ heuristics.suggestedAngles[0] ?? "want me to confirm assumptions before I start?";
656
+
657
+ const label = heuristics.researchHeavy
658
+ ? "soly: research-heavy prompt — clarifying question?"
659
+ : "soly: non-trivial prompt — clarifying question?";
660
+
661
+ ctx.ui.notify(`${label} ${angle}`, "info");
662
+ nudgeActiveForTask = true;
663
+ lastNudgePromptKey = text.slice(0, 200);
664
+
665
+ // Drift counter — non-soly, non-slash, non-trivial prompt.
666
+ // Workflow verbs reset this via onWorkflowUsed callback.
667
+ solyDrift.turnsSinceLastSolyVerb += 1;
668
+ });
669
+
670
+ pi.on("turn_end", async (_event, ctx) => {
671
+ const beforeRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(",");
672
+ const beforeStateUpdated = state.lastUpdated;
673
+
674
+ refreshRules();
675
+ refreshState();
676
+
677
+ const afterRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(",");
678
+ const rulesChanged = beforeRules !== afterRules;
679
+ const stateChanged = beforeStateUpdated !== state.lastUpdated;
680
+
681
+ // Update session stats — count assistant turns + rough token estimate
682
+ const entries = ctx.sessionManager.getBranch();
683
+ let turns = 0;
684
+ let tokens = 0;
685
+ for (const entry of entries) {
686
+ if (entry.type === "message" && entry.message.role === "assistant") {
687
+ turns++;
688
+ }
689
+ }
690
+ const usage = ctx.getContextUsage();
691
+ if (usage) tokens = usage.tokens ?? 0;
692
+ sessionStats = { turns, tokensEstimate: tokens };
693
+
694
+ // Refresh git context (cheap; debounced naturally by turn cadence)
695
+ if (sessionCwd) {
696
+ const newGit = await readGitContext(sessionCwd);
697
+ if (
698
+ newGit.branch !== gitContext.branch ||
699
+ newGit.statusShort !== gitContext.statusShort
700
+ ) {
701
+ gitContext = newGit;
702
+ lastGitSection = buildGitSection(gitContext);
703
+ }
704
+ }
705
+
706
+ // Refresh intent (zero-point docs) — cheap, but skip if last was recent
707
+ const beforeIntentCount = intentDocs.length;
708
+ refreshIntent();
709
+ if (intentDocs.length !== beforeIntentCount) {
710
+ // re-render status (intentionally don't push a section — system
711
+ // prompt regenerates next turn)
712
+ }
713
+
714
+ if (rulesChanged || stateChanged) {
715
+ updateStatus(ctx);
716
+ }
717
+ });
718
+ }