supipowers 2.1.0 → 2.2.0

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.
@@ -0,0 +1,511 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ import YAML from "yaml";
6
+
7
+ import type { Platform, PlatformContext, CommandInfo } from "../platform/types.js";
8
+
9
+ type RuleProvider = "native" | "cursor" | "windsurf" | "cline";
10
+ type RuleLevel = "project" | "user";
11
+ type RuleBucket = "ttsr" | "always" | "rulebook" | "inactive";
12
+ type RunbookMode = "rules" | "ttsr" | "commands" | "help";
13
+
14
+ interface RuleSource {
15
+ provider: RuleProvider;
16
+ level: RuleLevel;
17
+ path: string;
18
+ priority: number;
19
+ }
20
+
21
+ interface RuleCandidate {
22
+ name: string;
23
+ content: string;
24
+ description: string | null;
25
+ alwaysApply: boolean;
26
+ globs: string[];
27
+ condition: string[];
28
+ triggers: string[];
29
+ scope: string[];
30
+ interruptMode: string | null;
31
+ source: RuleSource;
32
+ }
33
+
34
+ export interface RegisteredRule extends RuleCandidate {
35
+ bucket: RuleBucket;
36
+ shadowedBy?: RuleSource;
37
+ }
38
+
39
+ export interface RuleDiscoveryResult {
40
+ active: RegisteredRule[];
41
+ shadowed: RegisteredRule[];
42
+ checkedLocations: string[];
43
+ }
44
+
45
+ export interface RuleDiscoveryOptions {
46
+ homeDir?: string;
47
+ includeUserRules?: boolean;
48
+ }
49
+
50
+ interface FrontmatterResult {
51
+ metadata: Record<string, unknown>;
52
+ body: string;
53
+ }
54
+
55
+ const RULE_EXTENSIONS = new Set([".md", ".mdc"]);
56
+ const PROVIDER_PRIORITY: Record<RuleProvider, number> = {
57
+ native: 100,
58
+ cursor: 50,
59
+ windsurf: 50,
60
+ cline: 40,
61
+ };
62
+
63
+ function parseFrontmatter(content: string): FrontmatterResult {
64
+ if (!content.startsWith("---\n")) {
65
+ return { metadata: {}, body: content.trim() };
66
+ }
67
+
68
+ const closing = content.indexOf("\n---", 4);
69
+ if (closing === -1) {
70
+ return { metadata: {}, body: content.trim() };
71
+ }
72
+
73
+ const raw = content.slice(4, closing);
74
+ const bodyStart = content.indexOf("\n", closing + 4);
75
+ const body = bodyStart === -1 ? "" : content.slice(bodyStart + 1).trim();
76
+ try {
77
+ const parsed = YAML.parse(raw);
78
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
79
+ return { metadata: parsed as Record<string, unknown>, body };
80
+ }
81
+ } catch {
82
+ // Match OMP's permissive behavior: invalid YAML still leaves the rule loadable.
83
+ }
84
+
85
+ return { metadata: parseSimpleFrontmatter(raw), body };
86
+ }
87
+
88
+ function parseSimpleFrontmatter(raw: string): Record<string, unknown> {
89
+ const metadata: Record<string, unknown> = {};
90
+ for (const line of raw.split(/\r?\n/)) {
91
+ const match = /^(\w+):\s*(.*)$/.exec(line);
92
+ if (!match) continue;
93
+ metadata[match[1]] = match[2];
94
+ }
95
+ return metadata;
96
+ }
97
+
98
+ function asString(value: unknown): string | null {
99
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
100
+ }
101
+
102
+ function asStringArray(value: unknown): string[] {
103
+ if (typeof value === "string") {
104
+ const trimmed = value.trim();
105
+ return trimmed.length > 0 ? [trimmed] : [];
106
+ }
107
+ if (!Array.isArray(value)) return [];
108
+ return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
109
+ .map((item) => item.trim());
110
+ }
111
+
112
+ function asBoolean(value: unknown): boolean {
113
+ return value === true;
114
+ }
115
+
116
+ function ruleNameFromPath(filePath: string, fallbackName?: string): string {
117
+ if (fallbackName) return fallbackName;
118
+ const base = basename(filePath);
119
+ const ext = extname(base);
120
+ return ext.length > 0 ? base.slice(0, -ext.length) : base;
121
+ }
122
+
123
+ function buildRuleCandidate(filePath: string, source: RuleSource, fallbackName?: string): RuleCandidate | null {
124
+ let content: string;
125
+ try {
126
+ content = readFileSync(filePath, "utf8");
127
+ } catch {
128
+ return null;
129
+ }
130
+
131
+ const { metadata, body } = parseFrontmatter(content);
132
+ const condition = asStringArray(
133
+ metadata.condition ?? metadata.ttsr_trigger ?? metadata.ttsrTrigger,
134
+ );
135
+
136
+ return {
137
+ name: ruleNameFromPath(filePath, fallbackName),
138
+ content: body,
139
+ description: asString(metadata.description),
140
+ alwaysApply: asBoolean(metadata.alwaysApply),
141
+ globs: asStringArray(metadata.globs),
142
+ condition,
143
+ scope: asStringArray(metadata.scope),
144
+ triggers: asStringArray(metadata.triggers ?? metadata.triggerDescription),
145
+ interruptMode: asString(metadata.interruptMode),
146
+ source,
147
+ };
148
+ }
149
+
150
+ function isRuleFile(filePath: string): boolean {
151
+ return RULE_EXTENSIONS.has(extname(filePath));
152
+ }
153
+
154
+ function loadRuleDir(dirPath: string, source: Omit<RuleSource, "path">): RuleCandidate[] {
155
+ if (!existsSync(dirPath)) return [];
156
+ let entries: string[];
157
+ try {
158
+ entries = readdirSync(dirPath).sort();
159
+ } catch {
160
+ return [];
161
+ }
162
+
163
+ const rules: RuleCandidate[] = [];
164
+ for (const entry of entries) {
165
+ const filePath = join(dirPath, entry);
166
+ if (!isRuleFile(filePath)) continue;
167
+ try {
168
+ if (!statSync(filePath).isFile()) continue;
169
+ } catch {
170
+ continue;
171
+ }
172
+ const candidate = buildRuleCandidate(filePath, { ...source, path: filePath });
173
+ if (candidate) rules.push(candidate);
174
+ }
175
+ return rules;
176
+ }
177
+
178
+ function loadSingleRule(filePath: string, source: Omit<RuleSource, "path">, fallbackName?: string): RuleCandidate[] {
179
+ if (!existsSync(filePath)) return [];
180
+ try {
181
+ if (!statSync(filePath).isFile()) return [];
182
+ } catch {
183
+ return [];
184
+ }
185
+ const candidate = buildRuleCandidate(filePath, { ...source, path: filePath }, fallbackName);
186
+ return candidate ? [candidate] : [];
187
+ }
188
+
189
+ function findNearestClineRules(cwd: string, home: string): string | null {
190
+ let current = resolve(cwd);
191
+ const homeResolved = resolve(home);
192
+ while (true) {
193
+ const candidate = join(current, ".clinerules");
194
+ if (existsSync(candidate)) return candidate;
195
+ if (current === homeResolved || dirname(current) === current) return null;
196
+ current = dirname(current);
197
+ }
198
+ }
199
+
200
+ function createSource(provider: RuleProvider, level: RuleLevel): Omit<RuleSource, "path"> {
201
+ return { provider, level, priority: PROVIDER_PRIORITY[provider] };
202
+ }
203
+
204
+ function collectRuleCandidates(cwd: string, options: RuleDiscoveryOptions): { candidates: RuleCandidate[]; checked: string[] } {
205
+ const home = options.homeDir ?? homedir();
206
+ const includeUser = options.includeUserRules ?? true;
207
+ const candidates: RuleCandidate[] = [];
208
+ const checked: string[] = [];
209
+
210
+ const nativeProject = join(cwd, ".omp", "rules");
211
+ checked.push(nativeProject);
212
+ candidates.push(...loadRuleDir(nativeProject, createSource("native", "project")));
213
+
214
+ if (includeUser) {
215
+ const nativeUser = join(home, ".omp", "agent", "rules");
216
+ checked.push(nativeUser);
217
+ candidates.push(...loadRuleDir(nativeUser, createSource("native", "user")));
218
+ }
219
+
220
+ if (includeUser) {
221
+ const cursorUser = join(home, ".cursor", "rules");
222
+ checked.push(cursorUser);
223
+ candidates.push(...loadRuleDir(cursorUser, createSource("cursor", "user")));
224
+ }
225
+ const cursorProject = join(cwd, ".cursor", "rules");
226
+ checked.push(cursorProject);
227
+ candidates.push(...loadRuleDir(cursorProject, createSource("cursor", "project")));
228
+
229
+ if (includeUser) {
230
+ const windsurfUser = join(home, ".codeium", "windsurf", "memories", "global_rules.md");
231
+ checked.push(windsurfUser);
232
+ candidates.push(...loadSingleRule(windsurfUser, createSource("windsurf", "user"), "global_rules"));
233
+ }
234
+ const windsurfProject = join(cwd, ".windsurf", "rules");
235
+ checked.push(windsurfProject);
236
+ candidates.push(...loadRuleDir(windsurfProject, createSource("windsurf", "project")));
237
+
238
+ const clineRules = findNearestClineRules(cwd, home);
239
+ if (clineRules) {
240
+ checked.push(clineRules);
241
+ try {
242
+ if (statSync(clineRules).isDirectory()) {
243
+ candidates.push(...loadRuleDir(clineRules, createSource("cline", "project")));
244
+ } else {
245
+ candidates.push(...loadSingleRule(clineRules, createSource("cline", "project"), "clinerules"));
246
+ }
247
+ } catch {
248
+ // Ignore unreadable Cline rule paths, matching discovery's best-effort behavior.
249
+ }
250
+ } else {
251
+ checked.push(join(cwd, ".clinerules"));
252
+ }
253
+
254
+ return { candidates, checked };
255
+ }
256
+
257
+ function bucketForRule(rule: RuleCandidate): RuleBucket {
258
+ if (rule.condition.length > 0) return "ttsr";
259
+ if (rule.alwaysApply) return "always";
260
+ if (rule.description) return "rulebook";
261
+ return "inactive";
262
+ }
263
+
264
+ export function discoverRegisteredRules(cwd: string, options: RuleDiscoveryOptions = {}): RuleDiscoveryResult {
265
+ const { candidates, checked } = collectRuleCandidates(cwd, options);
266
+ const seen = new Map<string, RegisteredRule>();
267
+ const active: RegisteredRule[] = [];
268
+ const shadowed: RegisteredRule[] = [];
269
+
270
+ for (const candidate of candidates) {
271
+ const registered: RegisteredRule = { ...candidate, bucket: bucketForRule(candidate) };
272
+ const existing = seen.get(candidate.name);
273
+ if (existing) {
274
+ shadowed.push({ ...registered, shadowedBy: existing.source });
275
+ continue;
276
+ }
277
+ seen.set(candidate.name, registered);
278
+ active.push(registered);
279
+ }
280
+
281
+ return { active, shadowed, checkedLocations: checked };
282
+ }
283
+
284
+ function displayPath(cwd: string, filePath: string): string {
285
+ const absolute = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
286
+ const home = homedir();
287
+ if (absolute === home || absolute.startsWith(`${home}/`)) {
288
+ return `~${absolute.slice(home.length)}`;
289
+ }
290
+ const rel = relative(cwd, absolute);
291
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
292
+ return absolute;
293
+ }
294
+
295
+ function summarizeCounts(rules: RegisteredRule[]): string {
296
+ const ttsr = rules.filter((rule) => rule.bucket === "ttsr").length;
297
+ const rulebook = rules.filter((rule) => rule.bucket === "rulebook").length;
298
+ const always = rules.filter((rule) => rule.bucket === "always").length;
299
+ const inactive = rules.filter((rule) => rule.bucket === "inactive").length;
300
+ return `${rules.length} registered (${ttsr} TTSR, ${rulebook} rulebook, ${always} always-apply, ${inactive} inactive)`;
301
+ }
302
+
303
+ function formatListValue(values: string[], empty: string): string[] {
304
+ if (values.length === 0) return [` ${empty}`];
305
+ return values.map((value) => ` - ${value}`);
306
+ }
307
+
308
+ function formatInlineList(values: string[]): string {
309
+ return values.join(", ");
310
+ }
311
+
312
+ function describeScope(rule: RegisteredRule): string {
313
+ if (rule.scope.length === 0) return "assistant prose and tool-call text";
314
+ const labels = rule.scope.map((scope) => {
315
+ const normalized = scope.toLowerCase();
316
+ if (normalized === "text") return "assistant prose";
317
+ if (normalized === "thinking") return "assistant thinking";
318
+ if (normalized === "tool" || normalized === "toolcall") return "all tool-call text";
319
+ return `tool scope ${scope}`;
320
+ });
321
+ return `${formatInlineList(labels)} only`;
322
+ }
323
+
324
+ function formatTriggerSummary(rule: RegisteredRule): string[] {
325
+ if (rule.triggers.length > 0) {
326
+ return [` Triggers: ${formatInlineList(rule.triggers)}`];
327
+ }
328
+
329
+ if (rule.condition.length === 0) return [" Triggers: none"];
330
+ return [
331
+ " Triggers: exact regex only; add `triggers:` frontmatter for a readable summary",
332
+ " Raw regex:",
333
+ ...formatListValue(rule.condition, "none"),
334
+ ];
335
+ }
336
+
337
+ function formatRule(rule: RegisteredRule, cwd: string): string[] {
338
+ const lines = [` ${rule.name}`];
339
+ if (rule.description) lines.push(` Description: ${rule.description}`);
340
+
341
+ if (rule.bucket === "ttsr") {
342
+ lines.push(" Applies: when assistant output matches the trigger phrase(s)");
343
+ lines.push(...formatTriggerSummary(rule));
344
+ lines.push(` Scope: ${describeScope(rule)}`);
345
+ lines.push(` Interrupt: ${rule.interruptMode ?? "default"}`);
346
+ } else if (rule.bucket === "always") {
347
+ lines.push(" Applies: alwaysApply=true, full rule content is injected at session start");
348
+ } else if (rule.bucket === "rulebook") {
349
+ lines.push(` Applies: on demand via rule://${rule.name} when the description/domain matches`);
350
+ } else {
351
+ lines.push(" Applies: inactive in OMP prompt surfaces (no description, condition, or alwaysApply)");
352
+ }
353
+
354
+ if (rule.globs.length > 0) {
355
+ lines.push(" Globs:");
356
+ lines.push(...formatListValue(rule.globs, "none"));
357
+ }
358
+ lines.push(` Source: ${rule.source.provider}/${rule.source.level} ${displayPath(cwd, rule.source.path)}`);
359
+ return lines;
360
+ }
361
+
362
+ function formatRuleSection(title: string, rules: RegisteredRule[], cwd: string): string[] {
363
+ const lines = [title];
364
+ if (rules.length === 0) {
365
+ lines.push(" none");
366
+ return lines;
367
+ }
368
+ for (const rule of rules) {
369
+ lines.push(...formatRule(rule, cwd), "");
370
+ }
371
+ if (lines[lines.length - 1] === "") lines.pop();
372
+ return lines;
373
+ }
374
+
375
+ function formatShadowedRules(shadowed: RegisteredRule[], cwd: string): string[] {
376
+ if (shadowed.length === 0) return [];
377
+ const lines = ["", `Shadowed rules (${shadowed.length})`];
378
+ for (const rule of shadowed) {
379
+ const by = rule.shadowedBy;
380
+ const source = by ? `${by.provider}/${by.level} ${displayPath(cwd, by.path)}` : "earlier rule";
381
+ lines.push(` ${rule.name}: ${displayPath(cwd, rule.source.path)} shadowed by ${source}`);
382
+ }
383
+ return lines;
384
+ }
385
+
386
+ export function formatRulesRunbook(discovery: RuleDiscoveryResult, cwd: string): string {
387
+ const sorted = [...discovery.active].sort((a, b) => a.name.localeCompare(b.name));
388
+ const ttsr = sorted.filter((rule) => rule.bucket === "ttsr");
389
+ const always = sorted.filter((rule) => rule.bucket === "always");
390
+ const rulebook = sorted.filter((rule) => rule.bucket === "rulebook");
391
+ const inactive = sorted.filter((rule) => rule.bucket === "inactive");
392
+ const lines = [
393
+ "/runbook rules",
394
+ "",
395
+ `Rules: ${summarizeCounts(sorted)}`,
396
+ "",
397
+ "Usage:",
398
+ " /runbook rules Show all registered rules",
399
+ " /runbook rules ttsr Show only TTSR stream-interrupt rules",
400
+ " /runbook commands Show registered slash commands",
401
+ "",
402
+ ...formatRuleSection("TTSR rules", ttsr, cwd),
403
+ "",
404
+ ...formatRuleSection("Always-apply rules", always, cwd),
405
+ "",
406
+ ...formatRuleSection("Rulebook rules", rulebook, cwd),
407
+ ];
408
+ if (inactive.length > 0) {
409
+ lines.push("", ...formatRuleSection("Inactive discovered rules", inactive, cwd));
410
+ }
411
+ lines.push(...formatShadowedRules(discovery.shadowed, cwd));
412
+ return lines.join("\n");
413
+ }
414
+
415
+ export function formatTtsrRunbook(discovery: RuleDiscoveryResult, cwd: string): string {
416
+ const ttsr = discovery.active
417
+ .filter((rule) => rule.bucket === "ttsr")
418
+ .sort((a, b) => a.name.localeCompare(b.name));
419
+ const lines = [
420
+ "/runbook rules ttsr",
421
+ "",
422
+ `TTSR rules: ${ttsr.length}`,
423
+ "",
424
+ "These rules are registered at session creation and monitor assistant output. When a condition matches, OMP injects the rule reminder without requiring a user prompt.",
425
+ "",
426
+ ...formatRuleSection("TTSR rules", ttsr, cwd),
427
+ ];
428
+ return lines.join("\n");
429
+ }
430
+
431
+ export function formatCommandsRunbook(commands: CommandInfo[]): string {
432
+ const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name));
433
+ const lines = [
434
+ "/runbook commands",
435
+ "",
436
+ `Registered slash commands: ${sorted.length}`,
437
+ "",
438
+ ];
439
+ if (sorted.length === 0) {
440
+ lines.push(" none");
441
+ return lines.join("\n");
442
+ }
443
+ for (const command of sorted) {
444
+ const description = command.description ?? "No description";
445
+ const source = command.source ? ` (${command.source})` : "";
446
+ lines.push(` /${command.name}${source}`);
447
+ lines.push(` ${description}`);
448
+ }
449
+ return lines.join("\n");
450
+ }
451
+
452
+ export function parseRunbookMode(args: string | undefined): RunbookMode {
453
+ const tokens = (args ?? "")
454
+ .trim()
455
+ .split(/\s+/)
456
+ .filter(Boolean)
457
+ .map((token) => token.toLowerCase());
458
+
459
+ if (tokens.length === 0) return "rules";
460
+ if (tokens[0] === "rules") {
461
+ if (tokens.length === 1) return "rules";
462
+ if (tokens[1] === "ttsr" || tokens[1] === "--ttsr") return "ttsr";
463
+ if (tokens[1] === "commands" || tokens[1] === "--commands") return "commands";
464
+ return "help";
465
+ }
466
+ if (tokens[0] === "ttsr" || tokens[0] === "--ttsr") return "ttsr";
467
+ if (tokens[0] === "commands" || tokens[0] === "--commands") return "commands";
468
+ if (tokens[0] === "help" || tokens[0] === "--help" || tokens[0] === "-h") return "help";
469
+ return "help";
470
+ }
471
+
472
+ function formatRunbookHelp(): string {
473
+ return [
474
+ "/runbook",
475
+ "",
476
+ "Usage:",
477
+ " /runbook rules Show all registered OMP rules",
478
+ " /runbook rules ttsr Show TTSR rule conditions",
479
+ " /runbook ttsr Alias for /runbook rules ttsr",
480
+ " /runbook commands Show registered slash commands",
481
+ "",
482
+ "This command is read-only and never starts an LLM turn.",
483
+ ].join("\n");
484
+ }
485
+
486
+ export function buildRunbookReport(platform: Platform, cwd: string, args: string | undefined): string {
487
+ const mode = parseRunbookMode(args);
488
+ if (mode === "help") return formatRunbookHelp();
489
+ if (mode === "commands") return formatCommandsRunbook(platform.getCommands());
490
+
491
+ const discovery = discoverRegisteredRules(cwd);
492
+ return mode === "ttsr" ? formatTtsrRunbook(discovery, cwd) : formatRulesRunbook(discovery, cwd);
493
+ }
494
+
495
+ export function handleRunbook(platform: Platform, ctx: PlatformContext, args?: string): void {
496
+ if (!ctx.hasUI) return;
497
+ try {
498
+ ctx.ui.notify(buildRunbookReport(platform, ctx.cwd, args), "info");
499
+ } catch (error) {
500
+ ctx.ui.notify(`Runbook failed: ${(error as Error).message}`, "error");
501
+ }
502
+ }
503
+
504
+ export function registerRunbookCommand(platform: Platform): void {
505
+ platform.registerCommand("runbook", {
506
+ description: "Show registered OMP rules, TTSR conditions, and slash commands without an LLM turn",
507
+ async handler(args: string | undefined, ctx: PlatformContext): Promise<void> {
508
+ handleRunbook(platform, ctx, args);
509
+ },
510
+ });
511
+ }