@groeponline/pi-wishcraft 1.4.11 → 1.4.12

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
@@ -1,102 +1,314 @@
1
- # AGENTS.md
2
-
3
- Guidance for AI agents (and humans) working on this repository.
4
-
5
- ## What this project is
6
-
7
- pi-wishcraft is a powerline-style status bar and wishcraft interaction layer for the `pi`
8
- coding agent: status segments, welcome overlay/header, working "vibes" loading
9
- messages, a queue + idea inbox, bash mode, prompt/stash history, and full
10
- user customization.
11
-
12
- ## Repository layout
13
-
14
- - `index.ts` — package entry: the public barrel. It re-exports the default
15
- activation function and the documented public API (`resolveShortcutConfig`,
16
- `parseBashModeSettings`, `PowerlineShortcuts`). Keep it a barrel; no
17
- implementation lives here.
18
- - `src/` — the proxy-free extension runtime, organized by domain:
19
- - `src/extension/` the extension runtime, organized into domain subfolders:
20
- `core/` (constants, types, `state.ts` hub, segment-context), `commands/`
21
- (slash commands, queue commands, bash-mode actions, vibe command),
22
- `session/` (activation, session lifecycle, git invalidation, stale-context),
23
- `ui/` (custom editor, layout, menu views, powerline widgets, status-line
24
- renderers), `history/` (prompt + stash history), `queue/` (queue context +
25
- integration), `settings/` (settings IO), `shortcuts/` (shortcut config +
26
- router), `welcome/` (welcome control + integration), `skills/` (inline
27
- invocation). Leaf modules keep the dependency graph acyclic: shared types
28
- and constants live in `core/`, queue/welcome callbacks are wired through
29
- leaves or `RuntimeState`, and `core/state.ts` is the hub that only has
30
- inbound edges. Verify with `npx madge --circular src index.ts bash-mode
31
- queue`.
32
- - `src/config/`powerline config parsing, presets, types, segment ids/options.
33
- - `src/segments/`segment registry and the segment renderers.
34
- - `src/theme/`colors, icons, separators, theme loading.
35
- - `src/usage/` — token stats, context usage, currency rates.
36
- - `src/welcome/`welcome header/overlay rendering and discovery.
37
- - `src/working-vibes/`vibe theme storage, generation, manager.
38
- - `src/git/`, `src/shell/`, `src/editor/`, `src/render/`, `src/paths/`,
39
- `src/shortcuts/`, `src/lifecycle/`small single-purpose domains.
40
- - `bash-mode/`managed shell session, transcript store, completion engine.
41
- - `queue/` — file-backed queue/inbox store and types.
42
- - `tests/` flat `node:test` suite (`tests/*.test.ts`); structural tests read
43
- the module under test from `src/`, never from `index.ts` source text.
44
- - `scripts/release.mjs` zero-dep release helper (bump, CHANGELOG, tag).
45
-
46
- ## Rules
47
-
48
- - **No root-level monofiles.** Implementation lives under `src/` organized by
49
- domain; `index.ts` is only a barrel. Import implementation via
50
- `src/**` paths (or `bash-mode/`, `queue/`).
51
- - **Module size:** when a file exceeds ~450 lines, split it along domain
52
- boundaries (e.g. command handlers out of `commands.ts`, git invalidation
53
- out of `session-lifecycle.ts`). Prefer many cohesive small modules.
54
- - **No circular imports.** Domain modules must not import back into
55
- `src/extension/core/state.ts` for callbacks; move the callback into a leaf module
56
- (e.g. `welcome-control.ts`) or wire it through `RuntimeState`.
57
- - **Bun/Node-native TS:** `import` paths carry `.ts` extensions
58
- (`allowImportingTsExtensions`), `node:`-prefixed builtins, `node --test`
59
- with type stripping. No build step.
60
- - **English UI:** operator overlays, notify strings, and `/wishcraft` copy are
61
- English. Do not add Dutch UI strings.
62
- - **Tests:** behavior changes in `src/` need a focused regression test near
63
- the existing tests for that subsystem; structural tests assert on module
64
- files in `src/` (never `index.ts`).
65
-
66
- ## Commands
1
+ # Repository Guidelines
2
+
3
+ ## Project Overview
4
+
5
+ `pi-wishcraft` is an operator cockpit extension for the **Pi** coding agent (`@earendil-works/pi-coding-agent`). It provides a powerline-style status bar, welcome overlay/header, working "vibes" loading messages, a file-backed idea queue, sticky Bash mode with PTY sessions, inline skill invocation, command-level hooks/policy controls, and a fullscreen Skill Studio. The package ships as a Pi extension rooted at `./index.ts` and is published to npm as `@groeponline/pi-wishcraft`.
6
+
7
+ ## Architecture & Data Flow
8
+
9
+ ### Bootstrap & Runtime Hub
10
+
11
+ The extension bootstraps from `index.ts`, which re-exports `powerlineFooter` from `src/extension/session/activate.ts`. Activation reads Pi settings, parses powerline config, creates a mutable `RuntimeState` (via `createRuntimeState` in `src/extension/core/state.ts`), and registers Pi lifecycle hooks, commands, segments, presets, shortcuts, skills, and bash-mode sessions.
12
+
13
+ `src/extension/core/state.ts` is the **central mutable hub** — it holds `config`, schedulers, and derived caches. All runtime mutations flow through `RuntimeState` and callback hooks (`requestStatusRender`, `requestImmediateStatusRender`, `resetLayoutCache`, `dispatchSignalEvent`). Leaf modules consume and extend this hub but never import sibling leaves directly.
14
+
15
+ ### Rendering Pipeline
16
+
17
+ The render pipeline is a pure, acyclic chain:
18
+
19
+ 1. **SegmentContext** is built read-only by `src/extension/core/segment-context.ts` from `RuntimeState`, config, theme, git state, and usage data.
20
+ 2. **Segment renderers** are dispatched through `src/segments/registry.ts` a fault-isolated `renderSegment` that maps segment IDs to pure render functions.
21
+ 3. **Status line composition** happens in `src/render/v2-entry.ts`, which orders segments, inserts the motion rail from `src/render/motion-rail.ts`, then delegates painting to `src/render/paint.ts` and `src/render/v2-adapter.ts`.
22
+
23
+ ### Motion & Signal
24
+
25
+ The motion engine (`src/motion/*`) defines a catalog of animations (`src/motion/catalog.ts`), a centralized scheduler (`src/motion/scheduler.ts`), and an accessibility policy (`src/motion/policy.ts`). The Signal controller (`src/signal/controller.ts`) leases one motion consumer per runtime event and manages the lifecycle of rendered signals on the status-line rail.
26
+
27
+ ### Leaf Domains
28
+
29
+ Each domain operates through `RuntimeState` callbacks:
30
+
31
+ - **bash-mode/** — PTY-managed shell sessions, transcripts, ghost suggestions, completion engine
32
+ - **queue/**file-backed JSONL queue/inbox store with aliases and retention
33
+ - **working-vibes/**themed loading message generation and persistence
34
+ - **welcome/**fullscreen TUI overlay with branded layout and countdown
35
+ - **usage/** — token/cost/context ledger, TPS ring, daily budget
36
+ - **theme/**color resolution, icons, separators, token mapping
37
+ - **studio/**Skill Studio UI component, Deck renderer, advice engine
38
+ - **skills/** skill registry, manager, inline invocation, doctor, status
39
+ - **hooks/**session/context/tool hooks, policy engine, repairs
40
+ - **settings/**settings IO, config commands, appearance write-back
41
+
42
+ ### Acyclic Dependency Rule
43
+
44
+ Domain modules must not import back into the hub for callbacks. Shared types and constants live in `src/extension/core/`. Verify no circular imports:
45
+
46
+ ```bash
47
+ npm run circular
48
+ # madge --circular src index.ts bash-mode queue
49
+ ```
50
+
51
+ ## Key Directories
52
+
53
+ | Directory | Purpose |
54
+ |---|---|
55
+ | `src/` | Extension runtime, organized by domain (core, config, segments, render, signal, motion, theme, welcome, working-vibes, usage, studio, skills, hooks, settings, shortcuts, history, queue, contrib) |
56
+ | `src/extension/` | Pi extension runtime: `core/` (hub, types, constants), `session/` (activation, lifecycle), `ui/` (deck, layout), `commands/`, `shortcuts/`, `queue/`, `welcome/`, `skills/`, `hooks/`, `settings/`, `history/`, `contrib/` |
57
+ | `src/config/` | Powerline config parsing, presets, settings registry, tokens |
58
+ | `src/segments/` | Segment registry and builtin renderers (core, system, usage, custom) |
59
+ | `src/theme/` | Colors, icons, separators, token mapping |
60
+ | `src/render/` | Paint primitives, v2 adapter, layout, motion-rail |
61
+ | `src/motion/` | Motion catalog, scheduler, policy, types, gallery, composer |
62
+ | `src/signal/` | Signal controller and event dispatching |
63
+ | `src/welcome/` | Welcome overlay, renderer, art, layout |
64
+ | `src/studio/` | Studio UI component, deck, advice engine |
65
+ | `bash-mode/` | Standalone PTY shell session management (editor, transcript, completion, forward mode) |
66
+ | `queue/` | Standalone file-backed queue store and types |
67
+ | `tests/` | Flat `node:test` suite (`tests/*.test.ts`), helpers in `tests/helpers/`, fixtures in `tests/fixtures/` |
68
+ | `docs/` | Operator guides (configuration, commands, bash-mode, segments, skills, etc.) |
69
+ | `docs/design/` | vNext design specs (not operator how-to docs) |
70
+ | `.compound-engineering/` | CE overlay: tracked `config.yaml`, artifacts, plans |
71
+
72
+ ## Development Commands
67
73
 
68
74
  ```bash
75
+ # Install dependencies
69
76
  npm ci
70
- npm run typecheck # tsc --noEmit (strict)
71
- npm test # node --experimental-strip-types --test tests/**/*.test.ts
77
+
78
+ # Type check (strict, no emit)
79
+ npm run typecheck
80
+
81
+ # Run all tests
82
+ npm test
83
+
84
+ # Check for circular imports
85
+ npm run circular
86
+
87
+ # Full package contract verification (for publish preparation)
88
+ npm run verify:package
89
+
90
+ # Local preview server (Deck/Signal/motion surfaces)
91
+ npm run preview
92
+
93
+ # Dockerized parallel testing (typecheck + test + circular)
94
+ scripts/docker-test.sh [-n N]
95
+
96
+ # Cloud agent bootstrap (Node 24, pi CLI install)
97
+ scripts/cloud-agent-install.sh
98
+ scripts/cloud-agent-start.sh
99
+ ```
100
+
101
+ ### Release Flow
102
+
103
+ Releases are CI-driven via GitHub Actions:
104
+
105
+ ```bash
106
+ # Locally: bump version, rewrite CHANGELOG, tag, optional push
107
+ node scripts/release.mjs # or: npm run release
108
+
109
+ # Publish to npm (CI handles auth)
110
+ scripts/npm-publish.sh
111
+
112
+ # Create GitHub Release
113
+ scripts/github-release.sh
114
+ ```
115
+
116
+ CI workflows (`.github/workflows/`):
117
+ - `test.yml` — Node 24, typecheck, test, circular, verify:package, npm audit
118
+ - `release.yml` — reuses test.yml, prepares release-candidate branch
119
+ - `promote-release-candidate.yml` — promotes verified SHA to main, tags, dispatches publish
120
+
121
+ ## Code Conventions & Common Patterns
122
+
123
+ ### Module Structure
124
+
125
+ - **No root-level monofiles**: implementation lives under `src/` by domain; `index.ts` is a barrel only
126
+ - **Module size**: split files exceeding ~450 lines along domain boundaries
127
+ - **TypeScript**: `.ts` extensions on all imports (`allowImportingTsExtensions`), `node:`-prefixed builtins, strict mode, no build step
128
+ - **Entry point**: `index.ts` re-exports `powerlineFooter` and shortcut helpers — keep it a barrel, no implementation
129
+
130
+ ### Import Paths & Dependency Direction
131
+
132
+ ```typescript
133
+ // Correct: import via domain paths
134
+ import { SEGMENTS } from "src/segments/registry.ts";
135
+ import { createRuntimeState } from "src/extension/core/state.ts";
136
+
137
+ // Incorrect: avoid importing implementation from index.ts barrel
138
+ import { powerlineFooter } from "./index.ts"; // tests must not do this
139
+ ```
140
+
141
+ - Tests import from `src/<domain>/*` module files directly, **never** from `index.ts`
142
+ - Domain modules must not import back into the hub for callbacks — use `RuntimeState` + callback hooks
143
+
144
+ ### State Management
145
+
146
+ `RuntimeState` (from `src/extension/core/state.ts`) is the singleton mutable hub. It owns:
147
+ - `config` — parsed powerline configuration
148
+ - Motion/signal/render schedulers
149
+ - Derived caches (segment context, layout)
150
+
151
+ Leaf consumers receive `RuntimeState` plus callback hooks rather than importing sibling leaves directly.
152
+
153
+ ### Callback Wiring Pattern
154
+
155
+ ```typescript
156
+ // Leaf modules receive hooks, do not import siblings
157
+ function createRuntimeState(hooks: {
158
+ requestStatusRender: () => void;
159
+ requestImmediateStatusRender: () => void;
160
+ resetLayoutCache: () => void;
161
+ }): RuntimeState
162
+ ```
163
+
164
+ ### Error Handling
165
+
166
+ - Segment rendering is fault-isolated — a failing segment does not crash the status line
167
+ - Policy engine evaluates pre/post tool hooks with deny/inject results
168
+ - Hooks runner uses stdin JSON + timeout + process-group kill
169
+
170
+ ### Naming Conventions
171
+
172
+ - Domain barrels: `src/<domain>/index.ts`
173
+ - Segment files: `src/segments/{core,system,usage,custom}.ts`
174
+ - Motion modules: `src/motion/{catalog,scheduler,policy,types}.ts`
175
+ - Test files: `tests/<subsystem>.test.ts` (flat suite, descriptive names)
176
+
177
+ ## Important Files
178
+
179
+ ### Entry Points
180
+ - `index.ts` — package barrel (re-exports `powerlineFooter`, `resolveShortcutConfig`, `parseBashModeSettings`, `PowerlineShortcuts`)
181
+ - `src/extension/session/activate.ts` — activation bootstrap
182
+ - `src/extension/core/state.ts` — `RuntimeState` hub, `createRuntimeState`
183
+ - `src/extension/core/types.ts` — `RuntimeState` type definitions
184
+
185
+ ### Configuration
186
+ - `src/config/parse.ts` — `parsePowerlineConfig`, `PowerlineConfig` interface
187
+ - `src/config/settings-registry.ts` — operator settings definitions, defaults, validation
188
+ - `src/config/structural-presets.ts` — structural preset definitions
189
+ - `src/config/tokens.ts` — semantic token mapping
190
+
191
+ ### Rendering
192
+ - `src/render/v2-entry.ts` — `renderStatusLineV2` (composed render entry)
193
+ - `src/render/v2-adapter.ts` — layout adaptation for painting
194
+ - `src/render/paint.ts` — painting primitives
195
+ - `src/render/motion-rail.ts` — motion rail surface rendering
196
+
197
+ ### Segments
198
+ - `src/segments/registry.ts` — `SEGMENTS` registry, `renderSegment` dispatcher
199
+ - `src/segments/core.ts` — model, shell_mode, path, git, time, session, hostname
200
+ - `src/segments/system.ts` — thinking, subagents, queue, extension_statuses, open ports/TPS
201
+ - `src/segments/usage.ts` — token_in/out/total, cost, context_pct, cache_read/write
202
+ - `src/segments/custom.ts` — custom registered/static segments
203
+
204
+ ### Signal & Motion
205
+ - `src/signal/controller.ts` — `SignalRuntime` lifecycle
206
+ - `src/signal/integration.ts` — event dispatcher
207
+ - `src/motion/catalog.ts` — motion definitions, channel matrix
208
+ - `src/motion/scheduler.ts` — shared `MotionScheduler`
209
+ - `src/motion/policy.ts` — accessibility/policy filter
210
+
211
+ ### Bash Mode
212
+ - `bash-mode/pty-session.ts` — PTY session abstraction
213
+ - `bash-mode/session-factory.ts` — shell session creation
214
+ - `bash-mode/transcript.ts` — `BashTranscriptStore`
215
+ - `bash-mode/completion.ts` — completion engine
216
+
217
+ ### Queue
218
+ - `queue/store.ts` — `PowerlineQueueStore` (filesystem-backed JSONL)
219
+ - `queue/types.ts` — queue item/target/status types
220
+
221
+ ### Studio & Skills
222
+ - `src/studio/component.ts` — fullscreen Skill Studio component
223
+ - `src/studio/deck/render.ts` — Deck renderer
224
+ - `src/studio/advise/engine.ts` — advice pane engine
225
+ - `src/extension/skills/skill-registry.ts` — skill catalog
226
+ - `src/extension/skills/skill-manager.ts` — skill management commands
227
+
228
+ ### Release & Verification
229
+ - `scripts/release.mjs` — version bump, CHANGELOG rewrite, tag
230
+ - `scripts/verify-package.mjs` — package contract checks (name, description, keywords, pi manifest, resources, peers)
231
+ - `scripts/verify-pi-package-contract.mjs` — Pi package contract validation
232
+ - `scripts/npm-publish.sh` — idempotent npm publish with marker-tag promotion
233
+ - `scripts/github-release.sh` — GitHub Release creation
234
+ - `scripts/gen-lantern.mjs` — generates lantern art pixel-grid module
235
+
236
+ ## Runtime/Tooling Preferences
237
+
238
+ | Requirement | Detail |
239
+ |---|---|
240
+ | **Node** | v22.14 system default; Node 24 via nvm for CI and pi CLI (pi requires ≥22.19) |
241
+ | **Package manager** | npm (lockfile v3) |
242
+ | **TypeScript** | 5.9.3, strict mode, `NodeNext` module/resolution, `allowImportingTsExtensions`, no build step |
243
+ | **Test runner** | Node built-in `node:test` with `--experimental-strip-types` type stripping |
244
+ | **Circular import check** | `madge --circular src index.ts bash-mode queue` (via `npm run circular`) |
245
+ | **Import style** | `.ts` extensions on all imports, `node:`-prefixed builtins |
246
+ | **Pi CLI** | Installed at `^0.84.0`; wrapped to run under Node 24 via nvm |
247
+ | **Docker** | `fuse-overlayfs` storage driver for nested VM testing |
248
+ | **Compound Engineering** | `.compound-engineering/` overlay; portable skills via `~/.agents/skills/ce-*`; native Cursor plugin disabled |
249
+
250
+ ## Testing & QA
251
+
252
+ ### Test Framework
253
+
254
+ - **Runner**: Node's built-in `node:test` (flat per-file suites)
255
+ - **Assertions**: `node:assert/strict`
256
+ - **Type stripping**: `--experimental-strip-types` (no `tsc` compilation in tests)
257
+ - **Invocation**: `npm test` runs `node --experimental-strip-types --test tests/**/*.test.ts`
258
+
259
+ ### Test Organization
260
+
261
+ Tests are flat in `tests/` with supporting helpers and fixtures:
262
+
263
+ - `tests/helpers/strip-ansi.ts` — shared ANSI stripping utility
264
+ - `tests/fixtures/skill-template-golden.ts` — golden fixture for skill templates
265
+
266
+ ### Test Types
267
+
268
+ | Type | Convention | Examples |
269
+ |---|---|---|
270
+ | **Structural tests** | Assert on module contracts: layout rules, config precedence, catalog/scheduler invariants, registry behavior | `effective-config.test.ts`, `render-v2.test.ts`, `tokens.test.ts`, `hooks.test.ts` |
271
+ | **Behavior tests** | Assert runtime output: rendered strings, command execution, state changes, temp FS flows | `signal.test.ts`, `system-segments.test.ts`, `queue-store.test.ts`, `deck.test.ts` |
272
+ | **Golden tests** | Pin exact rendered output or template text | `signal-golden.test.ts`, `skill-templates.test.ts` |
273
+
274
+ ### Import Conventions
275
+
276
+ Tests import from `src/<domain>/*` module files directly — **never** from `index.ts`. For example:
277
+
278
+ ```typescript
279
+ // Correct
280
+ import { SEGMENTS } from "src/segments/registry.ts";
281
+
282
+ // Incorrect — do not import from index.ts
283
+ import { powerlineFooter } from "./index.ts";
284
+ ```
285
+
286
+ ### CI Verification Gates
287
+
288
+ The `.github/workflows/test.yml` workflow enforces the full verification chain:
289
+
290
+ ```bash
291
+ npm run typecheck # tsc --noEmit
292
+ npm test # node --test
293
+ npm run circular # madge --circular
294
+ npm run verify:package # verify-package.mjs + verify-pi-package-contract.mjs
295
+ npm audit # security audit
296
+ ```
297
+
298
+ ### Release Verification
299
+
300
+ Before any publish, `prepublishOnly` runs the full chain:
301
+
302
+ ```bash
303
+ tsc --noEmit && verify-package.mjs && verify-pi-package-contract.mjs
304
+ ```
305
+
306
+ ### Dockerized Parallel Testing
307
+
308
+ For isolated, parallel CI runs:
309
+
310
+ ```bash
311
+ scripts/docker-test.sh -n 4 # 4 parallel containers
72
312
  ```
73
313
 
74
- Run both before proposing any non-trivial change.
75
-
76
- Compound Engineering overlay: `.compound-engineering/` (tracked `config.yaml`, gitignored `config.local.yaml`). Artifact root `.compound-engineering/artifacts/`. Portable skills `~/.agents/skills/ce-*`; native Cursor plugin is fallback only when this overlay is absent.
77
-
78
- ## Cursor Cloud specific instructions
79
-
80
- The Cloud Agent environment is provisioned so the extension can be tested both as
81
- a library and inside real `pi`, with Docker for containerized/parallel runs.
82
-
83
- - **Node:** the platform `node` on `PATH` is v22.14 (fine for `npm ci`,
84
- `typecheck`, and the test suite, which use `--experimental-strip-types`).
85
- Node 24 is available via `nvm` and is what CI uses.
86
- - **pi CLI:** installed and pinned to the wishcraft peer range
87
- (`@earendil-works/pi-coding-agent` `>=0.81.0 <0.85.0`). pi requires Node
88
- `>=22.19`, so the `pi` launcher is wrapped to run under the `nvm` Node 24.
89
- `~/.pi/agent/settings.json` loads this checkout (`packages: ["/workspace"]`,
90
- `preset: chef`) so `pi` renders the wishcraft welcome/powerline on startup.
91
- Running a model needs a provider key (`/login`); the extension loads without
92
- one.
93
- - **Docker:** `dockerd` runs with the `fuse-overlayfs` storage driver (required
94
- in the nested VM). `scripts/cloud-agent-start.sh` starts it per boot;
95
- `scripts/cloud-agent-install.sh` is the idempotent bootstrap (`npm ci` plus
96
- self-healing pi setup).
97
- - **Containerized / parallel testing:** `scripts/docker-test.sh [-n N]` runs the
98
- full check suite (`typecheck` + `test` + `circular`) in `node:24` containers,
99
- each with an isolated copy of the tree, so `N` runs are safe in parallel.
100
- - **Publishing / pi.dev:** merges to `main` auto-publish to npm via the org
101
- `NPM_TOKEN` (`.github/workflows/release.yml`), and pi.dev mirrors npm. Gate
102
- the catalog contract with `npm run verify:package`.
314
+ Each container runs `typecheck + test + circular` with a shared read-only `node_modules` bind mount.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.12] - 2026-09-05
6
+
5
7
  ## [1.4.11] - 2026-08-30
6
8
 
7
9
  ## [1.4.10] - 2026-08-30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.4.11",
3
+ "version": "1.4.12",
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": [
@@ -63,7 +63,7 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@earendil-works/pi-ai": "^0.84.0",
66
- "@earendil-works/pi-coding-agent": "^0.84.0",
66
+ "@earendil-works/pi-coding-agent": "^0.84.3",
67
67
  "@earendil-works/pi-tui": "^0.84.0",
68
68
  "@types/node": "24.13.3",
69
69
  "madge": "^8.0.0",
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface GlobalRegistrySkill {
6
+ name: string;
7
+ description: string;
8
+ category: string;
9
+ family: string;
10
+ metaSkill: string | null;
11
+ role: string | null;
12
+ routerParent: string | null;
13
+ mounts: string[];
14
+ drift: boolean;
15
+ filePath: string;
16
+ }
17
+
18
+ interface RegistryPayload {
19
+ schema?: string;
20
+ skills?: unknown[];
21
+ }
22
+
23
+ export function globalRegistryPath(): string {
24
+ return process.env.CHEFGROEP_SKILL_REGISTRY?.trim() || join(homedir(), ".config", "chefgroep", "skill-registry", "skills.json");
25
+ }
26
+
27
+ function expandHome(path: string): string {
28
+ if (path === "~") return homedir();
29
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
30
+ return path;
31
+ }
32
+
33
+ export function loadGlobalSkillRegistry(path = globalRegistryPath()): GlobalRegistrySkill[] {
34
+ if (!existsSync(path)) return [];
35
+ let payload: RegistryPayload;
36
+ try {
37
+ payload = JSON.parse(readFileSync(path, "utf8")) as RegistryPayload;
38
+ } catch {
39
+ return [];
40
+ }
41
+ if (payload.schema !== "chefgroep-global-skill-registry/v1" || !Array.isArray(payload.skills)) return [];
42
+ const out: GlobalRegistrySkill[] = [];
43
+ for (const raw of payload.skills) {
44
+ if (!raw || typeof raw !== "object") continue;
45
+ const skill = raw as Record<string, any>;
46
+ if (typeof skill.name !== "string" || typeof skill.description !== "string") continue;
47
+ const canonical = skill.canonical && typeof skill.canonical === "object" ? skill.canonical : {};
48
+ const canonicalPath = typeof canonical.path === "string" ? canonical.path : "";
49
+ out.push({
50
+ name: skill.name,
51
+ description: skill.description,
52
+ category: typeof skill.category === "string" ? skill.category : "general",
53
+ family: typeof skill.family === "string" ? skill.family : "general",
54
+ metaSkill: typeof skill.meta_skill === "string" ? skill.meta_skill : null,
55
+ role: typeof skill.role === "string" ? skill.role : null,
56
+ routerParent: typeof skill.router_parent === "string" ? skill.router_parent : null,
57
+ mounts: Array.isArray(skill.mounts) ? skill.mounts.filter((x: unknown): x is string => typeof x === "string") : [],
58
+ drift: skill.drift === true,
59
+ filePath: expandHome(canonicalPath),
60
+ });
61
+ }
62
+ return out;
63
+ }
@@ -14,6 +14,7 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSy
14
14
  import { basename, dirname, join, relative } from "node:path";
15
15
  import { getAgentDir, getAgentPath } from "../../paths/agent-dirs.ts";
16
16
  import { parseSkillFrontmatter, stripFrontmatter } from "../../core/frontmatter.ts";
17
+ import { loadGlobalSkillRegistry } from "./global-registry.ts";
17
18
 
18
19
  /** Category: where the skill came from. */
