@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 +309 -97
- package/CHANGELOG.md +2 -0
- package/package.json +2 -2
- package/src/extension/skills/global-registry.ts +63 -0
- package/src/extension/skills/skill-registry.ts +56 -4
- package/src/motion/index.ts +1 -0
- package/src/motion/policy.ts +28 -1
- package/src/render/motion-rail.ts +70 -16
- package/src/render/v2-entry.ts +1 -1
- package/src/signal/controller.ts +10 -10
- package/src/studio/component.ts +41 -19
- package/src/studio/list.ts +11 -1
- package/src/studio/open.ts +5 -7
- package/src/theme/colors.ts +2 -6
- package/src/tools/patch.ts +0 -179
- package/src/tools/ripgrep.ts +0 -104
package/AGENTS.md
CHANGED
|
@@ -1,102 +1,314 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groeponline/pi-wishcraft",
|
|
3
|
-
"version": "1.4.
|
|
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.
|
|
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
|
-
|
|
353
|
+
const localEntries = [
|
|
344
354
|
...deduped.filter((e) => !looseNames.has(e.name)),
|
|
345
355
|
...loose,
|
|
346
356
|
...rejected,
|
|
347
|
-
]
|
|
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
|
-
|
|
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(
|
|
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 });
|
package/src/motion/index.ts
CHANGED
package/src/motion/policy.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
46
|
-
else if (i < pos)
|
|
47
|
-
|
|
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)
|
|
96
|
-
|
|
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
|
}
|
package/src/render/v2-entry.ts
CHANGED
|
@@ -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
|
|
package/src/signal/controller.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
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;
|
package/src/studio/component.ts
CHANGED
|
@@ -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(
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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));
|
package/src/studio/list.ts
CHANGED
|
@@ -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
|
}
|
package/src/studio/open.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/src/theme/colors.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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] {
|
package/src/tools/patch.ts
DELETED
|
@@ -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
|
-
}
|
package/src/tools/ripgrep.ts
DELETED
|
@@ -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
|
-
}
|