pi-soly 1.6.0 โ†’ 1.8.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.
Files changed (4) hide show
  1. package/commands.ts +13 -1
  2. package/core.ts +151 -0
  3. package/index.ts +26 -11
  4. package/package.json +1 -1
package/commands.ts CHANGED
@@ -21,9 +21,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
  import {
22
22
  analyzeRules,
23
23
  buildProgressBar,
24
+ buildRulesContextStats,
24
25
  CONTEXT_WINDOW_TOKENS,
25
26
  extractFilePathsFromPrompt,
26
27
  formatAnalyticsFull,
28
+ formatRulesContextStats,
27
29
  formatTok,
28
30
  readIfExists,
29
31
  solyDirFor,
@@ -70,7 +72,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
70
72
 
71
73
  pi.registerCommand("rules", {
72
74
  description:
73
- "manage soly rules (list, show, analytics, reload, enable, disable)",
75
+ "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
74
76
  handler: async (args, ctx) => {
75
77
  const ui: CommandUI = {
76
78
  notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
@@ -129,6 +131,16 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
129
131
  return;
130
132
  }
131
133
 
134
+ if (sub === "stats") {
135
+ // Claude-memory-style breakdown: shows which rules are always-on
136
+ // (every turn) vs glob-matched (only when prompt has file paths).
137
+ // Surfaces context bloat and verifies rules will actually fire.
138
+ const rules = getRules();
139
+ const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
140
+ ui.notify(formatRulesContextStats(stats), "info");
141
+ return;
142
+ }
143
+
132
144
  if (sub === "show") {
133
145
  if (!target) {
134
146
  ui.notify("Usage: /rules show <path>", "error");
package/core.ts CHANGED
@@ -993,6 +993,127 @@ export function formatAnalyticsFull(analytics: RuleAnalytics): string {
993
993
  return lines.join("\n");
994
994
  }
995
995
 
996
+ // =============================================================================
997
+ // Rules context stats โ€” Claude-memory-style breakdown
998
+ // =============================================================================
999
+ //
1000
+ // Shows which rules are "always-on" (loaded every turn) vs "glob-matched"
1001
+ // (loaded only when file paths in prompt match). Useful for spotting
1002
+ // context bloat and verifying rules will actually fire.
1003
+
1004
+ export interface RuleStat {
1005
+ relPath: string;
1006
+ tokens: number;
1007
+ sourceLabel: string;
1008
+ description?: string;
1009
+ always: boolean;
1010
+ globs: string[];
1011
+ loadedLastTurn: boolean;
1012
+ }
1013
+
1014
+ export interface RulesContextStats {
1015
+ totalLoaded: number;
1016
+ totalTokens: number;
1017
+ contextBudgetPct: number;
1018
+ alwaysOn: RuleStat[];
1019
+ globMatched: RuleStat[];
1020
+ disabled: RuleStat[];
1021
+ lastTurn: {
1022
+ promptFiles: string[];
1023
+ matchedRulePaths: string[];
1024
+ };
1025
+ }
1026
+
1027
+ export function buildRulesContextStats(
1028
+ rules: RuleFile[],
1029
+ contextWindowTokens: number,
1030
+ lastTurn?: { promptFiles: string[]; matchedRelPaths: string[] },
1031
+ ): RulesContextStats {
1032
+ const enabled = rules.filter((r) => r.enabled);
1033
+ const disabled = rules.filter((r) => !r.enabled);
1034
+ const lastTurnMatched = new Set(lastTurn?.matchedRelPaths ?? []);
1035
+ const stat = (r: RuleFile, loadedLastTurn: boolean): RuleStat => ({
1036
+ relPath: r.relPath,
1037
+ tokens: estimateTokens(r.body),
1038
+ sourceLabel: r.sourceLabel,
1039
+ description: r.meta.description,
1040
+ always: r.meta.always === true,
1041
+ globs: r.meta.globs ?? [],
1042
+ loadedLastTurn,
1043
+ });
1044
+ const alwaysOn: RuleStat[] = [];
1045
+ const globMatched: RuleStat[] = [];
1046
+ for (const r of enabled) {
1047
+ const isAlways = r.meta.always === true;
1048
+ const isLoadedLastTurn = lastTurnMatched.has(r.relPath) || isAlways;
1049
+ const s = stat(r, isLoadedLastTurn);
1050
+ if (isAlways) alwaysOn.push(s);
1051
+ else globMatched.push(s);
1052
+ }
1053
+ const totalTokens = [...alwaysOn, ...globMatched].reduce((a, b) => a + b.tokens, 0);
1054
+ return {
1055
+ totalLoaded: enabled.length,
1056
+ totalTokens,
1057
+ contextBudgetPct:
1058
+ contextWindowTokens > 0 ? (totalTokens / contextWindowTokens) * 100 : 0,
1059
+ alwaysOn,
1060
+ globMatched,
1061
+ disabled: disabled.map((r) => stat(r, false)),
1062
+ lastTurn: {
1063
+ promptFiles: lastTurn?.promptFiles ?? [],
1064
+ matchedRulePaths: lastTurn?.matchedRelPaths ?? [],
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ export function formatRulesContextStats(stats: RulesContextStats): string {
1070
+ const lines: string[] = [];
1071
+ lines.push(`๐Ÿ“Š Rules context stats`);
1072
+ lines.push(``);
1073
+ const ctxWindow = stats.contextBudgetPct > 0
1074
+ ? Math.round(stats.totalTokens / (stats.contextBudgetPct / 100))
1075
+ : 0;
1076
+ lines.push(
1077
+ `Loaded: ${stats.totalLoaded} rule(s) ยท ${formatTok(stats.totalTokens)} ยท ${stats.contextBudgetPct.toFixed(1)}% of ${formatTok(ctxWindow)} context`,
1078
+ );
1079
+ lines.push(``);
1080
+ if (stats.alwaysOn.length > 0) {
1081
+ lines.push(`ALWAYS-ON (loaded every turn):`);
1082
+ for (const r of stats.alwaysOn) {
1083
+ const last = r.loadedLastTurn ? " โœ“" : "";
1084
+ const desc = r.description ? ` โ€” "${r.description}"` : "";
1085
+ lines.push(` โ— ${r.relPath} ${formatTok(r.tokens)}${desc}${last}`);
1086
+ }
1087
+ lines.push(``);
1088
+ }
1089
+ if (stats.globMatched.length > 0) {
1090
+ lines.push(`GLOB-MATCHED (loaded when prompt file matches):`);
1091
+ for (const r of stats.globMatched) {
1092
+ const last = r.loadedLastTurn ? " โœ“" : "";
1093
+ const desc = r.description ? ` โ€” "${r.description}"` : "";
1094
+ const globs = r.globs.length > 0 ? ` [globs: ${r.globs.join(", ")}]` : "";
1095
+ lines.push(` โ— ${r.relPath} ${formatTok(r.tokens)}${desc}${globs}${last}`);
1096
+ }
1097
+ lines.push(``);
1098
+ }
1099
+ if (stats.disabled.length > 0) {
1100
+ lines.push(`DISABLED:`);
1101
+ for (const r of stats.disabled) {
1102
+ lines.push(` โ—‹ ${r.relPath} ${formatTok(r.tokens)} โ€” disabled`);
1103
+ }
1104
+ lines.push(``);
1105
+ }
1106
+ if (stats.lastTurn.promptFiles.length > 0) {
1107
+ lines.push(`Last turn: ${stats.lastTurn.promptFiles.length} file path(s) in prompt`);
1108
+ for (const f of stats.lastTurn.promptFiles) {
1109
+ lines.push(` โ†’ ${f}`);
1110
+ }
1111
+ } else {
1112
+ lines.push(`Last turn: no file paths in prompt (only always-on rules loaded)`);
1113
+ }
1114
+ return lines.join("\n");
1115
+ }
1116
+
996
1117
  // ============================================================================
997
1118
  // @import resolver (markdown only)
998
1119
  // ============================================================================
@@ -1614,3 +1735,33 @@ export function buildDriftReminder(turnsSinceLastVerb: number): string | null {
1614
1735
  const when = turnsSinceLastVerb === 1 ? "1 turn" : `${turnsSinceLastVerb} turns`;
1615
1736
  return `soly drift hint: ${when} since last soly verb. Consider \`${verb}\` to sync state (pause saves HANDOFF for resume across compactions).`;
1616
1737
  }
1738
+
1739
+ // =============================================================================
1740
+ // Post-work rules check: which rules apply to a set of edited files?
1741
+ // =============================================================================
1742
+ //
1743
+ // Used by the turn_end hook to surface a checklist of rules that SHOULD have
1744
+ // been followed during this turn. Honest post-hook โ€” does not claim to detect
1745
+ // violations, just lists what was applicable so the user can verify.
1746
+
1747
+ export function rulesApplicableToFiles(
1748
+ rules: RuleFile[],
1749
+ editedFiles: string[],
1750
+ ): string[] {
1751
+ const applicable = new Set<string>();
1752
+ for (const filePath of editedFiles) {
1753
+ for (const rule of rules) {
1754
+ if (!rule.enabled) continue;
1755
+ const globs = rule.meta.globs;
1756
+ const always = rule.meta.always === true;
1757
+ if (always) {
1758
+ applicable.add(rule.relPath);
1759
+ continue;
1760
+ }
1761
+ if (globs && globs.some((g) => matchesGlob(filePath, g))) {
1762
+ applicable.add(rule.relPath);
1763
+ }
1764
+ }
1765
+ }
1766
+ return [...applicable];
1767
+ }
package/index.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  loadAllRules,
37
37
  loadPhaseRules,
38
38
  loadProjectState,
39
+ matchesGlob,
40
+ rulesApplicableToFiles,
39
41
  STATUS_ID,
40
42
  solyDirFor,
41
43
  isLegacySolyDir,
@@ -125,7 +127,7 @@ export default function solyExtension(pi: ExtensionAPI) {
125
127
 
126
128
  // Behavioral nudge state
127
129
  let nudgeActiveForTask = false;
128
- let rulesEditNotifyShown = false;
130
+ let editedFilesThisTurn = new Set<string>();
129
131
  let lastNudgePromptKey = "";
130
132
 
131
133
  // Git context (cached, refreshed on hot reload + before_agent_start)
@@ -427,7 +429,6 @@ export default function solyExtension(pi: ExtensionAPI) {
427
429
  rulesLoaded = [];
428
430
  lastRulesTokens = 0;
429
431
  nudgeActiveForTask = false;
430
- rulesEditNotifyShown = false;
431
432
  lastNudgePromptKey = "";
432
433
  sessionStats = { turns: 0, tokensEstimate: 0 };
433
434
 
@@ -711,21 +712,35 @@ export default function solyExtension(pi: ExtensionAPI) {
711
712
  if (rulesChanged || stateChanged) {
712
713
  updateStatus(ctx);
713
714
  }
715
+
716
+ // Post-work rules check: surface applicable rules for files edited
717
+ // in this turn. Honest post-hook โ€” doesn't pretend to detect violations,
718
+ // just reminds the user which rules SHOULD have been followed.
719
+ if (editedFilesThisTurn.size > 0) {
720
+ const applicable = rulesApplicableToFiles(
721
+ combinedRules(),
722
+ [...editedFilesThisTurn],
723
+ );
724
+ if (applicable.length > 0) {
725
+ ctx.ui.notify(
726
+ `๐Ÿ“‹ Rules check: edited ${editedFilesThisTurn.size} file(s), ${applicable.length} rule(s) applied:\n โ€ข ${applicable.join("\n โ€ข ")}`,
727
+ "info",
728
+ );
729
+ }
730
+ editedFilesThisTurn = new Set();
731
+ }
714
732
  });
715
733
 
716
734
  // ============================================================================
717
- // tool_call: rules reinforcement โ€” fire a brief notify to the user when
718
- // the LLM is about to edit/write a file that has applicable rules.
719
- // This doesn't block the tool โ€” it's a visibility signal so the user
720
- // can spot when the LLM is editing without checking rules.
735
+ // tool_call: track files edited in this turn. Used at turn_end to surface
736
+ // applicable rules as a post-work checklist ("did the agent follow them?").
721
737
  // ============================================================================
722
738
  pi.on("tool_call", async (event, _ctx) => {
723
739
  if (event.toolName !== "edit" && event.toolName !== "write") return;
724
- const activeRules = combinedRules();
725
- if (activeRules.length === 0) return;
726
- // Don't spam โ€” only notify once per session
727
- if (rulesEditNotifyShown) return;
728
- rulesEditNotifyShown = true;
740
+ const input = event.input as { path?: string };
741
+ if (input?.path) {
742
+ editedFilesThisTurn.add(input.path);
743
+ }
729
744
  });
730
745
 
731
746
  // Mount built-in sub-features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Project management framework for pi-coding-agent. Workflows, planning, multi-question picker, agent switcher, live task list โ€” one npm install, zero config.",
5
5
  "type": "module",
6
6
  "main": "index.ts",