19
20
  export type SkillCategory = "global" | "project" | "prompts" | "extra";
@@ -39,6 +40,14 @@ export interface SkillEntry {
39
40
  frontmatterKeys: string[];
40
41
  /** Frontmatter trigger value (e.g. /test, /showcase). */
41
42
  trigger: string | null;
43
+ /** Machine-side ChefGroep routing metadata when the global registry is installed. */
44
+ routingCategory?: string;
45
+ routingFamily?: string;
46
+ metaSkill?: string | null;
47
+ role?: string | null;
48
+ routerParent?: string | null;
49
+ mounts?: string[];
50
+ registryDrift?: boolean;
42
51
  /** Diagnostic message (core diagnostics, or empty description). */
43
52
  warning?: string;
44
53
  }
@@ -108,6 +117,7 @@ export function setSkillCacheInvalidationHandler(handler: (() => void) | null):
108
117
 
109
118
  const usageCache = new Map<string, SkillUsage>();
110
119
  let usageLoaded = false;
120
+ let usageLoadedFrom: string | null = null;
111
121
 
112
122
  /** Drop the discovery cache (the next read scans again). */
113
123
  export function invalidateSkillCache(): void {
@@ -340,11 +350,24 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
340
350
  );
341
351
  cachedAt = now;
342
352
  cachedCwd = cwd;
