@groeponline/pi-wishcraft 1.11.0 → 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/AGENTS.md CHANGED
@@ -115,9 +115,11 @@ scripts/github-release.sh
115
115
 
116
116
  CI workflows (`.github/workflows/`):
117
117
  - `test.yml` — Node 24, typecheck, test, circular, verify:package, npm audit
118
- - `release.yml` — reuses test.yml, prepares release-candidate branch
118
+ - `release.yml` — reuses test.yml, prepares release-candidate branch, publishes on tag, attaches `npm pack` + `SHA256SUMS`
119
119
  - `promote-release-candidate.yml` — promotes verified SHA to main, tags, dispatches publish
120
120
 
121
+ Tag `vX.Y.Z` must equal `package.json` and sit on `origin/main`. Identity: `src/product-identity.ts --version` / `/signal version`. See [`docs/release.md`](docs/release.md).
122
+
121
123
  Release behavior:
122
124
  - **Every main merge with `[Unreleased]` CHANGELOG notes cuts a release automatically** (verify → candidate → promote → tag → npm). Notes-less merges skip candidate creation entirely — no more empty versions.
123
125
  - Notes live under `## [Unreleased]` in CHANGELOG.md; the release commit rolls them into the new version heading.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.13.0] - 2026-09-25
6
+
7
+ ### Added
8
+ - `budget` segment (daily token budget): renders `budget NN%` with a fill bar when `wishcraft.tokenBudget.daily.limit` is set; wired into the `chef`, `full`, and `nerd` presets.
9
+ - Resting animation has a real clock: while idle at full motion, the signal rail leases the scheduler's `ambient` channel and breathes from that tick. `reduced`/`off` motion still renders a stable dim marker with no scheduler consumer.
10
+
11
+ ## [1.12.0] - 2026-09-25
12
+
13
+ ### Added
14
+ - Recovered the unmatched vNext gallery motions (ember-trail, lantern-glow, fat-band, and the rest that were not already in the catalog) and the extreme-motion rail candidates (braille wave, boids, chevrons, shimmer, seismograph, fat-band). Assign motion `fat-band` to paint the topographic streaming rail; the default sweep stays a single status row.
15
+ - Deck skills: `w` opens the multi-step new-skill wizard (name, description, template, triggers) and writes `SKILL.md`. `n` remains the one-field create.
16
+ - Appearance route search (`/`) filters presets, palettes, motions, and motion levels. Enter persists a structural base, a streaming motion, or a motion level.
17
+
5
18
  ## [1.11.0] - 2026-09-20
6
19
 
7
20
  ### Fixed
