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.
- package/commands.ts +13 -1
- package/core.ts +151 -0
- package/index.ts +26 -11
- 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
|
|
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:
|
|
718
|
-
//
|
|
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
|
|
725
|
-
if (
|
|
726
|
-
|
|
727
|
-
|
|
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.
|
|
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",
|