343
- cachedEntries = [
353
+ const localEntries = [
344
354
  ...deduped.filter((e) => !looseNames.has(e.name)),
345
355
  ...loose,
346
356
  ...rejected,
347
- ].sort((a, b) => a.name.localeCompare(b.name));
357
+ ];
358
+
359
+ // Enrich local Pi entries with routing metadata, but do not append
360
+ // registry-only skills here: generic Pi catalog/count semantics stay local.
361
+ const registry = loadGlobalSkillRegistry();
362
+ const registryByName = new Map(registry.map((r) => [r.name, r] as const));
363
+ for (const entry of localEntries) {
364
+ const r = registryByName.get(entry.name);
365
+ if (!r) continue;
366
+ entry.routingCategory = r.category; entry.routingFamily = r.family;
367
+ entry.metaSkill = r.metaSkill; entry.role = r.role; entry.routerParent = r.routerParent;
368
+ entry.mounts = r.mounts; entry.registryDrift = r.drift;
369
+ }
370
+ cachedEntries = localEntries.sort((a, b) => a.name.localeCompare(b.name));
348
371
  cachedPathMap = new Map(cachedEntries.map((e) => [e.name, e.filePath] as const));
349
372
  cachedTriggerMap = new Map(
350
373
  cachedEntries
@@ -354,6 +377,32 @@ export function loadSkillCatalog(cwd: string = process.cwd()): SkillEntry[] {
354
377
  return cachedEntries;
355
378
  }
356
379
 
380
+ /** Full machine-side catalog for Skill Studio only. */
381
+ export function loadSkillStudioCatalog(cwd: string = process.cwd()): SkillEntry[] {
382
+ const local = loadSkillCatalog(cwd).map((e) => ({ ...e }));
383
+ const localNames = new Set(local.map((e) => e.name));
384
+ const registryOnly: SkillEntry[] = loadGlobalSkillRegistry()
385
+ .filter((r) => !localNames.has(r.name))
386
+ .map((r) => {
387
+ let sizeBytes = 0, lineCount = 0, mtimeMs = 0;
388
+ let warning: string | undefined;
389
+ try {
390
+ const content = readFileSync(r.filePath, "utf8");
391
+ sizeBytes = Buffer.byteLength(content, "utf8");
392
+ lineCount = content.split("\n").length;
393
+ mtimeMs = statSync(r.filePath).mtimeMs;
394
+ } catch { warning = "registry source not readable on this host"; }
395
+ return {
396
+ name: r.name, description: r.description, filePath: r.filePath, baseDir: dirname(r.filePath),
397
+ isDirectorySkill: basename(r.filePath) === "SKILL.md", category: "extra" as SkillCategory,
398
+ disableModelInvocation: false, sizeBytes, lineCount, mtimeMs, frontmatterKeys: [], trigger: null, warning,
399
+ routingCategory: r.category, routingFamily: r.family, metaSkill: r.metaSkill, role: r.role,
400
+ routerParent: r.routerParent, mounts: r.mounts, registryDrift: r.drift,
401
+ };
402
+ });
403
+ return [...local, ...registryOnly].sort((a, b) => a.name.localeCompare(b.name));
404
+ }
405
+
357
406
  /** Compat: name → file path (for inline-invocation $skill). */
358
407
  export function getAvailableSkills(): Map<string, string> {
359
408
  loadSkillCatalog();
@@ -376,10 +425,13 @@ function usageFile(): string {
376
425
  }
377
426
 
378
427
  function loadUsage(): void {
379
- if (usageLoaded) return;
428
+ const file = usageFile();
429
+ if (usageLoaded && usageLoadedFrom === file) return;
380
430
  usageLoaded = true;
431
+ usageLoadedFrom = file;
432
+ usageCache.clear();
381
433
  try {
382
- const raw = readFileSync(usageFile(), "utf8");
434
+ const raw = readFileSync(file, "utf8");
383
435
  const parsed = JSON.parse(raw) as Record<string, SkillUsage>;
384
436
  for (const [name, u] of Object.entries(parsed)) {
385
437
  usageCache.set(name, { count: u.count ?? 0, lastUsed: u.lastUsed ?? 0 });
@@ -36,6 +36,7 @@ export {
36
36
  allowedChannels,
37
37
  allowsColorTransition,
38
38
  cadenceFor,
39
+ channelsForMotion,
39
40
  describeMotionEvent,
40
41
  effectiveLevel,
41
42
  prefersAsciiGlyphs,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CADENCE_MS, CHANNEL_MATRIX, PREVIEW_INTERVAL_MS } from "./catalog.ts";
11
- import type { MotionChannel, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
11
+ import type { MotionChannel, MotionDef, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
12
12
 
13
13
  const CHANNEL_TOGGLE: Record<MotionChannel, keyof MotionToggles> = {
14
14
  workingGlyph: "state",
@@ -53,6 +53,33 @@ export function effectiveLevel(policy: MotionPolicy): MotionLevel {
53
53
  return policy.level;
54
54
  }
55
55
 
56
+ /**
57
+ * Channels a motion is allowed to drive under the current policy.
58
+ *
59
+ * Same a11y filter as `allowedChannels`, but the source is the **motion's own
60
+ * declared channels** instead of the event table. This is what makes an
61
+ * explicit motion choice (preset signature or `appearance.motion` override)
62
+ * actually runnable: the event no longer vetoes a motion it wasn't paired
63
+ * with by the default matrix — the motion decides, the policy still filters.
64
+ */
65
+ export function channelsForMotion(
66
+ motion: MotionDef,
67
+ policy: MotionPolicy,
68
+ ): MotionChannel[] {
69
+ if (policy.screenReader || policy.level === "off") return [];
70
+
71
+ const effective = effectiveLevel(policy);
72
+ return motion.channels.filter((channel) => {
73
+ if (effective === "functional") {
74
+ return channel === "workingGlyph" || channel === "panelIndicator";
75
+ }
76
+ if (effective === "reduced") {
77
+ if (channel === "ambient" || channel === "signal") return false;
78
+ }
79
+ return policy.toggles[CHANNEL_TOGGLE[channel]];
80
+ });
81
+ }
82
+
56
83
  export function cadenceFor(
57
84
  channel: MotionChannel,
58
85
  policy: MotionPolicy,
@@ -6,7 +6,8 @@
6
6
  * rail, streaming = travelling head + trail, compacting = inward heads.
7
7
  */
8
8
 
9
- import { sweepPosition, trailGlyph } from "../motion/index.ts";
9
+ import { defaultMotionFor, getMotion } from "../motion/catalog.ts";
10
+ import { frameAt, framesOf, sweepPosition, trailGlyph } from "../motion/frames.ts";
10
11
  import type { SignalRuntime } from "../signal/controller.ts";
11
12
  import type { SignalSpec } from "../config/types.ts";
12
13
  import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
@@ -16,6 +17,7 @@ export function renderActivity(
16
17
  runtime: SignalRuntime,
17
18
  spec: SignalSpec,
18
19
  ascii = false,
20
+ width = 80,
19
21
  ): string {
20
22
  const label = runtime.activity || "ready";
21
23
  const open = spec.caps.leftOpen ?? "";
@@ -23,28 +25,70 @@ export function renderActivity(
23
25
  const dim = getFgAnsiCode("sep");
24
26
  const hot = getFgAnsiCode("accent");
25
27
  const reset = colorEnabled() ? ansi.reset : "";
26
- // One glyph family, directional comet: light `─` track, fixed solid
27
- // head, and a short box-drawing trail ONLY behind the head. Idle is a
28
- // calm flat rail (no cycling glyphs — the old shade-block cloud read
29
- // muddy across three unrelated glyph families).
30
- const RAIL_WIDTH = 12;
28
+ // Adaptive rail width: ~20% of terminal, clamped [16, 40]. Wider
29
+ // terminals get a longer sweep so the motion reads at a glance.
30
+ const RAIL_WIDTH = Math.max(16, Math.min(40, Math.round(width * 0.2)));
31
31
  const track = ascii ? "-" : "─";
32
32
  const head = ascii ? "o" : "●";
33
33
  const railColor = runtime.active ? hot : dim;
34
+ const def = getMotion(runtime.motionId);
35
+ // The head glyph is the chosen motion's own frame — so ember-relay
36
+ // sweeps a ◇→◈→◆ sequence and hex-relay carries #-density, instead of
37
+ // every motion wearing the same generic comet. The trail reuses the
38
+ // motion's own past frames too, so each motion leaves its own wake.
39
+ // ASCII terminals fall back to the clean box-drawing comet — motion
40
+ // frames are a color-font feature.
41
+ const headGlyph = (tick: number, distance: number) => {
42
+ if (def && !ascii) return frameAt(def, Math.max(0, tick - distance), false);
43
+ return distance === 0 ? head : trailGlyph(distance, ascii);
44
+ };
45
+ const trailDepth = def?.generator?.trail ?? 4;
46
+ // Per-cell color gradient: hot head fading to dim through the palette.
47
+ // Distance 0 = accent, then model → path → sep so the wake cools off.
48
+ const cellColor = (distance: number): string => {
49
+ if (!colorEnabled()) return "";
50
+ if (distance <= 0) return hot;
51
+ if (distance <= 1) return getFgAnsiCode("model");
52
+ if (distance <= 2) return getFgAnsiCode("path");
53
+ return dim;
54
+ };
34
55
  let railBlock: string;
35
56
  if (!runtime.active) {
36
- railBlock = track.repeat(RAIL_WIDTH);
57
+ // Idle: a frozen breathing wave of the ambient motion (wisp) frames.
58
+ // No animation consumer at idle, but a sine-sampled wave reads as
59
+ // "resting, not dead" — calmer than a full sweep, warmer than a flat track.
60
+ const ambient = getMotion(defaultMotionFor("idle"));
61
+ const ambientFrames = ambient ? framesOf(ambient) : null;
62
+ if (ambientFrames && colorEnabled()) {
63
+ const phase = Date.now() % 4000 / 4000;
64
+ const built: string[] = [];
65
+ for (let i = 0; i < RAIL_WIDTH; i++) {
66
+ const wave = Math.sin((i / RAIL_WIDTH) * Math.PI * 2 + phase * Math.PI * 2);
67
+ const frameIdx = Math.floor(((wave + 1) / 2) * ambientFrames.length) % ambientFrames.length;
68
+ const color = wave > 0.3 ? hot : dim;
69
+ built.push(`${color}${ambientFrames[frameIdx]}${reset}`);
70
+ }
71
+ railBlock = built.join("");
72
+ } else {
73
+ railBlock = track.repeat(RAIL_WIDTH);
74
+ }
37
75
  } else if (runtime.activity === "compacting") {
38
- // Compact state: two heads travel inward and compress a heavy core —
39
- // visually distinct from the sweep so compaction reads at a glance.
40
- railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii);
76
+ railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii, headGlyph, cellColor);
41
77
  } else {
42
78
  const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
43
79
  const built: string[] = [];
44
80
  for (let i = 0; i < RAIL_WIDTH; i++) {
45
- if (i === pos) built.push(head);
46
- else if (i < pos) built.push(trailGlyph(Math.min(pos - i, 4), ascii));
47
- else built.push(track);
81
+ if (i === pos) built.push(`${cellColor(0)}${headGlyph(runtime.tick, 0)}${reset}`);
82
+ else if (i < pos) {
83
+ const distance = pos - i;
84
+ if (distance <= trailDepth) {
85
+ built.push(`${cellColor(distance)}${headGlyph(runtime.tick, distance)}${reset}`);
86
+ } else {
87
+ built.push(track);
88
+ }
89
+ } else {
90
+ built.push(track);
91
+ }
48
92
  }
49
93
  railBlock = built.join("");
50
94
  }
@@ -79,6 +123,8 @@ function renderCompactRail(
79
123
  tick: number,
80
124
  width: number,
81
125
  ascii: boolean,
126
+ headGlyph: (tick: number, i: number) => string,
127
+ cellColor: (distance: number) => string,
82
128
  ): string {
83
129
  const half = Math.floor(width / 2);
84
130
  // Heads oscillate from the edges toward center and back.
@@ -87,13 +133,21 @@ function renderCompactRail(
87
133
  const inward = phase < span ? phase : span * 2 - phase;
88
134
  const leftPos = inward;
89
135
  const rightPos = width - 1 - inward;
90
- const head = ascii ? "*" : "●";
91
136
  const core = ascii ? "=" : "━";
92
137
  const track = ascii ? "-" : "─";
138
+ const reset = colorEnabled() ? ansi.reset : "";
139
+ // Color the core by distance from the nearest head — hottest at the
140
+ // heads, cooling toward center, so the compression reads thermally.
93
141
  let built = "";
94
142
  for (let i = 0; i < width; i++) {
95
- if (i === leftPos || i === rightPos) built += head;
96
- else built += i > leftPos && i < rightPos ? core : track;
143
+ if (i === leftPos || i === rightPos) {
144
+ built += `${cellColor(0)}${headGlyph(tick, 0)}${reset}`;
145
+ } else if (i > leftPos && i < rightPos) {
146
+ const nearestHead = Math.min(Math.abs(i - leftPos), Math.abs(i - rightPos));
147
+ built += `${cellColor(nearestHead)}${core}${reset}`;
148
+ } else {
149
+ built += track;
150
+ }
97
151
  }
98
152
  return built;
99
153
  }
@@ -97,7 +97,7 @@ export function renderStatusLineV2(
97
97
 
98
98
  merged.leftSegments.forEach((id, i) => pushSegment(id, 10_000 - i));
99
99
 
100
- const rail = renderActivity(runtime, options.signal, options.ascii);
100
+ const rail = renderActivity(runtime, options.signal, options.ascii, width);
101
101
  segments.push({ id: "signal", text: rail, priority: 5_000 });
102
102
  primary.push("signal");
103
103
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
- allowedChannels,
9
+ channelsForMotion,
10
10
  defaultMotionFor,
11
11
  getMotion,
12
12
  type MotionEvent,
@@ -61,17 +61,17 @@ export function setSignalEvent(
61
61
  runtime.tick = 0;
62
62
  runtime.startedAt = Date.now();
63
63
  runtime.activity = options.activity ?? activityForEvent(event);
64
- runtime.active = event !== "idle";
65
-
66
- if (
67
- event === "idle" ||
68
- !allowedChannels(event, policy).includes("signal")
69
- ) {
70
- runtime.active = false;
71
- return;
72
- }
73
64
 
74
65
  const def = getMotion(runtime.motionId);
66
+ // The chosen motion's own channel declaration decides whether the rail
67
+ // runs — not the event table. `CHANNEL_MATRIX` still picks the default
68
+ // motion per event, but an explicit choice (preset signature or an
69
+ // `appearance.motion` override) is never vetoed by the matrix afterwards.
70
+ // The a11y policy (screen-reader, full→reduced, functional, toggles)
71
+ // filters both paths identically via `channelsForMotion`.
72
+ runtime.active =
73
+ def !== undefined && channelsForMotion(def, policy).includes("signal");
74
+ if (!runtime.active) return;
75
75
  // Wrap subscribe so a throw doesn't leave runtime.active=true with
76
76
  // release=null (a leaked state that would survive stopSignal).
77
77
  let release: (() => void) | null = null;
@@ -12,6 +12,8 @@ import {
12
12
  STUDIO_PANES,
13
13
  } from "./state.ts";
14
14
  import type { StudioKeyEvent, StudioState } from "./types.ts";
15
+ import type { SkillEntry } from "../extension/skills/skill-registry.ts";
16
+ import { buildListRows, filterListRows } from "./list.ts";
15
17
 
16
18
  const PANE_LABELS: Record<string, string> = {
17
19
  list: "Skills",
@@ -45,36 +47,55 @@ export function mapRawInput(data: string): StudioKeyEvent {
45
47
  return { key: "other" };
46
48
  }
47
49
 
48
- export function renderStudioFrame(theme: Theme, width: number, state: StudioState): string[] {
50
+ export function renderStudioFrame(
51
+ theme: Theme,
52
+ width: number,
53
+ state: StudioState,
54
+ entries: readonly SkillEntry[] = [],
55
+ ): string[] {
49
56
  if (state.mode === "help") {
50
57
  const lines = [theme.fg("accent", HELP_LINES[0] ?? ""), ""];
51
- for (const line of HELP_LINES.slice(2)) {
52
- lines.push(theme.fg("muted", line));
53
- }
58
+ for (const line of HELP_LINES.slice(2)) lines.push(theme.fg("muted", line));
54
59
  lines.push("", theme.fg("dim", "Press q, Esc, or Enter to close help"));
55
60
  return lines;
56
61
  }
57
62
 
58
63
  const focusMark = (pane: string): string =>
59
64
  state.focus === pane ? theme.fg("accent", `[${PANE_LABELS[pane] ?? pane}]`) : theme.fg("dim", ` ${PANE_LABELS[pane] ?? pane} `);
60
-
61
65
  const header = STUDIO_PANES.map((pane) => focusMark(pane)).join(" ");
62
66
  const filterLine = state.mode === "filter"
63
67
  ? theme.fg("accent", `filter: ${state.filterQuery}_`)
64
- : state.filterQuery
65
- ? theme.fg("muted", `filter: ${state.filterQuery}`)
66
- : theme.fg("dim", "press / to filter, ? for help");
68
+ : state.filterQuery ? theme.fg("muted", `filter: ${state.filterQuery}`) : theme.fg("dim", "press / to filter, ? for help");
67
69
 
68
- const lines: string[] = [
69
- theme.fg("accent", "Skill Studio"),
70
- header,
71
- filterLine,
72
- theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
73
- theme.fg("muted", `selected: ${state.selectedIndex}`),
74
- theme.fg("dim", "panes populate in upcoming units (browse, actions, advice)"),
75
- theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
76
- theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"),
77
- ];
70
+ const rows = filterListRows(buildListRows(entries), state.filterQuery);
71
+ const selectedIndex = rows.length ? Math.min(state.selectedIndex, rows.length - 1) : 0;
72
+ const selected = rows[selectedIndex];
73
+ const radius = 4;
74
+ const from = Math.max(0, selectedIndex - radius);
75
+ const visible = rows.slice(from, from + 9);
76
+ const lines: string[] = [theme.fg("accent", `Skill Studio · ${rows.length} skills`), header, filterLine, theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 78))))];
77
+
78
+ if (!selected) {
79
+ lines.push(theme.fg("muted", "No skills match the current filter."));
80
+ } else {
81
+ for (let i = 0; i < visible.length; i += 1) {
82
+ const row = visible[i]!;
83
+ const absolute = from + i;
84
+ const mark = absolute === selectedIndex ? ">" : " ";
85
+ const route = row.routingCategory ? ` · ${row.routingCategory}/${row.routingFamily ?? "general"}` : "";
86
+ const drift = row.registryDrift ? " · drift" : "";
87
+ const text = `${mark} [${row.badge}] ${row.name}${route}${drift}`;
88
+ lines.push(absolute === selectedIndex ? theme.fg("accent", text) : theme.fg("muted", text));
89
+ }
90
+ lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 78)))));
91
+ lines.push(theme.fg("accent", selected.name));
92
+ lines.push(theme.fg("muted", selected.description || "No description"));
93
+ const ownership = [selected.role && `role=${selected.role}`, selected.routerParent && `parent=${selected.routerParent}`].filter(Boolean).join(" · ");
94
+ if (ownership) lines.push(theme.fg("dim", ownership));
95
+ lines.push(theme.fg("dim", selected.filePath));
96
+ if (selected.warning) lines.push(theme.fg("warning", `warning: ${selected.warning}`));
97
+ }
98
+ lines.push(theme.fg("dim", "q/Esc exit · / filter · j/k navigate · Tab focus · ? help"));
78
99
  return lines;
79
100
  }
80
101
 
@@ -82,6 +103,7 @@ export function createStudioComponent(
82
103
  theme: Theme,
83
104
  done: (value: string | null) => void,
84
105
  onStateChange?: (state: StudioState) => void,
106
+ entries: readonly SkillEntry[] = [],
85
107
  ) {
86
108
  let state = createStudioState();
87
109
 
@@ -89,7 +111,7 @@ export function createStudioComponent(
89
111
  focused: true,
90
112
  invalidate() {},
91
113
  render(width: number) {
92
- return renderStudioFrame(theme, width, state);
114
+ return renderStudioFrame(theme, width, state, entries);
93
115
  },
94
116
  handleInput(data: string) {
95
117
  const next = handleStudioKey(state, mapRawInput(data));
@@ -13,6 +13,11 @@ export interface ListRow {
13
13
  category: SkillCategory;
14
14
  filePath: string;
15
15
  warning?: string;
16
+ routingCategory?: string;
17
+ routingFamily?: string;
18
+ role?: string | null;
19
+ routerParent?: string | null;
20
+ registryDrift?: boolean;
16
21
  }
17
22
 
18
23
  export function badgeForCategory(category: SkillCategory): string {
@@ -36,6 +41,11 @@ export function buildListRows(entries: readonly SkillEntry[]): ListRow[] {
36
41
  category: entry.category,
37
42
  filePath: entry.filePath,
38
43
  warning: entry.warning,
44
+ routingCategory: entry.routingCategory,
45
+ routingFamily: entry.routingFamily,
46
+ role: entry.role,
47
+ routerParent: entry.routerParent,
48
+ registryDrift: entry.registryDrift,
39
49
  }));
40
50
  }
41
51
 
@@ -43,6 +53,6 @@ export function filterListRows(rows: readonly ListRow[], query: string): ListRow
43
53
  const q = query.trim().toLowerCase();
44
54
  if (q.length === 0) return [...rows];
45
55
  return rows.filter(
46
- (row) => row.name.toLowerCase().includes(q) || row.description.toLowerCase().includes(q),
56
+ (row) => row.name.toLowerCase().includes(q) || row.description.toLowerCase().includes(q) || row.routingCategory?.toLowerCase().includes(q) || row.routingFamily?.toLowerCase().includes(q),
47
57
  );
48
58
  }
@@ -6,13 +6,14 @@
6
6
 
7
7
  import type { RuntimeState } from "../extension/core/types.ts";
8
8
  import { createStudioComponent } from "./component.ts";
9
+ import { loadSkillStudioCatalog, invalidateSkillCache } from "../extension/skills/skill-registry.ts";
9
10
 
10
11
  /**
11
12
  * Keep the operator command fail-closed until list/detail/actions/advice are
12
13
  * actually wired into the fullscreen component. The scaffold stays available
13
14
  * to tests and follow-up implementation without exposing a misleading command.
14
15
  */
15
- export const SKILL_STUDIO_PANES_READY = false;
16
+ export const SKILL_STUDIO_PANES_READY = true;
16
17
 
17
18
  export async function openSkillStudio(
18
19
  rt: RuntimeState,
@@ -30,15 +31,12 @@ export async function openSkillStudio(
30
31
  ctx.ui.notify("Skill Studio is not available in RPC mode", "warning");
31
32
  return;
32
33
  }
33
- if (!SKILL_STUDIO_PANES_READY) {
34
- ctx.ui.notify("Skill Studio is not available until its panes are connected", "warning");
35
- return;
36
- }
37
-
38
34
  rt.currentCtx = ctx;
35
+ invalidateSkillCache();
36
+ const entries = loadSkillStudioCatalog(process.cwd());
39
37
 
40
38
  await ctx.ui.custom(
41
39
  (_tui: any, theme: any, _keybindings: any, done: (value: string | null) => void) =>
42
- createStudioComponent(theme, done),
40
+ createStudioComponent(theme, done, undefined, entries),
43
41
  );
44
42
  }
@@ -15,13 +15,9 @@ export const ansi: AnsiColors = {
15
15
  // ponytail: NO_COLOR (de-facto standard — present and non-empty) disables all
16
16
  // wishcraft color so the status bar stays plain text in no-color terminals
17
17
  // and color-blind pipelines. Computed lazily so test env changes take effect.
18
- let _colorEnabled: boolean | undefined;
19
18
  export function colorEnabled(): boolean {
20
- if (_colorEnabled === undefined) {
21
- const v = process.env.NO_COLOR;
22
- _colorEnabled = !(v != null && v !== "");
23
- }
24
- return _colorEnabled;
19
+ const v = process.env.NO_COLOR;
20
+ return !(v != null && v !== "");
25
21
  }
26
22
 
27
23
  function hexToRgb(hex: string): [number, number, number] {
@@ -1,179 +0,0 @@
1
- import { readFileSync, writeFileSync, copyFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { getAgentPath } from "../paths/agent-dirs.ts";
4
-
5
- export interface PatchHunk {
6
- oldStart: number;
7
- oldLines: number;
8
- newStart: number;
9
- newLines: number;
10
- lines: string[];
11
- }
12
-
13
- export interface FilePatch {
14
- targetFile: string;
15
- hunks: PatchHunk[];
16
- }
17
-
18
- export interface PatchResult {
19
- success: boolean;
20
- targetFile: string;
21
- appliedHunks: number;
22
- totalHunks: number;
23
- backupPath?: string;
24
- error?: string;
25
- }
26
-
27
- const undoStack: { targetFile: string; backupPath: string; timestamp: number }[] = [];
28
- const MAX_UNDO_STACK = 10;
29
-
30
- /**
31
- * Parse a unified diff string into structured FilePatch objects.
32
- */
33
- export function parseUnifiedDiff(diffText: string): FilePatch[] {
34
- const patches: FilePatch[] = [];
35
- const lines = diffText.split(/\r?\n/);
36
- let currentPatch: FilePatch | null = null;
37
- let currentHunk: PatchHunk | null = null;
38
-
39
- for (let i = 0; i < lines.length; i++) {
40
- const line = lines[i];
41
-
42
- if (line.startsWith("--- ")) {
43
- continue;
44
- }
45
-
46
- if (line.startsWith("+++ ")) {
47
- const rawPath = line.slice(4).trim();
48
- const targetFile = rawPath.replace(/^[ab]\//, "");
49
- currentPatch = { targetFile, hunks: [] };
50
- patches.push(currentPatch);
51
- continue;
52
- }
53
-
54
- const hunkHeaderMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
55
- if (hunkHeaderMatch && currentPatch) {
56
- currentHunk = {
57
- oldStart: parseInt(hunkHeaderMatch[1], 10),
58
- oldLines: parseInt(hunkHeaderMatch[2] ?? "1", 10),
59
- newStart: parseInt(hunkHeaderMatch[3], 10),
60
- newLines: parseInt(hunkHeaderMatch[4] ?? "1", 10),
61
- lines: [],
62
- };
63
- currentPatch.hunks.push(currentHunk);
64
- continue;
65
- }
66
-
67
- if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") || line === "")) {
68
- currentHunk.lines.push(line);
69
- }
70
- }
71
-
72
- return patches;
73
- }
74
-
75
- /**
76
- * Apply a FilePatch to a file safely with an atomic backup.
77
- */
78
- export function applyFilePatch(patch: FilePatch, baseDir: string = process.cwd()): PatchResult {
79
- const fullPath = join(baseDir, patch.targetFile);
80
-
81
- if (!existsSync(fullPath)) {
82
- return {
83
- success: false,
84
- targetFile: patch.targetFile,
85
- appliedHunks: 0,
86
- totalHunks: patch.hunks.length,
87
- error: `Target file not found: ${fullPath}`,
88
- };
89
- }
90
-
91
- // 1. Create backup
92
- const backupDir = getAgentPath("patch-backups");
93
- if (!existsSync(backupDir)) {
94
- mkdirSync(backupDir, { recursive: true });
95
- }
96
- const backupPath = join(backupDir, `${Date.now()}-${patch.targetFile.replace(/\//g, "_")}.bak`);
97
- copyFileSync(fullPath, backupPath);
98
-
99
- try {
100
- const originalContent = readFileSync(fullPath, "utf-8");
101
- let fileLines = originalContent.split(/\r?\n/);
102
- let appliedHunks = 0;
103
-
104
- for (const hunk of patch.hunks) {
105
- const targetLineIdx = hunk.oldStart - 1;
106
- const expectedOldLines = hunk.lines.filter((l) => !l.startsWith("+"));
107
-
108
- // Match context check
109
- let matches = true;
110
- for (let j = 0; j < expectedOldLines.length; j++) {
111
- const expected = expectedOldLines[j].slice(1);
112
- const actual = fileLines[targetLineIdx + j];
113
- if (actual !== undefined && actual !== expected) {
114
- matches = false;
115
- break;
116
- }
117
- }
118
-
119
- if (matches) {
120
- const newHunkLines: string[] = [];
121
- for (const line of hunk.lines) {
122
- if (!line.startsWith("-")) {
123
- newHunkLines.push(line.startsWith("+") ? line.slice(1) : line.slice(1));
124
- }
125
- }
126
-
127
- fileLines.splice(targetLineIdx, hunk.oldLines, ...newHunkLines);
128
- appliedHunks++;
129
- }
130
- }
131
-
132
- writeFileSync(fullPath, fileLines.join("\n"), "utf-8");
133
-
134
- // Track undo stack
135
- undoStack.push({ targetFile: fullPath, backupPath, timestamp: Date.now() });
136
- if (undoStack.length > MAX_UNDO_STACK) {
137
- const oldest = undoStack.shift();
138
- if (oldest && existsSync(oldest.backupPath)) {
139
- try { unlinkSync(oldest.backupPath); } catch {}
140
- }
141
- }
142
-
143
- return {
144
- success: appliedHunks > 0,
145
- targetFile: patch.targetFile,
146
- appliedHunks,
147
- totalHunks: patch.hunks.length,
148
- backupPath,
149
- };
150
- } catch (err) {
151
- // Revert from backup
152
- copyFileSync(backupPath, fullPath);
153
- return {
154
- success: false,
155
- targetFile: patch.targetFile,
156
- appliedHunks: 0,
157
- totalHunks: patch.hunks.length,
158
- error: err instanceof Error ? err.message : String(err),
159
- };
160
- }
161
- }
162
-
163
- /**
164
- * Revert the last applied patch.
165
- */
166
- export function undoLastPatch(): { success: boolean; targetFile?: string; error?: string } {
167
- const last = undoStack.pop();
168
- if (!last || !existsSync(last.backupPath)) {
169
- return { success: false, error: "No undo backup available" };
170
- }
171
-
172
- try {
173
- copyFileSync(last.backupPath, last.targetFile);
174
- unlinkSync(last.backupPath);
175
- return { success: true, targetFile: last.targetFile };
176
- } catch (err) {
177
- return { success: false, error: err instanceof Error ? err.message : String(err) };
178
- }
179
- }
@@ -1,104 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- export interface RipgrepMatch {
4
- file: string;
5
- lineNumber: number;
6
- content: string;
7
- }
8
-
9
- export interface RipgrepOptions {
10
- cwd?: string;
11
- typeFilter?: string;
12
- maxResults?: number;
13
- }
14
-
15
- export interface RipgrepResult {
16
- matches: RipgrepMatch[];
17
- totalCount: number;
18
- engine: "ripgrep" | "grep-fallback";
19
- error?: string;
20
- }
21
-
22
- /**
23
- * Execute a ripgrep (or fallback grep) search programmatically for subagent use.
24
- */
25
- export function searchRipgrep(pattern: string, options: RipgrepOptions = {}): RipgrepResult {
26
- const cwd = options.cwd ?? process.cwd();
27
- const maxResults = options.maxResults ?? 50;
28
-
29
- // Try rg first
30
- try {
31
- const rgArgs = ["--json", "-m", String(maxResults), "-i"];
32
- if (options.typeFilter) {
33
- rgArgs.push("-t", options.typeFilter);
34
- }
35
- rgArgs.push(pattern, ".");
36
-
37
- const proc = spawnSync("rg", rgArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
38
-
39
- if (proc.status === 0 || proc.status === 1) {
40
- const matches: RipgrepMatch[] = [];
41
- const lines = (proc.stdout ?? "").split("\n");
42
-
43
- for (const line of lines) {
44
- if (!line.trim()) continue;
45
- try {
46
- const parsed = JSON.parse(line);
47
- if (parsed.type === "match") {
48
- const data = parsed.data;
49
- matches.push({
50
- file: data.path.text,
51
- lineNumber: data.line_number,
52
- content: data.lines.text.trimEnd(),
53
- });
54
- }
55
- } catch {
56
- // ignore non-json lines
57
- }
58
- }
59
-
60
- return {
61
- matches: matches.slice(0, maxResults),
62
- totalCount: matches.length,
63
- engine: "ripgrep",
64
- };
65
- }
66
- } catch {
67
- // Fallback to standard grep
68
- }
69
-
70
- // Fallback: standard grep
71
- try {
72
- const grepArgs = ["-rn", "-m", String(maxResults), "--exclude-dir=node_modules", "--exclude-dir=.git", pattern, "."];
73
- const proc = spawnSync("grep", grepArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
74
-
75
- const matches: RipgrepMatch[] = [];
76
- const lines = (proc.stdout ?? "").split("\n");
77
-
78
- for (const line of lines) {
79
- if (!line.trim()) continue;
80
- const parts = line.split(":");
81
- if (parts.length >= 3) {
82
- const file = parts[0];
83
- const lineNumber = parseInt(parts[1], 10);
84
- const content = parts.slice(2).join(":").trimEnd();
85
- if (!isNaN(lineNumber)) {
86
- matches.push({ file, lineNumber, content });
87
- }
88
- }
89
- }
90
-
91
- return {
92
- matches: matches.slice(0, maxResults),
93
- totalCount: matches.length,
94
- engine: "grep-fallback",
95
- };
96
- } catch (err) {
97
- return {
98
- matches: [],
99
- totalCount: 0,
100
- engine: "grep-fallback",
101
- error: err instanceof Error ? err.message : String(err),
102
- };
103
- }
104
- }