@orchid-labs/pluxx 0.1.0 → 0.1.1

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.
Files changed (51) hide show
  1. package/README.md +100 -522
  2. package/dist/cli/agent.d.ts +7 -0
  3. package/dist/cli/agent.d.ts.map +1 -1
  4. package/dist/cli/doctor.d.ts +1 -0
  5. package/dist/cli/doctor.d.ts.map +1 -1
  6. package/dist/cli/eval.d.ts +22 -0
  7. package/dist/cli/eval.d.ts.map +1 -0
  8. package/dist/cli/index.d.ts +19 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/init-from-mcp.d.ts +17 -2
  11. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  12. package/dist/cli/install.d.ts +2 -0
  13. package/dist/cli/install.d.ts.map +1 -1
  14. package/dist/cli/lint.d.ts +5 -1
  15. package/dist/cli/lint.d.ts.map +1 -1
  16. package/dist/cli/mcp-proxy.d.ts +10 -0
  17. package/dist/cli/mcp-proxy.d.ts.map +1 -0
  18. package/dist/cli/migrate.d.ts.map +1 -1
  19. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  20. package/dist/cli/test.d.ts +2 -0
  21. package/dist/cli/test.d.ts.map +1 -1
  22. package/dist/generators/claude-code/index.d.ts +2 -0
  23. package/dist/generators/claude-code/index.d.ts.map +1 -1
  24. package/dist/generators/codex/index.d.ts +1 -0
  25. package/dist/generators/codex/index.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +99 -1
  29. package/dist/mcp/introspect.d.ts +43 -1
  30. package/dist/mcp/introspect.d.ts.map +1 -1
  31. package/dist/permissions.d.ts.map +1 -1
  32. package/dist/validation/platform-rules.d.ts +20 -0
  33. package/dist/validation/platform-rules.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/cli/agent.ts +459 -34
  36. package/src/cli/doctor.ts +400 -1
  37. package/src/cli/eval.ts +470 -0
  38. package/src/cli/index.ts +633 -114
  39. package/src/cli/init-from-mcp.ts +545 -41
  40. package/src/cli/install.ts +166 -4
  41. package/src/cli/lint.ts +56 -26
  42. package/src/cli/mcp-proxy.ts +322 -0
  43. package/src/cli/migrate.ts +256 -3
  44. package/src/cli/sync-from-mcp.ts +23 -0
  45. package/src/cli/test.ts +10 -2
  46. package/src/generators/claude-code/index.ts +143 -0
  47. package/src/generators/codex/index.ts +23 -0
  48. package/src/index.ts +12 -1
  49. package/src/mcp/introspect.ts +297 -24
  50. package/src/permissions.ts +3 -1
  51. package/src/validation/platform-rules.ts +121 -0