@@ -31,18 +44,6 @@
31
44
  - Bash forward-mode privacy notice (issue #71): the first keystroke forwarded to a running command raises an info notice that typed input may echo into the transcript; documented in `docs/bash-mode.md`.
32
45
  - Managed PTY suite is now script-gated (issue #73): PTY-core tests skip when `script(1)` is missing instead of silently passing in degraded pipe mode, the basic run asserts a real PTY transport, and the explicit pipe-mode tests still cover the fallback.
33
46
 
34
- ### Changed
35
- - Signal rail is now a compact single-line state surface. `ready` is stable and costs no animation frames; thinking, streaming, tools, and compaction use a bounded directional trail without expanding the footer to three rows.
36
- - Chef preset now includes session age, generated-token throughput, and cache-read information when present. The port counter names its protocol (`tcp` or `tcp+udp`) instead of displaying an ambiguous bare count.
37
-
38
- ### Fixed
39
- - Bash paste-while-running (issue #72): a bracketed paste performed while a command runs is stripped of its markers and forwarded to the child stdin — including split delivery across input chunks — instead of silently queuing in the editor buffer behind the command.
40
- - PTY/transcript memory bounds (from jan `fix/runtime-pty-bounds`): a single unterminated output line is capped at a 64 KiB UTF-8 tail with a one-time notice, the partial-escape tail is bounded, and the active transcript command trims head lines/bytes within limits instead of growing unbounded.
41
- - Segment-options spread hygiene (CodeFactor #86–89): `normalizeSegmentOptions` assigns fields imperatively instead of spreading conditional empty objects.
42
- - Motion frames that contain multiple terminal columns no longer expand a rail cell and shift the footer layout while they animate.
43
- - Idle rail no longer samples `Date.now()` without a corresponding repaint clock, eliminating stale or apparently random idle frames.
44
- - TPS now reports `--` until a live two-sample rate exists, rather than falsely presenting idle telemetry as `0`; the existing `both`/`out`/`in` setting is validated and honored.
45
-
46
47
  ## [1.4.16] - 2026-09-05
47
48
 
48
49
  ### Fixed
@@ -0,0 +1 @@
1
+ {"source_sha":"394402d958799308f24ffc284a240b878588a931"}
package/docs/commands.md CHANGED
@@ -86,7 +86,7 @@ You can also set it in the agent settings file (`~/.pi/agent/settings.json` by d
86
86
  | `full` | Everything: hostname, model, path (abbreviated), full git incl commit + ↑/↓, totals, context total, elapsed + clock |
87
87
  | `nerd` | Maximum detail for Nerd Font users: qualified model, full tokens + cache, commit, totals, seconds clock |
88
88
  | `ascii` | Safe for any terminal: branch + short commit + ↑/↓, tokens, cost, context% (no Nerd glyphs) |
89
- | `chef` | Fork default: muted colors, slash separators, compact live state rail, session age, TPS in/out, protocol-labelled open ports, generated tokens, cache, subagent cost |
89
+ | `chef` | Fork default: muted colors, slash separators, breathing state rail, session age, TPS in/out, protocol-labelled open ports, generated tokens, cache, budget, subagent cost |
90
90
 
91
91
  **Environment:** `POWERLINE_NERD_FONTS=1` to force Nerd Fonts, `=0` for ASCII.
92
92
 
@@ -99,7 +99,8 @@ Pi core renders the footer as static text, so live click is not possible; action
99
99
  - `/tps`: overlay of the live 1s window (same ring as the segment). `/tps <value>` sets `POWERLINE_TPS`
100
100
  - `/usage`: session / today / week overlay from `~/.pi/agent/wishcraft-usage.json`
101
101
  - `/open-ports`: list listening ports and pick one
102
- - `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status, and queue file health
102
+ - `/powerline doctor`: diagnostics overlay — settings file validity, unknown presets, Nerd Font detection, git polling, bash-mode status, queue file health, and `package.identity`
103
+ - `/powerline version`: `{ version, source_sha }` from `package.json` (SHA only when release-stamped). See [release.md](./release.md).
103
104
  - `/powerline export`: export the current preset + effective layout + labels as a JSON snippet (Enter copies it to the clipboard)
104
105
  - `alt+p`: **Wishcraft Deck** — operator overlay (Home, Signal, Skills, Ideas, Guardrails, Appearance, …). `g` then a jump key (`h` home, `s` signal, `a` appearance). Escape closes. `/signal menu` still opens Navigate / Configure / Status.
105
106
  - `alt+i`: **powerline info**: full open-ports list
package/docs/index.md CHANGED
@@ -11,6 +11,7 @@ This package is a Pi.dev extension, not a standalone website. The README is the
11
11
  - [Skill manager](./skill-manager.md) — browsing, inserting, `/skills doctor`, and `/skills new` templates.
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
+ - [Release route](./release.md) — tag == package version, GitHub Release artifacts, and identity.
14
15
 
15
16
  ## Design System & vNext Specifications
16
17
 
@@ -0,0 +1,16 @@
1
+ # Release route
2
+
3
+ Declared version source: `package.json` `version`. Product tag `vX.Y.Z` must equal that value at the tagged commit, and the commit must be an ancestor of `origin/main`.
4
+
5
+ Existing flow is unchanged: main pushes with `[Unreleased]` notes still prepare a candidate; promotion still tags protected main and dispatches publish. `scripts/npm-publish.sh` remains the npm publish target.
6
+
7
+ This route adds GitHub Release evidence on top of that:
8
+
9
+ 1. `.github/workflows/release.yml` re-checks tag == `package.json` and ancestry on `origin/main`.
10
+ 2. It writes `build-stamp.json` / `PI_WISHCRAFT_BUILD_SHA` from the tag commit only (never a fake such as `0.0.0`).
11
+ 3. It runs `npm pack`, writes `SHA256SUMS`, and attaches those assets to the GitHub Release.
12
+ 4. Visible identity: `node --experimental-strip-types src/product-identity.ts --version` prints `{ version, source_sha }`. `/signal version` and `/signal doctor` (`package.identity`) show the same values. Missing stamp/env is `null`, never invented.
13
+
14
+ `workflow_dispatch` input `tag` backfills the same checks at an existing tag (for example `v1.10.0`) and uploads artifacts. It does not publish to npm. Do not run the backfill from a contract PR.
15
+
16
+ This document does not create a tag or GitHub Release.
package/docs/segments.md CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  - `tps`: live tokens/sec over a rolling 1-second window (EMA-free, no spikes), reporting **output and input** rates separately (`⇡out ⇣in`) so you can see generation speed and incoming prompt tokens at a glance. It shows `--` until two live samples exist, rather than pretending an idle session runs at zero. Set `segmentOptions.tps.mode` to `both`, `out`, or `in`; override with `POWERLINE_TPS` when needed.
8
8
  - `open_ports`: count of unique **TCP** listening ports (`ss` → `netstat` → `/proc/net` fallback, dedupes IPv4/IPv6) rendered as `21 tcp`. Set `segmentOptions.openPorts.includeUdp: true` to include noisy UDP (mDNS/DHCP/ephemeral), rendered as `tcp+udp`.
9
+ - `budget`: daily token-budget usage (`budget 62% ▓▓▓▓░░░░`) when `wishcraft.tokenBudget.daily.limit` is configured. Warns through the same threshold colors as the cost segment.
9
10
 
10
11
  ### Open-port process owners
11
12
 
@@ -101,7 +102,7 @@ The `minimal` and `compact` presets keep these off to stay lean; set `showCommit
101
102
 
102
103
  ## Segments
103
104
 
104
- `model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
105
+ `model` · `shell_mode` · `path` · `git` · `subagents` · `queue` · `token_in` · `token_out` · `token_total` · `cost` · `budget` · `context_pct` · `context_total` · `time_spent` · `time` · `session` · `hostname` · `cache_read` · `cache_write` · `thinking` · `tps` · `open_ports` · `extension_statuses`
105
106
 
106
107
  ## Separators
107
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -251,6 +251,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
251
251
  "cost",
252
252
  "context_pct",
253
253
  "context_total",
254
+ "budget",
254
255
  "time_spent",
255
256
  "time",
256
257
  "extension_statuses",
@@ -299,6 +300,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
299
300
  "cost",
300
301
  "context_pct",
301
302
  "context_total",
303
+ "budget",
302
304
  "time_spent",
303
305
  "time",
304
306
  "extension_statuses",
@@ -369,6 +371,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
369
371
  "subagents",
370
372
  "cost",
371
373
  "context_pct",
374
+ "budget",
372
375
  "time",
373
376
  ],
374
377
  separator: "slash",
@@ -213,6 +213,7 @@ export const BUILTIN_STATUS_LINE_SEGMENT_IDS = [
213
213
  "cost",
214
214
  "context_pct",
215
215
  "context_total",
216
+ "budget",
216
217
  "time_spent",
217
218
  "time",
218
219
  "session",
@@ -35,6 +35,7 @@ import { config, normalizePreset } from "../core/state.ts";
35
35
  import type { RuntimeState } from "../core/types.ts";
36
36
  import { getPowerlineArgumentCompletions } from "./powerline-completions.ts";
37
37
  import { settleSignal } from "../../signal/integration.ts";
38
+ import { formatProductIdentity, readProductIdentity } from "../../product-identity.ts";
38
39
 
39
40
  export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
40
41
  registerCdCommand(pi, () => rt.currentCtx?.cwd ?? process.cwd());
@@ -110,6 +111,10 @@ export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
110
111
  await runPowerlineDoctor(rt, ctx);
111
112
  return;
112
113
  }
114
+ if (normalizedArgs === "version") {
115
+ ctx.ui.notify(formatProductIdentity(readProductIdentity()).trimEnd(), "info");
116
+ return;
117
+ }
113
118
  if (normalizedArgs === "export") {
114
119
  await runPowerlineExport(ctx);
115
120
  return;
@@ -11,6 +11,10 @@ export const POWERLINE_SUBCOMMANDS: ReadonlyArray<{
11
11
  value: "doctor",
12
12
  description: "Run environment and settings diagnostics",
13
13
  },
14
+ {
15
+ value: "version",
16
+ description: "Print package version and stamped source SHA",
17
+ },
14
18
  {
15
19
  value: "export",
16
20
  description: "Export the current status-line configuration",
@@ -13,6 +13,7 @@ import { hasNerdFonts } from "../../theme/icons.ts";
13
13
  import { config } from "../core/state.ts";
14
14
  import type { RuntimeState } from "../core/types.ts";
15
15
  import { showSelectOverlay } from "../ui/menu-views.ts";
16
+ import { readProductIdentity } from "../../product-identity.ts";
16
17
 
17
18
  export type DoctorStatus = "ok" | "warn" | "fail";
18
19
 
@@ -51,6 +52,15 @@ export function buildDoctorReport(
51
52
  const cwd = ctx.cwd ?? process.cwd();
52
53
  const checks: DoctorCheck[] = [];
53
54
 
55
+ const identity = readProductIdentity();
56
+ checks.push({
57
+ status: "ok",
58
+ name: "package.identity",
59
+ detail: identity.source_sha
60
+ ? `${identity.version} source_sha=${identity.source_sha}`
61
+ : `${identity.version} source_sha=null`,
62
+ });
63
+
54
64
  // ── Settings ─────────────────────────────────────────────────────────────
55
65
  const globalPath = getSettingsPath();
56
66
  const projectPath = getProjectSettingsPath(cwd);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Map the skill catalog onto workbench rows (preview, health, sparkline).
3
+ * The usage ledger stores a count, not a histogram, so the series is that count.
4
+ */
5
+
6
+ import {
7
+ collectSkillDoctorInputs,
8
+ diagnoseSkills,
9
+ type SkillDoctorRow,
10
+ } from "./skill-doctor.ts";
11
+ import {
12
+ readSkillBody,
13
+ type SkillEntry,
14
+ type SkillUsage,
15
+ } from "./skill-registry.ts";
16
+ import { parseSkillTriggers, type WorkbenchSkill } from "./workbench.ts";
17
+
18
+ function usageSeriesOf(usage: SkillUsage | undefined): number[] {
19
+ if (!usage || usage.count <= 0) return [];
20
+ return [usage.count];
21
+ }
22
+
23
+ export function workbenchSkillFromEntry(
24
+ entry: SkillEntry,
25
+ usage: ReadonlyMap<string, SkillUsage>,
26
+ doctorByName?: ReadonlyMap<string, SkillDoctorRow>,
27
+ body = readSkillBody(entry.filePath),
28
+ ): WorkbenchSkill {
29
+ const used = usage.get(entry.name);
30
+ const row = doctorByName?.get(entry.name);
31
+ return {
32
+ name: entry.name,
33
+ description: entry.description,
34
+ category: entry.category,
35
+ warning: entry.warning,
36
+ usageCount: used?.count ?? 0,
37
+ usageSeries: usageSeriesOf(used),
38
+ health: row?.status ?? (entry.warning ? "warn" : "ok"),
39
+ bodyPreview: body,
40
+ triggers: parseSkillTriggers(entry.name, body),
41
+ filePath: entry.filePath,
42
+ };
43
+ }
44
+
45
+ export function loadWorkbenchSkills(cwd: string = process.cwd()): WorkbenchSkill[] {
46
+ const inputs = collectSkillDoctorInputs(cwd);
47
+ const rank: Record<SkillDoctorRow["status"], number> = { fail: 0, warn: 1, ok: 2 };
48
+ const doctorByName = new Map<string, SkillDoctorRow>();
49
+ for (const row of diagnoseSkills(inputs.entries, inputs.usage, inputs.contents)) {
50
+ const prev = doctorByName.get(row.skill);
51
+ if (!prev || rank[row.status] < rank[prev.status]) doctorByName.set(row.skill, row);
52
+ }
53
+ return inputs.entries.map((entry) =>
54
+ workbenchSkillFromEntry(entry, inputs.usage, doctorByName, inputs.contents.get(entry.filePath)),
55
+ );
56
+ }
57
+
58
+ export function doctorRowsBySkill(rows: readonly SkillDoctorRow[]): Map<string, SkillDoctorRow> {
59
+ return new Map(rows.map((row) => [row.skill, row]));
60
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Skill workbench: split-pane list + metadata + health + sparkline + preview,
3
+ * plus an inline new-skill wizard and workflow visualization.
4
+ * Recovered from the closed vNext operator draft and wired to the Deck.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { truncateToWidth } from "@earendil-works/pi-tui";
11
+ import { getAgentPath } from "../../paths/agent-dirs.ts";
12
+ import { invalidateSkillCache } from "./skill-registry.ts";
13
+ import {
14
+ SKILL_TEMPLATE_IDS,
15
+ SkillNameError,
16
+ isSkillTemplateId,
17
+ renderSkillTemplate,
18
+ sanitizeSkillName,
19
+ type SkillTemplateId,
20
+ } from "./skill-templates.ts";
21
+ import type { SkillDoctorStatus } from "./skill-doctor.ts";
22
+
23
+ export interface WorkbenchSkill {
24
+ name: string;
25
+ description: string;
26
+ category: string;
27
+ warning?: string;
28
+ usageCount: number;
29
+ usageSeries?: number[];
30
+ health?: SkillDoctorStatus;
31
+ bodyPreview?: string;
32
+ triggers?: string[];
33
+ filePath?: string;
34
+ }
35
+
36
+ export const WIZARD_STEPS = [
37
+ "name",
38
+ "description",
39
+ "template",
40
+ "triggers",
41
+ "confirm",
42
+ ] as const;
43
+
44
+ export type WizardStep = (typeof WIZARD_STEPS)[number];
45
+
46
+ export interface SkillWizardState {
47
+ step: WizardStep;
48
+ name: string;
49
+ description: string;
50
+ template: SkillTemplateId;
51
+ triggers: string;
52
+ error: string | null;
53
+ }
54
+
55
+ export type WizardInputResult =
56
+ | { type: "update"; wizard: SkillWizardState }
57
+ | { type: "close" }
58
+ | { type: "save"; wizard: SkillWizardState };
59
+
60
+ export function createSkillWizard(): SkillWizardState {
61
+ return {
62
+ step: "name",
63
+ name: "",
64
+ description: "",
65
+ template: "standard",
66
+ triggers: "",
67
+ error: null,
68
+ };
69
+ }
70
+
71
+ export function wizardStepIndex(step: WizardStep): number {
72
+ return WIZARD_STEPS.indexOf(step);
73
+ }
74
+
75
+ export function applyWizardInput(state: SkillWizardState, data: string): SkillWizardState {
76
+ if (data === "escape" || data === "\x1b") return state;
77
+ if (data === "backspace" || data === "\x7f" || data === "\b") {
78
+ return editWizardField(state, (value) => value.slice(0, -1));
79
+ }
80
+ if (data === "tab" || data === "enter" || data === "\r" || data === "\n") {
81
+ return advanceWizard(state);
82
+ }
83
+ if (data.length === 1 && data >= " " && data <= "~") {
84
+ return editWizardField(state, (value) => value + data);
85
+ }
86
+ return state;
87
+ }
88
+
89
+ export function handleWizardKey(wizard: SkillWizardState, data: string): WizardInputResult {
90
+ if (data === "escape" || data === "\x1b") {
91
+ if (wizard.step === "name") return { type: "close" };
92
+ return { type: "update", wizard: retreatWizard(wizard) };
93
+ }
94
+ if ((data === "[" || data === "]") && wizard.step === "template") {
95
+ return {
96
+ type: "update",
97
+ wizard: cycleWizardTemplate(wizard, data === "]" ? 1 : -1),
98
+ };
99
+ }
100
+ if ((data === "enter" || data === "\r" || data === "\n") && wizardIsComplete(wizard)) {
101
+ return { type: "save", wizard };
102
+ }
103
+ return { type: "update", wizard: applyWizardInput(wizard, data) };
104
+ }
105
+
106
+ function editWizardField(
107
+ state: SkillWizardState,
108
+ edit: (value: string) => string,
109
+ ): SkillWizardState {
110
+ switch (state.step) {
111
+ case "name":
112
+ return { ...state, name: edit(state.name), error: null };
113
+ case "description":
114
+ return { ...state, description: edit(state.description), error: null };
115
+ case "triggers":
116
+ return { ...state, triggers: edit(state.triggers), error: null };
117
+ case "template": {
118
+ const next = edit(state.template).trim();
119
+ return {
120
+ ...state,
121
+ template: isSkillTemplateId(next) ? next : state.template,
122
+ error: null,
123
+ };
124
+ }
125
+ case "confirm":
126
+ return state;
127
+ default: {
128
+ const exhaustive: never = state.step;
129
+ return exhaustive;
130
+ }
131
+ }
132
+ }
133
+
134
+ export function cycleWizardTemplate(state: SkillWizardState, direction = 1): SkillWizardState {
135
+ const index = SKILL_TEMPLATE_IDS.indexOf(state.template);
136
+ const next =
137
+ SKILL_TEMPLATE_IDS[(index + direction + SKILL_TEMPLATE_IDS.length) % SKILL_TEMPLATE_IDS.length]!;
138
+ return { ...state, template: next, error: null };
139
+ }
140
+
141
+ export function advanceWizard(state: SkillWizardState): SkillWizardState {
142
+ if (state.step === "name") {
143
+ try {
144
+ const name = sanitizeSkillName(state.name);
145
+ return { ...state, name, error: null, step: "description" };
146
+ } catch (error) {
147
+ return {
148
+ ...state,
149
+ error: error instanceof Error ? error.message : "Invalid skill name",
150
+ };
151
+ }
152
+ }
153
+ if (state.step === "description") {
154
+ if (!state.description.trim()) {
155
+ return { ...state, error: "Description is required" };
156
+ }
157
+ return { ...state, step: "template", error: null };
158
+ }
159
+ if (state.step === "template") return { ...state, step: "triggers", error: null };
160
+ if (state.step === "triggers") return { ...state, step: "confirm", error: null };
161
+ return state;
162
+ }
163
+
164
+ export function retreatWizard(state: SkillWizardState): SkillWizardState {
165
+ const index = wizardStepIndex(state.step);
166
+ if (index <= 0) return state;
167
+ return { ...state, step: WIZARD_STEPS[index - 1]!, error: null };
168
+ }
169
+
170
+ export function wizardIsComplete(state: SkillWizardState): boolean {
171
+ return state.step === "confirm" && !state.error && Boolean(state.name);
172
+ }
173
+
174
+ export function parseSkillTriggers(name: string, body: string): string[] {
175
+ const listed: string[] = [];
176
+ const heading = body.match(/## Triggers\s*\n+([\s\S]*?)(?=\n## |\s*$)/i);
177
+ if (heading?.[1]) {
178
+ for (const line of heading[1].split("\n")) {
179
+ const item = line.replace(/^\s*[-*]\s+/, "").trim();
180
+ if (item) listed.push(item);
181
+ }
182
+ }
183
+ return listed.length > 0 ? listed : [name, `$${name}`];
184
+ }
185
+
186
+ export function composeWizardSkill(state: SkillWizardState): string {
187
+ const name = sanitizeSkillName(state.name);
188
+ const body = renderSkillTemplate(state.template, name);
189
+ const triggers = state.triggers
190
+ .split(",")
191
+ .map((item) => item.trim())
192
+ .filter(Boolean);
193
+ const triggerBlock =
194
+ triggers.length > 0
195
+ ? `\n## Triggers\n\n${triggers.map((item) => `- ${item}`).join("\n")}\n`
196
+ : "";
197
+ if (!state.description.trim()) return `${body}${triggerBlock}`;
198
+ return (
199
+ body.replace(/description: .*/, `description: ${state.description.trim()}`) + triggerBlock
200
+ );
201
+ }
202
+
203
+ export function writeWizardSkill(
204
+ state: SkillWizardState,
205
+ skillsRoot: string = getAgentPath("skills"),
206
+ ): { filePath: string } {
207
+ const name = sanitizeSkillName(state.name);
208
+ const dir = join(skillsRoot, name);
209
+ const filePath = join(dir, "SKILL.md");
210
+ if (existsSync(filePath)) {
211
+ throw new SkillNameError(`Skill already exists: ${name}`);
212
+ }
213
+ mkdirSync(dir, { recursive: true });
214
+ writeFileSync(filePath, composeWizardSkill({ ...state, name }), "utf8");
215
+ invalidateSkillCache();
216
+ return { filePath };
217
+ }
218
+
219
+ export function renderUsageSparkline(counts: number[], width = 12): string {
220
+ const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
221
+ if (width <= 0) return "";
222
+ if (counts.length === 0) return "·".repeat(width);
223
+ const series =
224
+ counts.length >= width
225
+ ? counts.slice(counts.length - width)
226
+ : [...Array(width - counts.length).fill(0), ...counts];
227
+ const max = Math.max(1, ...series);
228
+ return series
229
+ .map((value) => {
230
+ const index = Math.round((value / max) * (blocks.length - 1));
231
+ return blocks[index] ?? " ";
232
+ })
233
+ .join("");
234
+ }
235
+
236
+ export function renderSkillWorkflow(skill: WorkbenchSkill): string[] {
237
+ const health = skill.health ?? (skill.warning ? "warn" : "ok");
238
+ const triggers = skill.triggers?.length ? skill.triggers.join(", ") : "name / $skill";
239
+ return [
240
+ `discover(${skill.category}) → health(${health}) → usage(${skill.usageCount}x) → insert`,
241
+ `triggers: ${triggers}`,
242
+ ];
243
+ }
244
+
245
+ export function renderSkillWizard(theme: Theme, width: number, state: SkillWizardState): string[] {
246
+ const title = theme.fg(
247
+ "accent",
248
+ `NEW SKILL WIZARD · step ${wizardStepIndex(state.step) + 1}/${WIZARD_STEPS.length}`,
249
+ );
250
+ const lines = [
251
+ title,
252
+ `Name: ${state.name || "…"}`,
253
+ `Description: ${state.description || "…"}`,
254
+ `Template: ${state.template}`,
255
+ `Triggers: ${state.triggers || "(none)"}`,
256
+ "",
257
+ `Editing: ${state.step}`,
258
+ ];
259
+ if (state.error) lines.push(theme.fg("warning", state.error));
260
+ if (state.step === "confirm") {
261
+ lines.push("Enter saves · esc cancels");
262
+ lines.push(...composeWizardSkill(state).split("\n").slice(0, 6));
263
+ } else {
264
+ lines.push("type · enter next · [ ] template · backspace edit");
265
+ }
266
+ return lines.map((line) => truncateToWidth(line, width, "…", true));
267
+ }
268
+
269
+ export function renderSkillWorkbench(
270
+ theme: Theme,
271
+ width: number,
272
+ skills: readonly WorkbenchSkill[],
273
+ selected: number,
274
+ wizard: SkillWizardState | null,
275
+ ): string[] {
276
+ if (wizard) return renderSkillWizard(theme, width, wizard);
277
+ if (skills.length === 0) {
278
+ return [
279
+ theme.fg("warning", "No skills installed"),
280
+ "Press n or ctrl+n to open the new-skill wizard",
281
+ ].map((line) => truncateToWidth(line, width, "…", true));
282
+ }
283
+
284
+ const index = Math.max(0, Math.min(selected, skills.length - 1));
285
+ const skill = skills[index]!;
286
+ const leftW = Math.max(12, Math.floor(width * 0.32));
287
+ const midW = Math.max(16, Math.floor(width * 0.36));
288
+ const rightW = Math.max(12, width - leftW - midW - 2);
289
+ const windowSize = 8;
290
+ const start = Math.max(
291
+ 0,
292
+ Math.min(index - Math.floor(windowSize / 2), Math.max(0, skills.length - windowSize)),
293
+ );
294
+ const visible = skills.slice(start, start + windowSize);
295
+
296
+ const list = [
297
+ theme.fg(
298
+ "accent",
299
+ skills.length > windowSize ? `SKILLS ${index + 1}/${skills.length}` : "SKILLS",
300
+ ),
301
+ ...visible.map((entry, i) => {
302
+ const actual = start + i;
303
+ const marker = actual === index ? "→" : " ";
304
+ const warn = entry.warning ? " !" : "";
305
+ return `${marker} ${entry.name}${warn}`;
306
+ }),
307
+ ];
308
+ const meta = [
309
+ theme.fg("accent", "METADATA"),
310
+ skill.name,
311
+ skill.description || "(no description)",
312
+ `health: ${skill.health ?? (skill.warning ? "warn" : "ok")}`,
313
+ `usage: ${skill.usageCount}x ${renderUsageSparkline(skill.usageSeries ?? [skill.usageCount])}`,
314
+ ...renderSkillWorkflow(skill),
315
+ ];
316
+ const preview = [
317
+ theme.fg("accent", "PREVIEW"),
318
+ ...(skill.bodyPreview || "(empty body)").split("\n").slice(0, 6),
319
+ ];
320
+
321
+ const rows = Math.max(list.length, meta.length, preview.length);
322
+ const lines: string[] = [];
323
+ for (let i = 0; i < rows; i++) {
324
+ const left = padPlain(list[i] ?? "", leftW);
325
+ const mid = padPlain(meta[i] ?? "", midW);
326
+ const right = padPlain(preview[i] ?? "", rightW);
327
+ lines.push(truncateToWidth(`${left} ${mid} ${right}`, width, "…", true));
328
+ }
329
+ lines.push(truncateToWidth("n new skill · ↑↓ select · enter insert", width, "…", true));
330
+ return lines;
331
+ }
332
+
333
+ function padPlain(text: string, width: number): string {
334
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
335
+ if (plain.length >= width) return truncateToWidth(text, width, "…", true);
336
+ return text + " ".repeat(width - plain.length);
337
+ }