pi-soly 1.12.1 → 1.13.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/ask/index.ts CHANGED
@@ -71,7 +71,7 @@ export default function piAskExtension(pi: ExtensionAPI) {
71
71
  name: "ask_pro",
72
72
  label: "soly · ask_pro",
73
73
  description:
74
- "Ask the user one or more questions at once via a tabbed picker (≤6 questions); returns all answers in one call. USE WHEN: (a) you have 2+ related questions to gather in one batch, OR (b) you have ONE simple choice among 2-4 options where each option is a short label + 1-2 sentence description (no per-option code, no pros/cons). For ONE architectural/design comparison where each option needs its own code + pros/cons card on a full screen, use `decision_deck` instead. Per question: single-select (default), `multiSelect` (+ `minSelect`/`maxSelect`), `allowOther` (free-text 'Other…'), or `freeText` (typed answer, no options). Per option: `recommended` (⭐) and `preview` (side panel, fenced code highlighted). User can skip (`s`) or note (`n`). Prefer this over one-by-one questions.",
74
+ "Ask the user one or more questions at once via a tabbed picker (≤6 questions); returns all answers in one call. USE WHEN: (a) you have 2+ related questions to gather in one batch, OR (b) the choice is local — a sub-question inside an already-decided theme, or a simple label-vs-label «или/или» that doesn't justify a full-screen deck. Each option is a short label + 1-2 sentence description (no per-option code, no pros/cons). For a global architectural fork with real counterweight between options, use `decision_deck` instead. Per question: single-select (default), `multiSelect` (+ `minSelect`/`maxSelect`), `allowOther` (free-text 'Other…'), or `freeText` (typed answer, no options). Per option: `recommended` (⭐) and `preview` (side panel, fenced code highlighted). User can skip (`s`) or note (`n`). Prefer this over one-by-one questions.",
75
75
  parameters: Type.Object({
76
76
  questions: Type.Array(
77
77
  Type.Object({
package/config.ts CHANGED
@@ -45,6 +45,13 @@ export interface SolyConfig {
45
45
  * user (ask "ready to implement, or discuss first?") before writing code.
46
46
  * Default on. */
47
47
  confirmBeforeCode: boolean;
48
+ /** Inject a compact list of rules applicable to the file being
49
+ * edited/written, appended to the tool's result. Forces the LLM to
50
+ * confirm in the next message which rules were applied — closes the
51
+ * "rules read at start of turn, forgotten by edit time" gap.
52
+ * Default on. Disable for verbose rule sets that would otherwise
53
+ * flood the per-edit context. */
54
+ preActionRuleReminder: boolean;
48
55
  };
49
56
  display: {
50
57
  /** Always show the recommended (⭐) option as the first row. */
@@ -125,6 +132,7 @@ export const DEFAULT_CONFIG: SolyConfig = {
125
132
  nudgeNotify: false,
126
133
  toolHints: true,
127
134
  confirmBeforeCode: true,
135
+ preActionRuleReminder: true,
128
136
  },
129
137
  display: {
130
138
  defaultRecommendedFirst: true,
@@ -216,6 +224,8 @@ function deepMerge(base: SolyConfig, over: RawConfig): SolyConfig {
216
224
  merged.agent.toolHints = over.agent.toolHints;
217
225
  if (typeof over.agent.confirmBeforeCode === "boolean")
218
226
  merged.agent.confirmBeforeCode = over.agent.confirmBeforeCode;
227
+ if (typeof over.agent.preActionRuleReminder === "boolean")
228
+ merged.agent.preActionRuleReminder = over.agent.preActionRuleReminder;
219
229
  }
220
230
  if (over.display) {
221
231
  if (typeof over.display.defaultRecommendedFirst === "boolean")
package/core.ts CHANGED
@@ -571,6 +571,68 @@ ${blocks.join("\n\n---\n\n")}${skippedNote}
571
571
  return { section, loaded: applicable.map(ruleKey), interactive };
572
572
  }
573
573
 
574
+ // ============================================================================
575
+ // Per-file rule lookup (used by pre-action reminder in tool_result hook)
576
+ // ============================================================================
577
+ //
578
+ // Returns the subset of `rules` that apply to a single file path. Mirrors the
579
+ // glob logic in `buildRulesSection` (always/no-globs = universal; otherwise
580
+ // match `filePath` against `meta.globs`). Excludes disabled rules. Use this
581
+ // to feed the pre-action reminder — LLM should see applicable rules AT THE
582
+ // MOMENT of edit, not just at the start of the turn (where they decay).
583
+ export function getApplicableRulesForFile(
584
+ filePath: string,
585
+ rules: RuleFile[],
586
+ ): RuleFile[] {
587
+ return rules.filter((r) => {
588
+ if (!r.enabled) return false;
589
+ if (r.meta.always === true) return true;
590
+ const globs = r.meta.globs;
591
+ if (!globs || globs.length === 0) return true;
592
+ return globs.some((g) => matchesGlob(filePath, g));
593
+ });
594
+ }
595
+
596
+ // Formats a compact reminder block for the LLM to see right after editing
597
+ // `filePath`. Sorted by priority (high → medium → low → undefined), capped
598
+ // at `cap` rules (default 3) so the LLM isn't flooded. Returns "" when no
599
+ // rules apply — caller should treat that as "don't inject anything".
600
+ export function formatRuleReminder(
601
+ rules: RuleFile[],
602
+ filePath: string,
603
+ cap = 3,
604
+ ): string {
605
+ if (rules.length === 0) return "";
606
+
607
+ const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
608
+ const sorted = [...rules].sort((a, b) => {
609
+ const pa = priorityOrder[a.meta.priority ?? ""] ?? 1;
610
+ const pb = priorityOrder[b.meta.priority ?? ""] ?? 1;
611
+ if (pa !== pb) return pa - pb;
612
+ return a.relPath.localeCompare(b.relPath);
613
+ });
614
+
615
+ const shown = sorted.slice(0, cap);
616
+ const remaining = rules.length - shown.length;
617
+
618
+ const lines: string[] = [];
619
+ lines.push(`\n\n---\n\n📋 **Applicable rules for \`${filePath}\`:**\n`);
620
+ shown.forEach((r, i) => {
621
+ const desc = r.meta.description ? ` — ${r.meta.description}` : "";
622
+ lines.push(`${i + 1}. [\`${r.relPath}\`]${desc}`);
623
+ });
624
+ if (remaining > 0) {
625
+ lines.push(
626
+ `\n_…and ${remaining} more — use \`/why ${filePath}\` to see all._`,
627
+ );
628
+ }
629
+ lines.push(
630
+ `\nIn your next message, briefly confirm which were applied (or note why not).`,
631
+ );
632
+
633
+ return lines.join("\n");
634
+ }
635
+
574
636
  // ============================================================================
575
637
  // Phase-scoped rules loader
576
638
  // ============================================================================
package/deck/index.ts CHANGED
@@ -24,7 +24,7 @@ export default function piDeckExtension(pi: ExtensionAPI) {
24
24
  name: "decision_deck",
25
25
  label: "soly · decision_deck",
26
26
  description:
27
- "Present ONE design/architecture decision as a full-screen deck of cards (one per option) the user flips through (←/→ or 1-N) and picks with Enter. Use when 2-6 options each need their own card with code + pros/cons to be properly compared (e.g. architectural forks, API shapes, data-model choices). STRICTLY ONE question per call — for 2+ related questions in one batch, use `ask_pro` instead. Each option SHOULD carry either a non-trivial code snippet or explicit pros/cons; for plain label-vs-label choices, `ask_pro` is lighter. The user can attach a free-text note (rationale, caveats) via `n`; it's returned in the result. Native TUI. Returns the chosen option.",
27
+ "Present ONE design/architecture decision as a full-screen deck of cards (one per option) the user flips through (←/→ or 1-N) and picks with Enter. Use when comparing global architectural forks (architecture, API shape, data model) where each option needs its own card with code + pros/cons to be properly weighed against its counterweight — trade-offs that genuinely pull in different directions (e.g. consistency vs availability, sync vs async). STRICTLY ONE question per call — for 2+ related questions in one batch, use `ask_pro` instead. For local sub-questions inside an already-decided theme, or simple label-vs-label «или/или», `ask_pro` is lighter. Default to `ask_pro` unless the stakes are global. The user can attach a free-text note (rationale, caveats) via `n`; it's returned in the result. Native TUI. Returns the chosen option.",
28
28
  parameters: Type.Object({
29
29
  title: Type.Optional(Type.String({ description: "Decision title." })),
30
30
  prompt: Type.Optional(Type.String({ description: "Question shown above the cards." })),
package/index.ts CHANGED
@@ -32,7 +32,9 @@ import {
32
32
  CONTEXT_WINDOW_TOKENS,
33
33
  DEFAULT_PROGRESS,
34
34
  extractFilePathsFromPrompt,
35
+ formatRuleReminder,
35
36
  formatTok,
37
+ getApplicableRulesForFile,
36
38
  loadAllRules,
37
39
  loadPhaseRules,
38
40
  loadProjectState,
@@ -834,6 +836,29 @@ export default function solyExtension(pi: ExtensionAPI) {
834
836
  }
835
837
  });
836
838
 
839
+ // ============================================================================
840
+ // tool_result: inject a compact reminder of rules applicable to the file
841
+ // just edited/written, so the LLM sees them AT THE MOMENT of action (not
842
+ // 30 turns later when they've decayed in attention). The LLM is asked to
843
+ // confirm in its next message which rules were applied — closing the gap
844
+ // where rules loaded at the start of a turn are forgotten by the time the
845
+ // agent actually edits something.
846
+ // ============================================================================
847
+ pi.on("tool_result", async (event, _ctx) => {
848
+ if (!getActiveConfig().agent.preActionRuleReminder) return;
849
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
850
+ if (event.isError) return; // tool failed — don't pile on
851
+ const input = event.input as { path?: string };
852
+ const filePath = input?.path;
853
+ if (!filePath) return;
854
+ const applicable = getApplicableRulesForFile(filePath, combinedRules());
855
+ const reminder = formatRuleReminder(applicable, filePath);
856
+ if (!reminder) return;
857
+ return {
858
+ content: [...event.content, { type: "text" as const, text: reminder }],
859
+ };
860
+ });
861
+
837
862
  // ============================================================================
838
863
  // Chrome: working-indicator telemetry lifecycle + reactive data refresh
839
864
  // ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives execution and delegates to a worker subagent when available.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/tool-hints.ts CHANGED
@@ -52,7 +52,7 @@ export function buildToolHintSection(h: ToolHint): string | null {
52
52
  if (h.artifact)
53
53
  bits.push("- Examples / a table / a visual result: **ask the user first** — render it in the browser as an artifact (`html_artifact`), or just as text here? Then proceed accordingly.");
54
54
  if (h.deck)
55
- bits.push("- Comparing 2-6 options: if it's ONE question and each option needs code + pros/cons on its own card → use `decision_deck`. If it's 2+ related questions in one batch → use `ask_pro`. **Default to `ask_pro` unless you have explicit code or trade-offs per option.** Never use `decision_deck` for 2+ questions.");
55
+ bits.push("- Comparing 2-6 options: if it's a global architectural fork with real counterweight (trade-offs pulling in different directions) and each option needs code + pros/cons on its own card → use `decision_deck`. If it's 2+ related questions OR a local sub-question → use `ask_pro`. **Default to `ask_pro` unless the stakes are global.** Never use `decision_deck` for 2+ questions.");
56
56
  if (h.ask)
57
57
  bits.push("- Several things to clarify → use `ask_pro` (one batched picker). `decision_deck` does NOT support multi-question — never reach for it here.");
58
58
  if (bits.length === 0) return null;