@groeponline/pi-wishcraft 0.23.4 → 0.25.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.25.0] - 2026-08-20
6
+
7
+ ## [0.24.0] - 2026-08-20
8
+
9
+ ### Added
10
+ - Declarative policy engine (`wishcraft.policy`): in-process deny/inject rules in global settings, evaluated before command hooks. No process spawn. `policyEnabled: false` is the kill-switch.
11
+ - `/skills doctor` health table: broken frontmatter, descriptions over 240 chars, global/project duplicates, unused skills.
12
+
5
13
  ## [0.23.4] - 2026-08-20
6
14
 
7
15
  ### Fixed
package/README.md CHANGED
@@ -171,6 +171,35 @@ PY
171
171
 
172
172
  Repairs run on custom/extension tools only, before hooks: drop null optionals, parse JSON-string arrays before wrapping, turn `{}` into `[]` on array keys, wrap bare strings, alias `filePath` / `absolutePath` / `target_file` to `path`, unwrap degenerate markdown auto-links. Core tools (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) are never rewritten. `/repairs` prints the counters.
173
173
 
174
+ ## Policy
175
+
176
+ Declarative deny/inject rules in the **global** agent settings file. No shell commands — pure in-process regex. Evaluated before command hooks. `wishcraft.policyEnabled: false` disables policy without deleting rules.
177
+
178
+ ```json
179
+ {
180
+ "wishcraft": {
181
+ "policy": [
182
+ {
183
+ "action": "deny",
184
+ "tool": "bash",
185
+ "match": "sudo\\s+rm",
186
+ "reason": "destructive sudo rm"
187
+ },
188
+ {
189
+ "action": "inject",
190
+ "tool": "read",
191
+ "pathMatch": "\\.env",
192
+ "context": "Do not leak secrets from .env files into the conversation."
193
+ }
194
+ ]
195
+ }
196
+ }
197
+ ```
198
+
199
+ **deny** — regex on tool input (`bash` uses `command`; other tools use JSON-serialized input). First match wins; the tool call is blocked with `reason`.
200
+
201
+ **inject** — regex on file path after a matching tool completes; context is appended to the tool result (same shape as postToolUse hook `additionalContext`).
202
+
174
203
  ## Limits
175
204
 
176
205
  - No mouse on the live footer. Pi core owns that surface.
@@ -218,6 +218,8 @@ Set `powerline.costAlert` to a USD threshold to get a single warning notificatio
218
218
 
219
219
  Command hooks live under `wishcraft.hooks` in the **global** agent settings file. `wishcraft.hooksEnabled: false` is the kill-switch. See the README Hooks section for three copy-paste examples (bash-guard, write-audit, SessionStart git-status).
220
220
 
221
+ Declarative policy rules (`wishcraft.policy`) live in the same global file. They run in-process before command hooks: **deny** blocks a tool call when input matches a regex; **inject** appends context after a matching read/write path. `wishcraft.policyEnabled: false` disables policy without deleting rules. See the README Policy section for two copy-paste examples.
222
+
221
223
  Tool-input repairs apply to custom/extension tools only (`wishcraft.repairsEnabled`, default on). `/repairs` prints the counters.
222
224
 
223
225
  ## Token budget
package/docs/index.md CHANGED
@@ -8,7 +8,7 @@ The README is the public landing page (`banner.png` only). Everything below live
8
8
  - [Configuration](./configuration.md) — custom items, hooks, repairs, token budget, labels, templates, layout, cost alert, and display formats.
9
9
  - [Bash mode](./bash-mode.md) — sticky shell, ghost suggestions, and shell config.
10
10
  - [Stash & shortcuts](./stash-and-shortcuts.md) — editor stash, prompt history, clipboard/navigation shortcuts, and shortcut config.
11
- - [Skill manager](./skill-manager.md) — browsing and inserting installed skills.
11
+ - [Skill manager](./skill-manager.md) — browsing and inserting installed skills, `/skills doctor` health table.
12
12
  - [Working vibes](./working-vibes.md) — themed loading messages, modes, and configuration.
13
13
  - [Segments & theming](./segments.md) — segment reference, separators, thinking/path/git options, and theme overrides.
14
14
 