@@ -0,0 +1,322 @@
1
+ import { mkdirSync, readFileSync } from 'fs'
2
+ import { dirname, resolve } from 'path'
3
+ import * as readline from 'readline'
4
+ import type { Readable, Writable } from 'stream'
5
+ import { createMcpClient, McpIntrospectionError, type McpClient } from '../mcp/introspect'
6
+ import { parseMcpSourceInput } from './init-from-mcp'
7
+
8
+ interface ProxyTapeInteraction {
9
+ kind: 'request' | 'notify'
10
+ method: string
11
+ params?: Record<string, unknown>
12
+ result?: unknown
13
+ error?: {
14
+ code: number
15
+ message: string
16
+ }
17
+ }
18
+
19
+ interface ProxyTape {
20
+ version: 1
21
+ source?: string
22
+ interactions: ProxyTapeInteraction[]
23
+ }
24
+
25
+ interface McpProxyOptions {
26
+ source?: string
27
+ recordPath?: string
28
+ replayPath?: string
29
+ }
30
+
31
+ interface ProxyIo {
32
+ input: Readable
33
+ output: Writable
34
+ error: Writable
35
+ }
36
+
37
+ function usage(): string {
38
+ return [
39
+ 'Usage: pluxx mcp proxy --from-mcp <source> [--record <tape.json>]',
40
+ ' pluxx mcp proxy --replay <tape.json>',
41
+ '',
42
+ 'Acts as a local stdio MCP proxy for development and CI.',
43
+ '- --record stores normalized request/response interactions as a replay tape.',
44
+ '- --replay serves a deterministic stdio MCP session from a recorded tape.',
45
+ ].join('\n')
46
+ }
47
+
48
+ function readOption(rawArgs: string[], flag: string): string | undefined {
49
+ const index = rawArgs.indexOf(flag)
50
+ if (index === -1) return undefined
51
+
52
+ const value = rawArgs[index + 1]
53
+ if (!value || value.startsWith('-')) {
54
+ return undefined
55
+ }
56
+
57
+ return value
58
+ }
59
+
60
+ function parseOptions(rawArgs: string[]): McpProxyOptions {
61
+ const source = readOption(rawArgs, '--from-mcp')
62
+ const recordPath = readOption(rawArgs, '--record')
63
+ const replayPath = readOption(rawArgs, '--replay')
64
+
65
+ if (recordPath && replayPath) {
66
+ throw new Error('Choose either --record or --replay, not both.')
67
+ }
68
+
69
+ if (!source && !replayPath) {
70
+ throw new Error('Expected --from-mcp <source> for live proxying, or --replay <tape.json>.')
71
+ }
72
+
73
+ if ((recordPath || source) && replayPath && source) {
74
+ throw new Error('Replay mode does not accept --from-mcp.')
75
+ }
76
+
77
+ return {
78
+ source,
79
+ recordPath,
80
+ replayPath,
81
+ }
82
+ }
83
+
84
+ function stableStringify(value: unknown): string {
85
+ if (value === null || typeof value !== 'object') {
86
+ return JSON.stringify(value)
87
+ }
88
+
89
+ if (Array.isArray(value)) {
90
+ return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
91
+ }
92
+
93
+ const entries = Object.entries(value as Record<string, unknown>)
94
+ .sort(([left], [right]) => left.localeCompare(right))
95
+ .map(([key, nested]) => `${JSON.stringify(key)}:${stableStringify(nested)}`)
96
+ return `{${entries.join(',')}}`
97
+ }
98
+
99
+ function sameParams(
100
+ left: Record<string, unknown> | undefined,
101
+ right: Record<string, unknown> | undefined,
102
+ ): boolean {
103
+ return stableStringify(left ?? null) === stableStringify(right ?? null)
104
+ }
105
+
106
+ function sendEnvelope(output: Writable, envelope: Record<string, unknown>): void {
107
+ output.write(`${JSON.stringify(envelope)}\n`)
108
+ }
109
+
110
+ function sendError(output: Writable, id: number | string | null, code: number, message: string): void {
111
+ sendEnvelope(output, {
112
+ jsonrpc: '2.0',
113
+ id,
114
+ error: {
115
+ code,
116
+ message,
117
+ },
118
+ })
119
+ }
120
+
121
+ async function loadReplayTape(filepath: string): Promise<ProxyTape> {
122
+ const absolutePath = resolve(process.cwd(), filepath)
123
+ const tape = JSON.parse(readFileSync(absolutePath, 'utf-8')) as ProxyTape
124
+ if (tape.version !== 1 || !Array.isArray(tape.interactions)) {
125
+ throw new Error(`Replay tape is not a valid pluxx MCP tape: ${filepath}`)
126
+ }
127
+ return tape
128
+ }
129
+
130
+ async function writeTape(filepath: string, tape: ProxyTape): Promise<void> {
131
+ const absolutePath = resolve(process.cwd(), filepath)
132
+ mkdirSync(dirname(absolutePath), { recursive: true })
133
+ await Bun.write(absolutePath, `${JSON.stringify(tape, null, 2)}\n`)
134
+ }
135
+
136
+ function serializeError(error: unknown): { code: number; message: string } {
137
+ if (error instanceof McpIntrospectionError) {
138
+ return {
139
+ code: error.rpcCode ?? -32000,
140
+ message: error.message,
141
+ }
142
+ }
143
+
144
+ return {
145
+ code: -32000,
146
+ message: error instanceof Error ? error.message : String(error),
147
+ }
148
+ }
149
+
150
+ async function proxyLiveSession(client: McpClient, options: McpProxyOptions, io: ProxyIo): Promise<void> {
151
+ const tape: ProxyTape | null = options.recordPath
152
+ ? {
153
+ version: 1,
154
+ source: options.source,
155
+ interactions: [],
156
+ }
157
+ : null
158
+
159
+ const rl = readline.createInterface({
160
+ input: io.input,
161
+ crlfDelay: Infinity,
162
+ })
163
+
164
+ try {
165
+ for await (const line of rl) {
166
+ if (!line.trim()) continue
167
+
168
+ let message: Record<string, unknown>
169
+ try {
170
+ message = JSON.parse(line) as Record<string, unknown>
171
+ } catch {
172
+ continue
173
+ }
174
+
175
+ const method = typeof message.method === 'string' ? message.method : undefined
176
+ if (!method) continue
177
+
178
+ const params = (typeof message.params === 'object' && message.params !== null)
179
+ ? message.params as Record<string, unknown>
180
+ : undefined
181
+ const id = typeof message.id === 'number' || typeof message.id === 'string'
182
+ ? message.id
183
+ : null
184
+
185
+ if (id === null) {
186
+ await client.notify(method, params)
187
+ tape?.interactions.push({
188
+ kind: 'notify',
189
+ method,
190
+ ...(params ? { params } : {}),
191
+ })
192
+ continue
193
+ }
194
+
195
+ try {
196
+ const result = await client.request<unknown>(method, params)
197
+ sendEnvelope(io.output, {
198
+ jsonrpc: '2.0',
199
+ id,
200
+ result,
201
+ })
202
+ tape?.interactions.push({
203
+ kind: 'request',
204
+ method,
205
+ ...(params ? { params } : {}),
206
+ result,
207
+ })
208
+ } catch (error) {
209
+ const serialized = serializeError(error)
210
+ sendError(io.output, id, serialized.code, serialized.message)
211
+ tape?.interactions.push({
212
+ kind: 'request',
213
+ method,
214
+ ...(params ? { params } : {}),
215
+ error: serialized,
216
+ })
217
+ }
218
+ }
219
+ } finally {
220
+ rl.close()
221
+ await client.close()
222
+ if (tape && options.recordPath) {
223
+ await writeTape(options.recordPath, tape)
224
+ }
225
+ }
226
+ }
227
+
228
+ async function replaySession(filepath: string, io: ProxyIo): Promise<void> {
229
+ const tape = await loadReplayTape(filepath)
230
+ const interactions = [...tape.interactions]
231
+ const rl = readline.createInterface({
232
+ input: io.input,
233
+ crlfDelay: Infinity,
234
+ })
235
+
236
+ try {
237
+ for await (const line of rl) {
238
+ if (!line.trim()) continue
239
+
240
+ let message: Record<string, unknown>
241
+ try {
242
+ message = JSON.parse(line) as Record<string, unknown>
243
+ } catch {
244
+ continue
245
+ }
246
+
247
+ const method = typeof message.method === 'string' ? message.method : undefined
248
+ if (!method) continue
249
+
250
+ const params = (typeof message.params === 'object' && message.params !== null)
251
+ ? message.params as Record<string, unknown>
252
+ : undefined
253
+ const id = typeof message.id === 'number' || typeof message.id === 'string'
254
+ ? message.id
255
+ : null
256
+ const expected = interactions.shift()
257
+
258
+ if (!expected) {
259
+ if (id !== null) {
260
+ sendError(io.output, id, -32001, `Replay tape exhausted before handling ${method}.`)
261
+ }
262
+ continue
263
+ }
264
+
265
+ if (expected.kind !== (id === null ? 'notify' : 'request') || expected.method !== method || !sameParams(expected.params, params)) {
266
+ if (id !== null) {
267
+ sendError(
268
+ io.output,
269
+ id,
270
+ -32002,
271
+ `Replay mismatch. Expected ${expected.kind} ${expected.method}, received ${id === null ? 'notify' : 'request'} ${method}.`,
272
+ )
273
+ }
274
+ continue
275
+ }
276
+
277
+ if (id === null) {
278
+ continue
279
+ }
280
+
281
+ if (expected.error) {
282
+ sendError(io.output, id, expected.error.code, expected.error.message)
283
+ continue
284
+ }
285
+
286
+ sendEnvelope(io.output, {
287
+ jsonrpc: '2.0',
288
+ id,
289
+ result: expected.result ?? null,
290
+ })
291
+ }
292
+ } finally {
293
+ rl.close()
294
+ }
295
+ }
296
+
297
+ export async function runMcpProxy(rawArgs: string[]): Promise<void> {
298
+ return await runMcpProxyWithIo(rawArgs, {
299
+ input: process.stdin,
300
+ output: process.stdout,
301
+ error: process.stderr,
302
+ })
303
+ }
304
+
305
+ export async function runMcpProxyWithIo(rawArgs: string[], io: ProxyIo): Promise<void> {
306
+ let options: McpProxyOptions
307
+ try {
308
+ options = parseOptions(rawArgs)
309
+ } catch (error) {
310
+ io.error.write(`${error instanceof Error ? error.message : String(error)}\n\n${usage()}\n`)
311
+ throw new Error('Invalid MCP proxy arguments.')
312
+ }
313
+
314
+ if (options.replayPath) {
315
+ await replaySession(options.replayPath, io)
316
+ return
317
+ }
318
+
319
+ const source = parseMcpSourceInput(options.source!)
320
+ const client = await createMcpClient(source)
321
+ await proxyLiveSession(client, options, io)
322
+ }
@@ -1,5 +1,12 @@
1
- import { resolve, basename, join, relative } from 'path'
1
+ import { resolve, basename } from 'path'
2
2
  import { existsSync, readdirSync, mkdirSync, cpSync, readFileSync } from 'fs'
3
+ import {
4
+ MCP_SCAFFOLD_METADATA_PATH,
5
+ MCP_TAXONOMY_PATH,
6
+ type McpScaffoldMetadata,
7
+ type PersistedSkill,
8
+ } from './init-from-mcp'
9
+ import type { McpServer } from '../schema'
3
10
 
4
11
  type DetectedPlatform = 'claude-code' | 'cursor' | 'codex' | 'opencode'
5
12
 
@@ -55,6 +62,7 @@ interface MigrateResult {
55
62
  scripts: boolean
56
63
  assets: boolean
57
64
  }
65
+ persistedSkills: PersistedSkill[]
58
66
  }
59
67
 
60
68
  // ── Platform Detection ──────────────────────────────────────────
@@ -347,6 +355,237 @@ function detectDirectories(pluginDir: string) {
347
355
  }
348
356
  }
349
357
 
358
+ function toKebabCase(value: string): string {
359
+ return value
360
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
361
+ .replace(/[^A-Za-z0-9]+/g, '-')
362
+ .replace(/^-+|-+$/g, '')
363
+ .toLowerCase()
364
+ }
365
+
366
+ function titleCaseFromDirName(value: string): string {
367
+ return value
368
+ .split(/[-_]+/)
369
+ .filter(Boolean)
370
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
371
+ .join(' ')
372
+ }
373
+
374
+ function firstHeading(content: string): string | undefined {
375
+ for (const line of content.split(/\r?\n/)) {
376
+ const trimmed = line.trim()
377
+ const match = trimmed.match(/^#\s+(.+)$/)
378
+ if (match) {
379
+ return match[1].trim()
380
+ }
381
+ }
382
+ return undefined
383
+ }
384
+
385
+ function extractFrontmatterField(content: string, key: 'name' | 'description'): string | undefined {
386
+ const lines = content.split(/\r?\n/)
387
+ if (lines[0]?.trim() !== '---') return undefined
388
+
389
+ let endIndex = -1
390
+ for (let i = 1; i < lines.length; i += 1) {
391
+ if (lines[i].trim() === '---') {
392
+ endIndex = i
393
+ break
394
+ }
395
+ }
396
+
397
+ if (endIndex === -1) return undefined
398
+
399
+ for (const line of lines.slice(1, endIndex)) {
400
+ const match = line.match(new RegExp(`^${key}:\\s*(.*)$`))
401
+ if (!match) continue
402
+ return match[1].trim().replace(/^['"]|['"]$/g, '')
403
+ }
404
+
405
+ return undefined
406
+ }
407
+
408
+ function readMigratedSkills(pluginDir: string, dirs: MigrateResult['directories']): PersistedSkill[] {
409
+ const skills: PersistedSkill[] = []
410
+
411
+ if (dirs.skills) {
412
+ const skillsDir = resolve(pluginDir, 'skills')
413
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
414
+
415
+ for (const entry of entries) {
416
+ if (!entry.isDirectory()) continue
417
+ const dirName = entry.name
418
+ const skillPath = resolve(skillsDir, dirName, 'SKILL.md')
419
+ let title = titleCaseFromDirName(dirName)
420
+ let description: string | undefined
421
+
422
+ if (existsSync(skillPath)) {
423
+ const content = readFileSync(skillPath, 'utf-8')
424
+ title = extractFrontmatterField(content, 'name')
425
+ ?? firstHeading(content)
426
+ ?? title
427
+ description = extractFrontmatterField(content, 'description')
428
+ }
429
+
430
+ skills.push({
431
+ dirName,
432
+ title,
433
+ description,
434
+ toolNames: [],
435
+ })
436
+ }
437
+ }
438
+
439
+ if (skills.length === 0 && dirs.commands) {
440
+ const commandsDir = resolve(pluginDir, 'commands')
441
+ const entries = readdirSync(commandsDir, { withFileTypes: true })
442
+
443
+ for (const entry of entries) {
444
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
445
+ const dirName = toKebabCase(entry.name.replace(/\.md$/, '')) || 'command'
446
+ const content = readFileSync(resolve(commandsDir, entry.name), 'utf-8')
447
+ skills.push({
448
+ dirName,
449
+ title: firstHeading(content) ?? titleCaseFromDirName(dirName),
450
+ description: extractFrontmatterField(content, 'description'),
451
+ toolNames: [],
452
+ })
453
+ }
454
+ }
455
+
456
+ return skills.sort((a, b) => a.dirName.localeCompare(b.dirName))
457
+ }
458
+
459
+ function primarySource(result: MigrateResult): McpServer {
460
+ const [serverName, server] = Object.entries(result.mcp)[0] ?? []
461
+ const auth = normalizeMigrateAuth(server?.auth)
462
+ if (server) {
463
+ if (server.transport === 'stdio') {
464
+ return {
465
+ transport: 'stdio',
466
+ command: server.command ?? 'TODO_MCP_COMMAND',
467
+ args: server.args ?? [],
468
+ ...(server.env ? { env: server.env } : {}),
469
+ ...(auth ? { auth } : {}),
470
+ }
471
+ }
472
+
473
+ if (server.transport === 'sse') {
474
+ return {
475
+ transport: 'sse',
476
+ url: server.url ?? `https://example.com/${serverName ?? 'mcp'}`,
477
+ ...(auth ? { auth } : {}),
478
+ }
479
+ }
480
+
481
+ return {
482
+ transport: 'http',
483
+ url: server.url ?? `https://example.com/${serverName ?? 'mcp'}`,
484
+ ...(auth ? { auth } : {}),
485
+ }
486
+ }
487
+
488
+ return {
489
+ transport: 'stdio',
490
+ command: 'TODO_MCP_COMMAND',
491
+ args: [],
492
+ }
493
+ }
494
+
495
+ function normalizeMigrateAuth(auth: ParsedMcp[string]['auth']) {
496
+ if (!auth) return undefined
497
+ if (auth.type === 'none') {
498
+ return { type: 'none' as const }
499
+ }
500
+ if (auth.type === 'bearer' && auth.envVar) {
501
+ return {
502
+ type: 'bearer' as const,
503
+ envVar: auth.envVar,
504
+ headerName: auth.headerName ?? 'Authorization',
505
+ headerTemplate: auth.headerTemplate ?? 'Bearer ${value}',
506
+ }
507
+ }
508
+ if (auth.type === 'header' && auth.envVar && auth.headerName) {
509
+ return {
510
+ type: 'header' as const,
511
+ envVar: auth.envVar,
512
+ headerName: auth.headerName,
513
+ headerTemplate: auth.headerTemplate ?? '${value}',
514
+ }
515
+ }
516
+ return undefined
517
+ }
518
+
519
+ function buildMigratedScaffoldMetadata(result: MigrateResult, outputDir: string): McpScaffoldMetadata {
520
+ const pluginName = result.manifest.name ?? 'my-plugin'
521
+ const displayName = result.manifest.name ? titleCaseFromDirName(result.manifest.name) : 'Migrated Plugin'
522
+ const description = result.manifest.description ?? 'Migrated plugin scaffold.'
523
+ const generatedHookEvents = Object.keys(result.hooks)
524
+ const managedFiles = [
525
+ ...(result.instructions ? [result.instructions.replace(/^\.\//, '')] : []),
526
+ ...(['skills', 'commands', 'agents', 'scripts', 'assets'] as const).flatMap((dir) => {
527
+ if (!result.directories[dir]) return []
528
+ const baseDir = dir
529
+ const dirPath = resolve(outputDir, baseDir)
530
+ if (!existsSync(dirPath)) return []
531
+ const entries = readdirSync(dirPath, { withFileTypes: true })
532
+ const files: string[] = []
533
+ for (const entry of entries) {
534
+ if (entry.isDirectory()) {
535
+ const nestedDir = resolve(dirPath, entry.name)
536
+ for (const nested of readdirSync(nestedDir, { withFileTypes: true })) {
537
+ if (nested.isFile()) {
538
+ files.push(`${baseDir}/${entry.name}/${nested.name}`)
539
+ }
540
+ }
541
+ continue
542
+ }
543
+ if (entry.isFile()) {
544
+ files.push(`${baseDir}/${entry.name}`)
545
+ }
546
+ }
547
+ return files
548
+ }),
549
+ 'pluxx.config.ts',
550
+ MCP_TAXONOMY_PATH,
551
+ MCP_SCAFFOLD_METADATA_PATH,
552
+ ]
553
+
554
+ return {
555
+ version: 1,
556
+ source: primarySource(result),
557
+ serverInfo: {
558
+ name: pluginName,
559
+ title: displayName,
560
+ version: result.manifest.version ?? '0.1.0',
561
+ description,
562
+ ...(result.manifest.repository ? { websiteUrl: result.manifest.repository } : {}),
563
+ },
564
+ settings: {
565
+ pluginName,
566
+ displayName,
567
+ description,
568
+ skillGrouping: 'workflow',
569
+ requestedHookMode: generatedHookEvents.length > 0 ? 'safe' : 'none',
570
+ generatedHookMode: generatedHookEvents.length > 0 ? 'safe' : 'none',
571
+ generatedHookEvents,
572
+ runtimeAuthMode: 'inline',
573
+ },
574
+ userConfig: [],
575
+ tools: [],
576
+ resources: [],
577
+ resourceTemplates: [],
578
+ prompts: [],
579
+ skills: result.persistedSkills.map((skill) => ({
580
+ dirName: skill.dirName,
581
+ title: skill.title,
582
+ description: skill.description,
583
+ toolNames: skill.toolNames,
584
+ })),
585
+ managedFiles: [...new Set(managedFiles)].sort(),
586
+ }
587
+ }
588
+
350
589
  // ── Copy Directories ────────────────────────────────────────────
351
590
 
352
591
  function copyDirectories(
@@ -559,12 +798,16 @@ export async function migrate(inputPath: string): Promise<void> {
559
798
 
560
799
  // 6. Detect directories
561
800
  const directories = detectDirectories(pluginDir)
801
+ const persistedSkills = readMigratedSkills(pluginDir, directories)
562
802
  const dirNames = Object.entries(directories)
563
803
  .filter(([_, exists]) => exists)
564
804
  .map(([name]) => name)
565
805
  if (dirNames.length > 0) {
566
806
  console.log(` directories: ${dirNames.join(', ')}`)
567
807
  }
808
+ if (persistedSkills.length > 0) {
809
+ console.log(` migrated skills: ${persistedSkills.map((skill) => skill.dirName).join(', ')}`)
810
+ }
568
811
 
569
812
  // 7. Build result
570
813
  const result: MigrateResult = {
@@ -574,6 +817,7 @@ export async function migrate(inputPath: string): Promise<void> {
574
817
  hooks,
575
818
  instructions,
576
819
  directories,
820
+ persistedSkills,
577
821
  }
578
822
 
579
823
  // 8. Generate config
@@ -606,9 +850,18 @@ export async function migrate(inputPath: string): Promise<void> {
606
850
  }
607
851
  }
608
852
 
853
+ // 11. Create synthetic migration metadata/taxonomy so Agent Mode and evals work.
854
+ const taxonomyPath = resolve(outputDir, MCP_TAXONOMY_PATH)
855
+ const metadataPath = resolve(outputDir, MCP_SCAFFOLD_METADATA_PATH)
856
+ mkdirSync(resolve(outputDir, '.pluxx'), { recursive: true })
857
+ await Bun.write(taxonomyPath, `${JSON.stringify(result.persistedSkills, null, 2)}\n`)
858
+ await Bun.write(metadataPath, `${JSON.stringify(buildMigratedScaffoldMetadata(result, outputDir), null, 2)}\n`)
859
+ console.log(`Generated: ${MCP_TAXONOMY_PATH}, ${MCP_SCAFFOLD_METADATA_PATH}`)
860
+
609
861
  console.log('')
610
862
  console.log('Migration complete! Next steps:')
611
863
  console.log(' 1. Review pluxx.config.ts and fill in any TODOs')
612
- console.log(' 2. Run: pluxx validate')
613
- console.log(' 3. Run: pluxx build')
864
+ console.log(' 2. Run: pluxx doctor')
865
+ console.log(' 3. Run: pluxx eval')
866
+ console.log(' 4. Run: pluxx build')
614
867
  }
@@ -142,6 +142,13 @@ export async function syncFromMcp(options: SyncFromMcpOptions): Promise<SyncFrom
142
142
  const after = readFileSync(currentPath, 'utf-8')
143
143
  return before !== after
144
144
  })
145
+ const scaffoldChanged = addedFiles.length > 0
146
+ || updatedFiles.length > 0
147
+ || removedFiles.length > 0
148
+ || renamedFiles.length > 0
149
+ if (scaffoldChanged) {
150
+ invalidateSavedAgentPack(options.rootDir)
151
+ }
145
152
 
146
153
  return {
147
154
  source,
@@ -216,6 +223,8 @@ export async function applyPersistedTaxonomy(rootDir: string): Promise<void> {
216
223
  writeFileSync(resolveWithinRoot(rootDir, file), previousInstructions, 'utf-8')
217
224
  }
218
225
  }
226
+
227
+ invalidateSavedAgentPack(rootDir)
219
228
  }
220
229
 
221
230
  export async function planSyncFromMcp(options: SyncFromMcpOptions): Promise<SyncFromMcpResult> {
@@ -309,6 +318,20 @@ function pruneEmptyDirectories(rootDir: string, startDir: string): void {
309
318
  }
310
319
  }
311
320
 
321
+ const AGENT_PACK_FILES = [
322
+ '.pluxx/agent/context.md',
323
+ '.pluxx/agent/plan.json',
324
+ '.pluxx/agent/taxonomy-prompt.md',
325
+ '.pluxx/agent/instructions-prompt.md',
326
+ '.pluxx/agent/review-prompt.md',
327
+ ] as const
328
+
329
+ function invalidateSavedAgentPack(rootDir: string): void {
330
+ for (const relativePath of AGENT_PACK_FILES) {
331
+ removeManagedFile(rootDir, relativePath)
332
+ }
333
+ }
334
+
312
335
  /**
313
336
  * Detect tool renames by comparing old and new tool lists.
314
337
  * Returns a map of oldName -> newName for likely renames.