@@ -3,5 +3,6 @@
3
3
  Browse and insert your installed skills (`SKILL.md` files and `*.md`/`*.txt` prompts) from an interactive TUI overlay:
4
4
 
5
5
  - **`/skills`** — open the skill manager. Filter with plain typing, `↑↓` to move, `enter` to open a skill's detail body, `↑↓` in the detail to scroll, `enter`/`tab` to insert the skill content into your prompt, `esc` to go back/close.
6
+ - **`/skills doctor`** — health table (not an essay): broken or missing frontmatter, descriptions over 240 characters, the same name in global and project, unused skills (usage ledger count 0). `↑↓` navigate, `enter` copies a row, `esc` closes.
6
7
 
7
8
  The manager reuses the same skill discovery as inline `/command`/`$skill` triggers, so anything you can inline you can also browse and insert manually.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "0.23.4",
3
+ "version": "0.25.0",
4
4
  "description": "Wishcraft cockpit for the pi coding agent — powerline status, vibes, idea inbox, bash mode, and full customization.",
5
5
  "type": "module",
6
6
  "files": [
@@ -27,6 +27,14 @@ import {
27
27
  runHookCommand,
28
28
  type HookPayload,
29
29
  } from "./hooks-runner.ts";
30
+ import {
31
+ parsePolicySettings,
32
+ type PolicyRule,
33
+ } from "./policy-config.ts";
34
+ import {
35
+ evalPostToolUsePolicy,
36
+ evalPreToolUsePolicy,
37
+ } from "./policy-engine.ts";
30
38
  import {
31
39
  recordRepairs,
32
40
  repairToolInput,
@@ -34,6 +42,8 @@ import {
34
42
 
35
43
  let hooksSettings: WishcraftHooksSettings = {};
36
44
  let hooksEnabled = false;
45
+ let policyRules: PolicyRule[] = [];
46
+ let policyEnabled = false;
37
47
  let repairsEnabled = true;
38
48
  let pendingSessionContext: string | null = null;
39
49
 
@@ -46,6 +56,9 @@ function refreshSettings(cwd: string): void {
46
56
  const parsed = parseHooksSettings(globalWishcraft);
47
57
  hooksSettings = parsed.hooks;
48
58
  hooksEnabled = parsed.enabled && hasAnyHook(parsed.hooks);
59
+ const policy = parsePolicySettings(globalWishcraft);
60
+ policyRules = policy.rules;
61
+ policyEnabled = policy.enabled;
49
62
  repairsEnabled =
50
63
  !merged ||
51
64
  typeof merged !== "object" ||
@@ -125,6 +138,12 @@ export function setupHooks(
125
138
  const result = repairToolInput(toolName, event.input);
126
139
  if (result.repairs.length > 0) recordRepairs(result);
127
140
  }
141
+ if (policyEnabled) {
142
+ const policyVerdict = evalPreToolUsePolicy(policyRules, toolName, event.input);
143
+ if (policyVerdict.block) {
144
+ return { block: true, reason: policyVerdict.reason };
145
+ }
146
+ }
128
147
  if (!hooksEnabled) return;
129
148
  const cmds = commandsFor(hooksSettings, "preToolUse", toolName);
130
149
  if (cmds.length === 0) return;
@@ -147,27 +166,33 @@ export function setupHooks(
147
166
  });
148
167
 
149
168
  pi.on("tool_result", async (event: any, ctx: any) => {
150
- if (!hooksEnabled) return;
151
- const cmds = commandsFor(hooksSettings, "postToolUse", event.toolName);
152
- if (cmds.length === 0) return;
153
- const payload = basePayload("postToolUse", ctx);
154
- payload.tool_use_id = event.toolCallId;
155
- payload.tool_name = event.toolName;
156
- payload.tool_input = event.input;
157
- payload.tool_response =
158
- typeof event.content === "string"
159
- ? event.content
160
- : Array.isArray(event.content)
161
- ? event.content.map((c: any) => c.text ?? "").join("\n")
162
- : "";
163
- // parallel: één crashende hook annuleert de rest niet
164
- const outs = await Promise.all(cmds.map((c) => runHookCommand(c, payload)));
165
169
  let extra = "";
166
- for (const out of outs) {
167
- const add = out.parsed?.hookSpecificOutput?.additionalContext;
168
- if (add) extra += (extra ? "\n" : "") + add;
169
- if (out.parsed?.systemMessage && ctx?.ui?.notify) {
170
- ctx.ui.notify(out.parsed.systemMessage, "info");
170
+ if (policyEnabled) {
171
+ const inject = evalPostToolUsePolicy(policyRules, event.toolName, event.input);
172
+ if (inject) extra = inject.additionalContext;
173
+ }
174
+ if (hooksEnabled) {
175
+ const cmds = commandsFor(hooksSettings, "postToolUse", event.toolName);
176
+ if (cmds.length > 0) {
177
+ const payload = basePayload("postToolUse", ctx);
178
+ payload.tool_use_id = event.toolCallId;
179
+ payload.tool_name = event.toolName;
180
+ payload.tool_input = event.input;
181
+ payload.tool_response =
182
+ typeof event.content === "string"
183
+ ? event.content
184
+ : Array.isArray(event.content)
185
+ ? event.content.map((c: any) => c.text ?? "").join("\n")
186
+ : "";
187
+ // parallel: één crashende hook annuleert de rest niet
188
+ const outs = await Promise.all(cmds.map((c) => runHookCommand(c, payload)));
189
+ for (const out of outs) {
190
+ const add = out.parsed?.hookSpecificOutput?.additionalContext;
191
+ if (add) extra += (extra ? "\n" : "") + add;
192
+ if (out.parsed?.systemMessage && ctx?.ui?.notify) {
193
+ ctx.ui.notify(out.parsed.systemMessage, "info");
194
+ }
195
+ }
171
196
  }
172
197
  }
173
198
  if (extra && Array.isArray(event.content)) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * policy-config.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Declarative policy rules (deny / inject) from global wishcraft settings.
5
+ * No process spawn — pure regex evaluation in-process.
6
+ *
7
+ * "wishcraft": {
8
+ * "policyEnabled": true,
9
+ * "policy": [
10
+ * { "action": "deny", "tool": "bash", "match": "sudo\\s+rm", "reason": "…" },
11
+ * { "action": "inject", "tool": "read", "pathMatch": "\\.env", "context": "…" }
12
+ * ]
13
+ * }
14
+ *
15
+ * policyEnabled defaults to true when policy is non-empty; explicit false
16
+ * is the kill-switch. Malformed rules are dropped (no throw).
17
+ * ---------------------------------------------------------------------------
18
+ */
19
+
20
+ export interface DenyPolicyRule {
21
+ action: "deny";
22
+ tool: string;
23
+ match: string;
24
+ reason: string;
25
+ }
26
+
27
+ export interface InjectPolicyRule {
28
+ action: "inject";
29
+ tool: string;
30
+ pathMatch: string;
31
+ context: string;
32
+ }
33
+
34
+ export type PolicyRule = DenyPolicyRule | InjectPolicyRule;
35
+
36
+ function isRecord(v: unknown): v is Record<string, unknown> {
37
+ return typeof v === "object" && v !== null && !Array.isArray(v);
38
+ }
39
+
40
+ function validRegex(pattern: string): boolean {
41
+ try {
42
+ new RegExp(pattern);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function parseDenyRule(v: unknown): DenyPolicyRule | null {
50
+ if (!isRecord(v) || v.action !== "deny") return null;
51
+ if (typeof v.tool !== "string" || !v.tool.trim()) return null;
52
+ if (typeof v.match !== "string" || !v.match.trim()) return null;
53
+ if (typeof v.reason !== "string" || !v.reason.trim()) return null;
54
+ if (!validRegex(v.match)) return null;
55
+ return {
56
+ action: "deny",
57
+ tool: v.tool,
58
+ match: v.match,
59
+ reason: v.reason,
60
+ };
61
+ }
62
+
63
+ function parseInjectRule(v: unknown): InjectPolicyRule | null {
64
+ if (!isRecord(v) || v.action !== "inject") return null;
65
+ if (typeof v.tool !== "string" || !v.tool.trim()) return null;
66
+ if (typeof v.pathMatch !== "string" || !v.pathMatch.trim()) return null;
67
+ if (typeof v.context !== "string" || !v.context.trim()) return null;
68
+ if (!validRegex(v.pathMatch)) return null;
69
+ return {
70
+ action: "inject",
71
+ tool: v.tool,
72
+ pathMatch: v.pathMatch,
73
+ context: v.context,
74
+ };
75
+ }
76
+
77
+ function parsePolicyRule(v: unknown): PolicyRule | null {
78
+ if (!isRecord(v)) return null;
79
+ if (v.action === "deny") return parseDenyRule(v);
80
+ if (v.action === "inject") return parseInjectRule(v);
81
+ return null;
82
+ }
83
+
84
+ /** Parse wishcraft policy settings. Invalid rules are dropped. */
85
+ export function parsePolicySettings(wishcraftSettings: unknown): {
86
+ enabled: boolean;
87
+ rules: PolicyRule[];
88
+ } {
89
+ if (!isRecord(wishcraftSettings)) return { enabled: false, rules: [] };
90
+ const raw = wishcraftSettings.policy;
91
+ if (!Array.isArray(raw)) return { enabled: false, rules: [] };
92
+ const rules = raw
93
+ .map(parsePolicyRule)
94
+ .filter((r): r is PolicyRule => r !== null);
95
+ if (rules.length === 0) return { enabled: false, rules: [] };
96
+ const enabled = wishcraftSettings.policyEnabled !== false;
97
+ return { enabled, rules };
98
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * policy-engine.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Pure in-process policy evaluation (deny before tool use, inject after).
5
+ * ---------------------------------------------------------------------------
6
+ */
7
+
8
+ import type { PolicyRule } from "./policy-config.ts";
9
+
10
+ function isRecord(v: unknown): v is Record<string, unknown> {
11
+ return typeof v === "object" && v !== null && !Array.isArray(v);
12
+ }
13
+
14
+ /** Text to match deny rules against (bash command or serialized input). */
15
+ export function toolInputText(toolName: string, input: unknown): string {
16
+ if (toolName === "bash" && isRecord(input) && typeof input.command === "string") {
17
+ return input.command;
18
+ }
19
+ try {
20
+ return JSON.stringify(input ?? {});
21
+ } catch {
22
+ return String(input ?? "");
23
+ }
24
+ }
25
+
26
+ /** Path from tool input (read/write/edit and common aliases). */
27
+ export function toolPath(input: unknown): string | null {
28
+ if (!isRecord(input)) return null;
29
+ for (const key of ["path", "filePath", "absolutePath", "target_file", "file_path"]) {
30
+ const v = input[key];
31
+ if (typeof v === "string" && v.length > 0) return v;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export type PreToolUsePolicyVerdict =
37
+ | { block: true; reason: string }
38
+ | { block: false };
39
+
40
+ /** First matching deny rule wins. */
41
+ export function evalPreToolUsePolicy(
42
+ rules: PolicyRule[],
43
+ toolName: string,
44
+ input: unknown,
45
+ ): PreToolUsePolicyVerdict {
46
+ const text = toolInputText(toolName, input);
47
+ for (const rule of rules) {
48
+ if (rule.action !== "deny" || rule.tool !== toolName) continue;
49
+ try {
50
+ if (new RegExp(rule.match).test(text)) {
51
+ return { block: true, reason: rule.reason };
52
+ }
53
+ } catch {
54
+ // invalid regex at runtime — skip
55
+ }
56
+ }
57
+ return { block: false };
58
+ }
59
+
60
+ /** All matching inject rules contribute context (in order). */
61
+ export function evalPostToolUsePolicy(
62
+ rules: PolicyRule[],
63
+ toolName: string,
64
+ input: unknown,
65
+ ): { additionalContext: string } | null {
66
+ const path = toolPath(input);
67
+ if (path === null) return null;
68
+ let extra = "";
69
+ for (const rule of rules) {
70
+ if (rule.action !== "inject" || rule.tool !== toolName) continue;
71
+ try {
72
+ if (new RegExp(rule.pathMatch).test(path)) {
73
+ extra += (extra ? "\n" : "") + rule.context;
74
+ }
75
+ } catch {
76
+ // invalid regex at runtime — skip
77
+ }
78
+ }
79
+ return extra ? { additionalContext: extra } : null;
80
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Skill health table for `/skills doctor`.
3
+ * Rows only — no essay. Overlay chrome matches `/powerline doctor`.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { basename } from "node:path";
8
+ import { copyToClipboard } from "@earendil-works/pi-coding-agent";
9
+ import type { SelectItem } from "@earendil-works/pi-tui";
10
+
11
+ import { showSelectOverlay } from "../ui/overlay-chrome.ts";
12
+ import {
13
+ getSkillUsage,
14
+ invalidateSkillCache,
15
+ loadSkillCatalog,
16
+ type SkillEntry,
17
+ type SkillUsage,
18
+ } from "./skill-registry.ts";
19
+
20
+ /** Prompt-budget cap for skill descriptions (stricter than core's 1024). */
21
+ export const SKILL_DESCRIPTION_MAX_CHARS = 240;
22
+
23
+ export type SkillDoctorStatus = "ok" | "warn" | "fail";
24
+
25
+ export type SkillDoctorIssue =
26
+ | "unclosed-frontmatter"
27
+ | "missing-frontmatter"
28
+ | "missing-description"
29
+ | "description-budget"
30
+ | "duplicate-global-project"
31
+ | "unused"
32
+ | "warning"
33
+ | "none";
34
+
35
+ export interface SkillDoctorRow {
36
+ status: SkillDoctorStatus;
37
+ skill: string;
38
+ issue: SkillDoctorIssue;
39
+ detail: string;
40
+ }
41
+
42
+ const ISSUE_LABEL: Record<SkillDoctorIssue, string> = {
43
+ "unclosed-frontmatter": "unclosed frontmatter",
44
+ "missing-frontmatter": "missing frontmatter",
45
+ "missing-description": "missing description",
46
+ "description-budget": "description over budget",
47
+ "duplicate-global-project": "duplicate global/project",
48
+ unused: "unused",
49
+ warning: "warning",
50
+ none: "no issues",
51
+ };
52
+
53
+ export function hasUnclosedFrontmatter(content: string): boolean {
54
+ const lines = content.split("\n");
55
+ if (lines[0]?.trim() !== "---") return false;
56
+ for (let i = 1; i < lines.length; i++) {
57
+ if (lines[i]?.trim() === "---") return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ export function hasClosedFrontmatter(content: string): boolean {
63
+ const lines = content.split("\n");
64
+ if (lines[0]?.trim() !== "---") return false;
65
+ for (let i = 1; i < lines.length; i++) {
66
+ if (lines[i]?.trim() === "---") return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function isLoosePromptOrExtraFile(entry: SkillEntry): boolean {
72
+ return (
73
+ (entry.category === "prompts" || entry.category === "extra") &&
74
+ basename(entry.filePath) !== "SKILL.md" &&
75
+ !entry.isDirectorySkill
76
+ );
77
+ }
78
+
79
+ function pushRow(
80
+ rows: SkillDoctorRow[],
81
+ skill: string,
82
+ status: SkillDoctorStatus,
83
+ issue: SkillDoctorIssue,
84
+ detail: string,
85
+ ): void {
86
+ rows.push({ status, skill, issue, detail });
87
+ }
88
+
89
+ /**
90
+ * Pure table builder. `contents` is optional file text keyed by `filePath`
91
+ * so unclosed-frontmatter can be detected without the filesystem.
92
+ */
93
+ export function diagnoseSkills(
94
+ entries: readonly SkillEntry[],
95
+ usage: ReadonlyMap<string, SkillUsage>,
96
+ contents: ReadonlyMap<string, string> = new Map(),
97
+ ): SkillDoctorRow[] {
98
+ const rows: SkillDoctorRow[] = [];
99
+
100
+ const byName = new Map<string, SkillEntry[]>();
101
+ for (const entry of entries) {
102
+ const key = entry.name.toLowerCase();
103
+ const list = byName.get(key) ?? [];
104
+ list.push(entry);
105
+ byName.set(key, list);
106
+ }
107
+
108
+ const duplicateNames = new Set<string>();
109
+ for (const [name, group] of byName) {
110
+ const cats = new Set(group.map((e) => e.category));
111
+ if (cats.has("global") && cats.has("project")) duplicateNames.add(name);
112
+ }
113
+
114
+ for (const entry of entries) {
115
+ const content = contents.get(entry.filePath);
116
+ const description = entry.description.trim();
117
+ let hadFail = false;
118
+ if (content !== undefined && hasUnclosedFrontmatter(content)) {
119
+ hadFail = true;
120
+ pushRow(
121
+ rows,
122
+ entry.name,
123
+ "fail",
124
+ "unclosed-frontmatter",
125
+ `${entry.category} · ${entry.filePath}`,
126
+ );
127
+ } else if (
128
+ content !== undefined &&
129
+ !hasClosedFrontmatter(content) &&
130
+ !isLoosePromptOrExtraFile(entry)
131
+ ) {
132
+ hadFail = true;
133
+ pushRow(
134
+ rows,
135
+ entry.name,
136
+ "fail",
137
+ "missing-frontmatter",
138
+ `${entry.category} · ${entry.filePath}`,
139
+ );
140
+ }
141
+
142
+ if (!hadFail && !description) {
143
+ hadFail = true;
144
+ pushRow(
145
+ rows,
146
+ entry.name,
147
+ "fail",
148
+ "missing-description",
149
+ `${entry.category} · model will not see this skill`,
150
+ );
151
+ } else if (!hadFail && description.length > SKILL_DESCRIPTION_MAX_CHARS) {
152
+ pushRow(
153
+ rows,
154
+ entry.name,
155
+ "warn",
156
+ "description-budget",
157
+ `${description.length}/${SKILL_DESCRIPTION_MAX_CHARS} chars`,
158
+ );
159
+ }
160
+
161
+ if (duplicateNames.has(entry.name.toLowerCase())) {
162
+ pushRow(
163
+ rows,
164
+ entry.name,
165
+ "warn",
166
+ "duplicate-global-project",
167
+ "same name in global and project",
168
+ );
169
+ }
170
+
171
+ const count = usage.get(entry.name)?.count ?? 0;
172
+ if (count === 0) {
173
+ pushRow(rows, entry.name, "warn", "unused", "usage ledger count 0");
174
+ }
175
+
176
+ if (entry.warning && !hadFail) {
177
+ pushRow(rows, entry.name, "warn", "warning", entry.warning);
178
+ }
179
+ }
180
+
181
+ const rank: Record<SkillDoctorStatus, number> = { fail: 0, warn: 1, ok: 2 };
182
+ rows.sort((a, b) => {
183
+ const s = rank[a.status] - rank[b.status];
184
+ if (s !== 0) return s;
185
+ const n = a.skill.localeCompare(b.skill);
186
+ if (n !== 0) return n;
187
+ return a.issue.localeCompare(b.issue);
188
+ });
189
+
190
+ if (rows.length === 0) {
191
+ return [
192
+ {
193
+ status: "ok",
194
+ skill: "catalog",
195
+ issue: "none",
196
+ detail: "no issues",
197
+ },
198
+ ];
199
+ }
200
+ return rows;
201
+ }
202
+
203
+ export function formatSkillDoctorRow(row: SkillDoctorRow): string {
204
+ const tag =
205
+ row.status === "ok" ? "[ok] " : row.status === "warn" ? "[warn]" : "[fail]";
206
+ if (row.issue === "none") {
207
+ return `${tag} catalog · no issues`;
208
+ }
209
+ return `${tag} ${row.skill} · ${ISSUE_LABEL[row.issue]}`;
210
+ }
211
+
212
+ export function skillDoctorRowsToSelectItems(
213
+ rows: readonly SkillDoctorRow[],
214
+ ): SelectItem[] {
215
+ return rows.map((row) => ({
216
+ label: formatSkillDoctorRow(row),
217
+ value: `${row.skill}: ${row.detail}`,
218
+ description: row.detail,
219
+ }));
220
+ }
221
+
222
+ /** Build doctor rows and file contents for a cwd (testable without overlay). */
223
+ export function collectSkillDoctorInputs(cwd: string = process.cwd()): {
224
+ entries: SkillEntry[];
225
+ usage: Map<string, SkillUsage>;
226
+ contents: Map<string, string>;
227
+ } {
228
+ invalidateSkillCache();
229
+ const entries = loadSkillCatalog(cwd);
230
+ const usage = getSkillUsage();
231
+ const contents = new Map<string, string>();
232
+ for (const entry of entries) {
233
+ try {
234
+ contents.set(entry.filePath, readFileSync(entry.filePath, "utf8"));
235
+ } catch {
236
+ // unreadables already surface as registry warnings
237
+ }
238
+ }
239
+ return { entries, usage, contents };
240
+ }
241
+
242
+ /** Overlay table. Enter copies the selected line. */
243
+ export async function runSkillDoctor(ctx: any): Promise<void> {
244
+ const cwd = ctx.cwd ?? process.cwd();
245
+ const { entries, usage, contents } = collectSkillDoctorInputs(cwd);
246
+ const items = skillDoctorRowsToSelectItems(
247
+ diagnoseSkills(entries, usage, contents),
248
+ );
249
+ const picked = await showSelectOverlay(
250
+ ctx,
251
+ "Skills doctor",
252
+ "↑↓ navigate · enter copy · esc close",
253
+ items,
254
+ Math.min(Math.max(items.length, 1), 20),
255
+ );
256
+ if (!picked) return;
257
+ try {
258
+ await copyToClipboard(picked.value);
259
+ ctx.ui.notify("Skill doctor row copied to clipboard", "info");
260
+ } catch {
261
+ ctx.ui.notify("Could not copy skill doctor row to clipboard", "warning");
262
+ }
263
+ }
@@ -28,6 +28,7 @@ import {
28
28
  type SkillCategory,
29
29
  type SkillEntry,
30
30
  } from "./skill-registry.ts";
31
+ import { runSkillDoctor } from "./skill-doctor.ts";
31
32
 
32
33
  const CATEGORY_LABELS: Record<SkillCategory | "all", string> = {
33
34
  all: "alles",
@@ -470,17 +471,28 @@ export async function showSkillManager(ctx: any): Promise<void> {
470
471
  }
471
472
 
472
473
  /** Registreer de `/skills` command. */
474
+ export type SkillManagerCommandDeps = {
475
+ runDoctor?: (ctx: any) => Promise<void>;
476
+ };
477
+
473
478
  export function registerSkillManagerCommand(
474
479
  pi: ExtensionAPI,
475
480
  rt: RuntimeState,
481
+ deps: SkillManagerCommandDeps = {},
476
482
  ): void {
483
+ const runDoctor = deps.runDoctor ?? runSkillDoctor;
477
484
  pi.registerCommand("skills", {
478
- description: "Browse installed skills and insert one into your prompt",
479
- handler: async (_args: string, ctx: any) => {
485
+ description: "Browse installed skills, or `doctor` for a health table",
486
+ handler: async (args: string, ctx: any) => {
480
487
  if (!rt.enabled || !ctx.hasUI) {
481
488
  ctx.ui.notify("Powerline UI is disabled", "info");
482
489
  return;
483
490
  }
491
+ const sub = args?.trim().split(/\s+/)[0]?.toLowerCase();
492
+ if (sub === "doctor") {
493
+ await runDoctor(ctx);
494
+ return;
495
+ }
484
496
  await showSkillManager(ctx);
485
497
  },
486
498
  });
@@ -11,9 +11,9 @@
11
11
 
12
12
  import { loadSkills } from "@earendil-works/pi-coding-agent";
13
13
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
14
- import { dirname, join, relative } from "node:path";
14
+ import { basename, dirname, join, relative } from "node:path";
15
15
  import { getAgentDir, getAgentPath } from "../../paths/agent-dirs.ts";
16
- import { stripFrontmatter } from "../../core/frontmatter.ts";
16
+ import { parseSkillFrontmatter, stripFrontmatter } from "../../core/frontmatter.ts";
17
17
 
18
18
  /** Categorie: waar de skill vandaan komt. */
19
19
  export type SkillCategory = "global" | "project" | "prompts" | "extra";
@@ -158,6 +158,92 @@ function parseFrontmatterKeys(content: string): string[] {
158
158
  return keys;
159
159
  }
160
160
 
161
+ /** Walk canonical skill trees the same way pi core discovers nested SKILL.md. */
162
+ function walkCanonicalSkillMdFiles(dir: string, visit: (filePath: string) => void): void {
163
+ if (!existsSync(dir)) return;
164
+ let entries;
165
+ try {
166
+ entries = readdirSync(dir, { withFileTypes: true });
167
+ } catch {
168
+ return;
169
+ }
170
+ const skillMd = entries.find((entry) => entry.isFile() && entry.name === "SKILL.md");
171
+ if (skillMd) {
172
+ visit(join(dir, skillMd.name));
173
+ return;
174
+ }
175
+ for (const entry of entries) {
176
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") {
177
+ continue;
178
+ }
179
+ walkCanonicalSkillMdFiles(join(dir, entry.name), visit);
180
+ }
181
+ }
182
+
183
+ /** Core rejects these paths (skill: null) but still emits diagnostics — surface them for doctor/manager. */
184
+ function buildRejectedSkillEntries(
185
+ diagnostics: { message: string; path?: string }[],
186
+ knownPaths: Set<string>,
187
+ cwd: string,
188
+ extras: { path: string; category: SkillCategory }[],
189
+ ): SkillEntry[] {
190
+ const byPath = new Map<string, string>();
191
+ for (const d of diagnostics) {
192
+ if (!d.path || knownPaths.has(d.path)) continue;
193
+ if (!byPath.has(d.path)) byPath.set(d.path, d.message);
194
+ }
195
+
196
+ const agent = getAgentDir();
197
+ for (const root of [join(agent, "skills"), join(cwd, ".pi", "skills"), join(cwd, "skills")]) {
198
+ walkCanonicalSkillMdFiles(root, (filePath) => {
199
+ if (!knownPaths.has(filePath) && !byPath.has(filePath)) {
200
+ byPath.set(filePath, "skill file not loaded by catalog");
201
+ }
202
+ });
203
+ }
204
+
205
+ const out: SkillEntry[] = [];
206
+ for (const [filePath, message] of byPath) {
207
+ let content = "";
208
+ let sizeBytes = 0;
209
+ let lineCount = 0;
210
+ let mtimeMs = 0;
211
+ try {
212
+ content = readFileSync(filePath, "utf8");
213
+ sizeBytes = Buffer.byteLength(content, "utf8");
214
+ lineCount = content.split("\n").length;
215
+ mtimeMs = statSync(filePath).mtimeMs;
216
+ } catch {
217
+ // include unreadable paths so doctor can still report them
218
+ }
219
+
220
+ const fm = parseSkillFrontmatter(content);
221
+ const base = basename(filePath);
222
+ const name =
223
+ fm.name ??
224
+ (base === "SKILL.md"
225
+ ? basename(dirname(filePath))
226
+ : base.replace(/\.(md|txt)$/, ""));
227
+
228
+ out.push({
229
+ name,
230
+ description: fm.description ?? "",
231
+ filePath,
232
+ baseDir: dirname(filePath),
233
+ isDirectorySkill: base === "SKILL.md",
234
+ category: categorize(filePath, cwd, extras),
235
+ disableModelInvocation: false,
236
+ sizeBytes,
237
+ lineCount,
238
+ mtimeMs,
239
+ frontmatterKeys: parseFrontmatterKeys(content),
240
+ warning: message,
241
+ });
242
+ knownPaths.add(filePath);
243
+ }
244
+ return out;
245
+ }
246
+
161
247
  /** Bouw de volledige skill-catalogus (gecached, TTL 30s). */
162
248
  export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
163
249
  const now = Date.now();
@@ -215,10 +301,23 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
215
301
 
216
302
  // loose entries achteraan; bij naam-collisie wint core
217
303
  const looseNames = new Set(loose.map((e) => e.name));
304
+ const catalogPaths = new Set([
305
+ ...entries.map((e) => e.filePath),
306
+ ...loose.map((e) => e.filePath),
307
+ ]);
308
+ const rejected = buildRejectedSkillEntries(
309
+ result.diagnostics,
310
+ catalogPaths,
311
+ cwd,
312
+ extras,
313
+ );
218
314
  cachedAt = now;
219
- cachedCwd = cwd;
220
- cachedEntries = [...entries.filter((e) => !looseNames.has(e.name)), ...loose]
221
- .sort((a, b) => a.name.localeCompare(b.name));
315
+ cachedCwd = cwd;
316
+ cachedEntries = [
317
+ ...entries.filter((e) => !looseNames.has(e.name)),
318
+ ...loose,
319
+ ...rejected,
320
+ ].sort((a, b) => a.name.localeCompare(b.name));
222
321
  cachedPathMap = new Map(cachedEntries.map((e) => [e.name, e.filePath] as const));
223
322
  return cachedEntries;
224
323
  }