opencastle 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli/bootstrap.d.ts +8 -0
  2. package/dist/cli/bootstrap.d.ts.map +1 -0
  3. package/dist/cli/bootstrap.js +358 -0
  4. package/dist/cli/bootstrap.js.map +1 -0
  5. package/dist/cli/bootstrap.test.d.ts +6 -0
  6. package/dist/cli/bootstrap.test.d.ts.map +1 -0
  7. package/dist/cli/bootstrap.test.js +196 -0
  8. package/dist/cli/bootstrap.test.js.map +1 -0
  9. package/dist/cli/detect.d.ts +6 -1
  10. package/dist/cli/detect.d.ts.map +1 -1
  11. package/dist/cli/detect.js +18 -0
  12. package/dist/cli/detect.js.map +1 -1
  13. package/dist/cli/init.d.ts.map +1 -1
  14. package/dist/cli/init.js +21 -10
  15. package/dist/cli/init.js.map +1 -1
  16. package/dist/cli/prompt.js +1 -1
  17. package/dist/cli/prompt.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/bootstrap.test.ts +286 -0
  20. package/src/cli/bootstrap.ts +472 -0
  21. package/src/cli/detect.ts +22 -1
  22. package/src/cli/init.ts +23 -13
  23. package/src/cli/prompt.ts +1 -1
  24. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  25. package/src/orchestrator/agents/team-lead.agent.md +4 -2
  26. package/src/orchestrator/customizations/README.md +3 -3
  27. package/src/orchestrator/customizations/agents/agent-registry.md +1 -1
  28. package/src/orchestrator/customizations/project/docs-structure.md +1 -1
  29. package/src/orchestrator/customizations/project/roadmap.md +1 -1
  30. package/src/orchestrator/customizations/project/tracker-config.md +1 -1
  31. package/src/orchestrator/customizations/project.instructions.md +2 -2
  32. package/src/orchestrator/customizations/stack/api-config.md +1 -1
  33. package/src/orchestrator/customizations/stack/cms-config.md +2 -2
  34. package/src/orchestrator/customizations/stack/data-pipeline-config.md +1 -1
  35. package/src/orchestrator/customizations/stack/database-config.md +2 -2
  36. package/src/orchestrator/customizations/stack/deployment-config.md +1 -1
  37. package/src/orchestrator/customizations/stack/notifications-config.md +1 -1
  38. package/src/orchestrator/customizations/stack/testing-config.md +1 -1
  39. package/src/orchestrator/instructions/general.instructions.md +2 -0
  40. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +127 -132
  41. package/src/orchestrator/skills/agent-hooks/SKILL.md +7 -2
@@ -0,0 +1,472 @@
1
+ import { readFile, writeFile, unlink, readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { existsSync } from 'node:fs'
4
+ import type { RepoInfo, StackConfig } from './types.js'
5
+
6
+ export interface BootstrapResult {
7
+ populated: string[]
8
+ removed: string[]
9
+ renamed: string[]
10
+ }
11
+
12
+ // ── Internal types ─────────────────────────────────────────────
13
+
14
+ interface PackageJson {
15
+ name?: string
16
+ description?: string
17
+ scripts?: Record<string, string>
18
+ }
19
+
20
+ interface WorkspacePkg {
21
+ path: string
22
+ name: string
23
+ description?: string
24
+ }
25
+
26
+ // ── Utilities ──────────────────────────────────────────────────
27
+
28
+ async function tryReadJson<T>(p: string): Promise<T | null> {
29
+ try {
30
+ return JSON.parse(await readFile(p, 'utf8')) as T
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Replace the first empty table row within a named markdown section.
38
+ * Scopes the search from `sectionMarker` to the next `## ` heading.
39
+ */
40
+ function fillTableSection(
41
+ content: string,
42
+ sectionMarker: string,
43
+ emptyRow: string,
44
+ rows: string[],
45
+ ): string {
46
+ if (rows.length === 0) return content
47
+ const sIdx = content.indexOf(sectionMarker)
48
+ if (sIdx === -1) return content
49
+ const nextSectionIdx = content.indexOf('\n## ', sIdx + sectionMarker.length)
50
+ const end = nextSectionIdx === -1 ? content.length : nextSectionIdx
51
+ const slice = content.slice(sIdx, end)
52
+ const needle = '\n' + emptyRow
53
+ const needleIdx = slice.indexOf(needle)
54
+ if (needleIdx === -1) return content
55
+ const abs = sIdx + needleIdx + 1 // position of first char of emptyRow (skip \n)
56
+ return content.slice(0, abs) + rows.join('\n') + content.slice(abs + emptyRow.length)
57
+ }
58
+
59
+ function replaceMarker(content: string, marker: string, replacement: string): string {
60
+ if (!content.includes(marker)) return content
61
+ return content.replace(marker, replacement)
62
+ }
63
+
64
+ function filterConfigFiles(configFiles: string[] | undefined, patterns: string[]): string[] {
65
+ if (!configFiles?.length) return []
66
+ return configFiles.filter(f => patterns.some(p => f === p || f.endsWith('/' + p) || f.includes(p)))
67
+ }
68
+
69
+ // ── Workspace scanning ─────────────────────────────────────────
70
+
71
+ async function scanWorkspace(projectRoot: string): Promise<WorkspacePkg[]> {
72
+ const packages: WorkspacePkg[] = []
73
+ for (const dir of ['apps', 'packages', 'libs']) {
74
+ const dirPath = join(projectRoot, dir)
75
+ if (!existsSync(dirPath)) continue
76
+ let entries: Array<{ name: string; isDirectory(): boolean }> = []
77
+ try {
78
+ entries = await readdir(dirPath, { withFileTypes: true })
79
+ } catch {
80
+ continue
81
+ }
82
+ for (const e of entries) {
83
+ if (!e.isDirectory()) continue
84
+ const pkg = await tryReadJson<PackageJson>(join(dirPath, e.name, 'package.json'))
85
+ packages.push({
86
+ path: `${dir}/${e.name}`,
87
+ name: pkg?.name ?? `${dir}/${e.name}`,
88
+ description: pkg?.description,
89
+ })
90
+ }
91
+ }
92
+ return packages
93
+ }
94
+
95
+ // ── Builders ───────────────────────────────────────────────────
96
+
97
+ function buildStackRows(info: RepoInfo): string[] {
98
+ const rows: string[] = []
99
+ const addList = (layer: string, values: string[] | undefined) => {
100
+ if (!values?.length) return
101
+ for (const v of values) rows.push(`| ${layer} | ${v} | <!-- TODO: verify --> | |`)
102
+ }
103
+ if (info.language) rows.push(`| Language | ${info.language} | | |`)
104
+ if (info.packageManager) rows.push(`| Package Manager | ${info.packageManager} | | |`)
105
+ addList('Framework', info.frameworks)
106
+ addList('Database', info.databases)
107
+ addList('CMS', info.cms)
108
+ addList('Deployment', info.deployment)
109
+ addList('Testing', info.testing)
110
+ addList('CI/CD', info.cicd)
111
+ addList('Styling', info.styling)
112
+ addList('Auth', info.auth)
113
+ return rows
114
+ }
115
+
116
+ function buildKeyCommandsBlock(pm: string, scripts: Record<string, string>): string {
117
+ const commands = (['dev', 'build', 'test', 'lint', 'start'] as const)
118
+ .filter(k => scripts[k])
119
+ .map(k => `${pm} run ${k}`)
120
+ if (commands.length === 0) return ''
121
+ return `**Package manager:** \`${pm}\`\n\n\`\`\`bash\n${commands.join('\n')}\n\`\`\``
122
+ }
123
+
124
+ // ── Populators ─────────────────────────────────────────────────
125
+
126
+ async function populateProjectInstructions(
127
+ opencastleDir: string,
128
+ projectRoot: string,
129
+ info: RepoInfo,
130
+ pkg: PackageJson,
131
+ result: BootstrapResult,
132
+ ): Promise<void> {
133
+ const filePath = join(opencastleDir, 'project.instructions.md')
134
+ if (!existsSync(filePath)) return
135
+ let content = await readFile(filePath, 'utf8')
136
+ const orig = content
137
+
138
+ // Overview: project name + description
139
+ if (pkg.name || pkg.description) {
140
+ const parts: string[] = []
141
+ if (pkg.name) parts.push(`**Project:** ${pkg.name}`)
142
+ if (pkg.description) parts.push(`**Description:** ${pkg.description}`)
143
+ content = replaceMarker(
144
+ content,
145
+ '<!-- Project name, description, and current status -->',
146
+ parts.join('\n\n'),
147
+ )
148
+ }
149
+
150
+ // Tech stack table
151
+ content = fillTableSection(content, '## Tech Stack', '| | | | |', buildStackRows(info))
152
+
153
+ // Key commands
154
+ if (pkg.scripts) {
155
+ const pm = info.packageManager ?? 'npm'
156
+ const cmdBlock = buildKeyCommandsBlock(pm, pkg.scripts)
157
+ if (cmdBlock) {
158
+ content = replaceMarker(
159
+ content,
160
+ '<!-- Package manager and common development commands -->',
161
+ '<!-- Package manager and common development commands -->\n\n' + cmdBlock,
162
+ )
163
+ }
164
+ }
165
+
166
+ // Monorepo workspace packages
167
+ if (info.monorepo) {
168
+ const workspaces = await scanWorkspace(projectRoot)
169
+ if (workspaces.length > 0) {
170
+ const pkgRows = workspaces.map(w => {
171
+ const cell =
172
+ w.name !== w.path ? `\`${w.path}\` (\`${w.name}\`)` : `\`${w.path}\``
173
+ return `| ${cell} | ${w.description ?? '<!-- TODO: verify -->'} |`
174
+ })
175
+ content = fillTableSection(content, '## Project Structure', '| | |', pkgRows)
176
+ }
177
+ }
178
+
179
+ if (content !== orig) {
180
+ await writeFile(filePath, content, 'utf8')
181
+ result.populated.push('project.instructions.md')
182
+ }
183
+ }
184
+
185
+ async function populateTestingConfig(
186
+ opencastleDir: string,
187
+ info: RepoInfo,
188
+ result: BootstrapResult,
189
+ ): Promise<void> {
190
+ const filePath = join(opencastleDir, 'stack', 'testing-config.md')
191
+ if (!existsSync(filePath)) return
192
+
193
+ if (!info.testing?.length) {
194
+ await unlink(filePath)
195
+ result.removed.push('stack/testing-config.md')
196
+ return
197
+ }
198
+
199
+ let content = await readFile(filePath, 'utf8')
200
+ const orig = content
201
+
202
+ const introLine = 'Project-specific testing details referenced by the `browser-testing` skill.'
203
+ if (content.includes(introLine)) {
204
+ const cfg = filterConfigFiles(info.configFiles, [
205
+ 'vitest.config.ts',
206
+ 'vitest.config.js',
207
+ 'jest.config.ts',
208
+ 'jest.config.js',
209
+ 'playwright.config.ts',
210
+ 'playwright.config.js',
211
+ ])
212
+ let addition = `\n\n**Test frameworks:** ${info.testing.join(', ')}`
213
+ if (cfg.length > 0) {
214
+ addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
215
+ }
216
+ content = content.replace(introLine, introLine + addition)
217
+ }
218
+
219
+ if (content !== orig) {
220
+ await writeFile(filePath, content, 'utf8')
221
+ result.populated.push('stack/testing-config.md')
222
+ }
223
+ }
224
+
225
+ async function populateDeploymentConfig(
226
+ opencastleDir: string,
227
+ info: RepoInfo,
228
+ result: BootstrapResult,
229
+ ): Promise<void> {
230
+ const filePath = join(opencastleDir, 'stack', 'deployment-config.md')
231
+ if (!existsSync(filePath)) return
232
+
233
+ if (!info.deployment?.length) {
234
+ await unlink(filePath)
235
+ result.removed.push('stack/deployment-config.md')
236
+ return
237
+ }
238
+
239
+ let content = await readFile(filePath, 'utf8')
240
+ const orig = content
241
+
242
+ const archMarker =
243
+ '<!-- Describe the deployment platform, CI/CD pipeline, and trigger mechanism. -->'
244
+ if (content.includes(archMarker)) {
245
+ const cfg = filterConfigFiles(info.configFiles, [
246
+ 'vercel.json',
247
+ 'netlify.toml',
248
+ 'Dockerfile',
249
+ 'docker-compose.yml',
250
+ 'docker-compose.yaml',
251
+ 'fly.toml',
252
+ 'render.yaml',
253
+ ])
254
+ let addition = `**Platform:** ${info.deployment.join(', ')}`
255
+ if (cfg.length > 0) {
256
+ addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
257
+ }
258
+ content = content.replace(archMarker, addition + '\n\n' + archMarker)
259
+ }
260
+
261
+ if (content !== orig) {
262
+ await writeFile(filePath, content, 'utf8')
263
+ result.populated.push('stack/deployment-config.md')
264
+ }
265
+ }
266
+
267
+ async function handleDatabaseConfig(
268
+ opencastleDir: string,
269
+ info: RepoInfo,
270
+ result: BootstrapResult,
271
+ ): Promise<void> {
272
+ const filePath = join(opencastleDir, 'stack', 'database-config.md')
273
+ if (!existsSync(filePath)) return
274
+
275
+ if (!info.databases?.length) {
276
+ await unlink(filePath)
277
+ result.removed.push('stack/database-config.md')
278
+ return
279
+ }
280
+
281
+ let content = await readFile(filePath, 'utf8')
282
+ const provider = info.databases[0]
283
+
284
+ const integrationMarker =
285
+ '<!-- Auth library path, migration directory, session pattern, role system overview. -->'
286
+ if (content.includes(integrationMarker)) {
287
+ const cfg = filterConfigFiles(info.configFiles, [
288
+ 'supabase/config.toml',
289
+ 'prisma/schema.prisma',
290
+ 'drizzle.config.ts',
291
+ 'drizzle.config.js',
292
+ ])
293
+ let addition = `**Provider:** ${provider}`
294
+ if (cfg.length > 0) {
295
+ addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
296
+ }
297
+ addition += '\n\n<!-- TODO: verify -->'
298
+ content = content.replace(integrationMarker, addition + '\n\n' + integrationMarker)
299
+ }
300
+
301
+ if (info.databases.length === 1) {
302
+ const newName = `${provider}-config.md`
303
+ const newPath = join(opencastleDir, 'stack', newName)
304
+ await writeFile(newPath, content, 'utf8')
305
+ await unlink(filePath)
306
+ result.renamed.push(`stack/database-config.md \u2192 stack/${newName}`)
307
+ } else {
308
+ await writeFile(filePath, content, 'utf8')
309
+ result.populated.push('stack/database-config.md')
310
+ }
311
+ }
312
+
313
+ async function handleCmsConfig(
314
+ opencastleDir: string,
315
+ info: RepoInfo,
316
+ result: BootstrapResult,
317
+ ): Promise<void> {
318
+ const filePath = join(opencastleDir, 'stack', 'cms-config.md')
319
+ if (!existsSync(filePath)) return
320
+
321
+ if (!info.cms?.length) {
322
+ await unlink(filePath)
323
+ result.removed.push('stack/cms-config.md')
324
+ return
325
+ }
326
+
327
+ let content = await readFile(filePath, 'utf8')
328
+ const provider = info.cms[0]
329
+
330
+ const configMarker = '<!-- CMS project IDs, dataset, API version, studio location, etc. -->'
331
+ if (content.includes(configMarker)) {
332
+ const cfg = filterConfigFiles(info.configFiles, [
333
+ 'sanity.config.ts',
334
+ 'sanity.config.js',
335
+ '.contentful.json',
336
+ 'payload.config.ts',
337
+ ])
338
+ let addition = `**Provider:** ${provider}`
339
+ if (cfg.length > 0) {
340
+ addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
341
+ }
342
+ addition += '\n\n<!-- TODO: verify -->'
343
+ content = content.replace(configMarker, addition + '\n\n' + configMarker)
344
+ }
345
+
346
+ if (info.cms.length === 1) {
347
+ const newName = `${provider}-config.md`
348
+ const newPath = join(opencastleDir, 'stack', newName)
349
+ await writeFile(newPath, content, 'utf8')
350
+ await unlink(filePath)
351
+ result.renamed.push(`stack/cms-config.md \u2192 stack/${newName}`)
352
+ } else {
353
+ await writeFile(filePath, content, 'utf8')
354
+ result.populated.push('stack/cms-config.md')
355
+ }
356
+ }
357
+
358
+ async function removeNotificationsIfUnused(
359
+ opencastleDir: string,
360
+ info: RepoInfo,
361
+ result: BootstrapResult,
362
+ ): Promise<void> {
363
+ const filePath = join(opencastleDir, 'stack', 'notifications-config.md')
364
+ if (!existsSync(filePath) || info.notifications?.length) return
365
+ await unlink(filePath)
366
+ result.removed.push('stack/notifications-config.md')
367
+ }
368
+
369
+ async function handleApiConfig(
370
+ opencastleDir: string,
371
+ info: RepoInfo,
372
+ result: BootstrapResult,
373
+ ): Promise<void> {
374
+ const filePath = join(opencastleDir, 'stack', 'api-config.md')
375
+ if (!existsSync(filePath)) return
376
+
377
+ if (!info.frameworks?.length) {
378
+ await unlink(filePath)
379
+ result.removed.push('stack/api-config.md')
380
+ return
381
+ }
382
+
383
+ let content = await readFile(filePath, 'utf8')
384
+ const orig = content
385
+
386
+ const initMarker =
387
+ '<!-- Populated by `opencastle init` based on detected API routes and Server Actions. -->'
388
+ if (content.includes(initMarker)) {
389
+ const frameworkList = info.frameworks.join(', ')
390
+ content = content.replace(
391
+ initMarker,
392
+ `<!-- Populated by \`opencastle init\`. Framework: ${frameworkList} -->`,
393
+ )
394
+ }
395
+
396
+ if (content !== orig) {
397
+ await writeFile(filePath, content, 'utf8')
398
+ result.populated.push('stack/api-config.md')
399
+ }
400
+ }
401
+
402
+ async function removeDataPipelineConfig(
403
+ opencastleDir: string,
404
+ result: BootstrapResult,
405
+ ): Promise<void> {
406
+ const filePath = join(opencastleDir, 'stack', 'data-pipeline-config.md')
407
+ if (!existsSync(filePath)) return
408
+ await unlink(filePath)
409
+ result.removed.push('stack/data-pipeline-config.md')
410
+ }
411
+
412
+ const TRACKER_TOOLS = new Set<string>(['linear', 'jira'])
413
+
414
+ async function handleTrackerConfig(
415
+ opencastleDir: string,
416
+ info: RepoInfo,
417
+ stack: StackConfig,
418
+ result: BootstrapResult,
419
+ ): Promise<void> {
420
+ const filePath = join(opencastleDir, 'project', 'tracker-config.md')
421
+ if (!existsSync(filePath)) return
422
+
423
+ const tracker =
424
+ stack.teamTools.find(t => TRACKER_TOOLS.has(t)) ??
425
+ info.pm?.find(p => TRACKER_TOOLS.has(p))
426
+
427
+ if (!tracker) {
428
+ await unlink(filePath)
429
+ result.removed.push('project/tracker-config.md')
430
+ return
431
+ }
432
+
433
+ let content = await readFile(filePath, 'utf8')
434
+ const displayName = tracker.charAt(0).toUpperCase() + tracker.slice(1)
435
+
436
+ content = content.replace('# Task Tracker Configuration', `# ${displayName} Configuration`)
437
+
438
+ const renameComment =
439
+ '<!-- Populated by `opencastle init`.\n Rename this file to match your tracker: linear-config.md, jira-config.md, etc. -->'
440
+ content = content.replace(renameComment + '\n', '')
441
+
442
+ const newName = `${tracker}-config.md`
443
+ const newPath = join(opencastleDir, 'project', newName)
444
+ await writeFile(newPath, content, 'utf8')
445
+ await unlink(filePath)
446
+ result.renamed.push(`project/tracker-config.md \u2192 project/${newName}`)
447
+ }
448
+
449
+ // ── Main export ────────────────────────────────────────────────
450
+
451
+ export async function bootstrapCustomizations(
452
+ projectRoot: string,
453
+ repoInfo: RepoInfo,
454
+ stack: StackConfig,
455
+ ): Promise<BootstrapResult> {
456
+ const opencastleDir = join(projectRoot, '.opencastle')
457
+ const result: BootstrapResult = { populated: [], removed: [], renamed: [] }
458
+
459
+ const pkg = (await tryReadJson<PackageJson>(join(projectRoot, 'package.json'))) ?? {}
460
+
461
+ await populateProjectInstructions(opencastleDir, projectRoot, repoInfo, pkg, result)
462
+ await populateTestingConfig(opencastleDir, repoInfo, result)
463
+ await populateDeploymentConfig(opencastleDir, repoInfo, result)
464
+ await handleDatabaseConfig(opencastleDir, repoInfo, result)
465
+ await handleCmsConfig(opencastleDir, repoInfo, result)
466
+ await removeNotificationsIfUnused(opencastleDir, repoInfo, result)
467
+ await handleApiConfig(opencastleDir, repoInfo, result)
468
+ await removeDataPipelineConfig(opencastleDir, result)
469
+ await handleTrackerConfig(opencastleDir, repoInfo, stack, result)
470
+
471
+ return result
472
+ }
package/src/cli/detect.ts CHANGED
@@ -1,7 +1,28 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { readFile, readdir, access } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
- import type { RepoInfo, StackConfig } from './types.js';
4
+ import type { IdeChoice, RepoInfo, StackConfig } from './types.js';
5
+
6
+ // ── IDE detection ───────────────────────────────────────────────
7
+
8
+ /**
9
+ * Detect which IDE the CLI is running from, based on environment variables.
10
+ * Returns the IdeChoice value or undefined if unknown.
11
+ */
12
+ export function detectCurrentIde(): IdeChoice | undefined {
13
+ const env = process.env;
14
+
15
+ // Cursor sets its own TERM_PROGRAM or CURSOR-specific env vars
16
+ if (env.CURSOR_TRACE_DIR || env.CURSOR_CHANNEL) return 'cursor';
17
+
18
+ // VS Code sets TERM_PROGRAM=vscode in its integrated terminal
19
+ if (env.TERM_PROGRAM === 'vscode') return 'vscode';
20
+
21
+ // Claude Code — check for CLAUDE_* env vars set by the CLI
22
+ if (env.CLAUDE_CODE === '1' || env.CLAUDE_PROJECT_ROOT) return 'claude-code';
23
+
24
+ return undefined;
25
+ }
5
26
 
6
27
  // ── Detection rules ───────────────────────────────────────────
7
28
 
package/src/cli/init.ts CHANGED
@@ -7,15 +7,11 @@ import { removeDirIfExists, copyDir, getOrchestratorRoot } from './copy.js'
7
7
  import { updateGitignore } from './gitignore.js'
8
8
  import { getRequiredMcpEnvVars, getCustomizationsTransform } from './stack-config.js'
9
9
  import { TECH_PLUGINS, TEAM_PLUGINS } from '../orchestrator/plugins/index.js'
10
- import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet } from './detect.js'
10
+ import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet, detectCurrentIde } from './detect.js'
11
11
  import { IDE_ADAPTERS } from './adapters/index.js'
12
12
  import { IDE_LABELS } from './types.js'
13
13
  import type { CliContext, IdeChoice, TechTool, TeamTool, StackConfig } from './types.js'
14
-
15
- /** OSC 8 terminal hyperlink — clickable in VS Code integrated terminal and modern terminals */
16
- function termLink(text: string, url: string): string {
17
- return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
18
- }
14
+ import { bootstrapCustomizations } from './bootstrap.js'
19
15
 
20
16
  export default async function init({ pkgRoot, args }: CliContext): Promise<void> {
21
17
  const projectRoot = process.cwd()
@@ -57,26 +53,31 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
57
53
 
58
54
  // ── IDE (single select) ────────────────────────────────────────
59
55
  console.log(` ${c.bold('── IDEs ──────────────────────────────────────')}`)
56
+ const detectedIde = detectCurrentIde()
60
57
  const selectedIde = await select('Which IDE do you use?', [
61
58
  {
62
59
  label: 'VS Code',
63
60
  hint: 'GitHub Copilot agents, instructions, skills',
64
61
  value: 'vscode',
62
+ ...(detectedIde === 'vscode' && { selected: true }),
65
63
  },
66
64
  {
67
65
  label: 'Cursor',
68
66
  hint: '.cursorrules & .cursor/rules/*.mdc',
69
67
  value: 'cursor',
68
+ ...(detectedIde === 'cursor' && { selected: true }),
70
69
  },
71
70
  {
72
71
  label: 'Claude Code',
73
72
  hint: 'CLAUDE.md & .claude/ commands, skills',
74
73
  value: 'claude-code',
74
+ ...(detectedIde === 'claude-code' && { selected: true }),
75
75
  },
76
76
  {
77
77
  label: 'OpenCode',
78
78
  hint: 'AGENTS.md & opencode.json',
79
79
  value: 'opencode',
80
+ ...(detectedIde === 'opencode' && { selected: true }),
80
81
  },
81
82
  ])
82
83
  const ides = [selectedIde]
@@ -227,6 +228,22 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
227
228
  totalSkipped += sub.skipped.length
228
229
  }
229
230
 
231
+ // ── Project scan ────────────────────────────────────────────────
232
+ console.log(`\n ${c.dim('Configuring project...')}`)
233
+ const bootstrapResult = await bootstrapCustomizations(projectRoot, combinedRepoInfo, stack)
234
+
235
+ if (bootstrapResult.populated.length > 0) {
236
+ console.log(` ${c.green('✓')} Populated ${c.bold(String(bootstrapResult.populated.length))} config files`)
237
+ }
238
+ if (bootstrapResult.renamed.length > 0) {
239
+ for (const r of bootstrapResult.renamed) {
240
+ console.log(` ${c.dim('→')} Renamed ${r}`)
241
+ }
242
+ }
243
+ if (bootstrapResult.removed.length > 0) {
244
+ console.log(` ${c.dim('→')} Removed ${bootstrapResult.removed.length} unused template(s)`)
245
+ }
246
+
230
247
  // ── Write manifest ──────────────────────────────────────────────
231
248
  const manifest = createManifest(pkg.version, ides[0], ides)
232
249
  manifest.managedPaths = allManagedPaths
@@ -317,13 +334,6 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
317
334
  )
318
335
  }
319
336
  step++
320
- const bootstrapLabel = ides.includes('vscode')
321
- ? termLink(c.cyan('"Bootstrap Customizations"'), 'vscode://GitHub.Copilot-Chat/chat?mode=Team%20Lead%20(OpenCastle)&prompt=%2Fbootstrap-customizations')
322
- : c.cyan('"Bootstrap Customizations"')
323
- console.log(
324
- ` ${step}. Run the ${bootstrapLabel} prompt to configure for your project`
325
- )
326
- step++
327
337
  console.log(` ${step}. Commit the .opencastle/ folder to your repository`)
328
338
  console.log()
329
339
 
package/src/cli/prompt.ts CHANGED
@@ -188,7 +188,7 @@ function selectInteractive(
188
188
  options: SelectOption[]
189
189
  ): Promise<string> {
190
190
  return new Promise<string>((resolve) => {
191
- let cursor = 0;
191
+ let cursor = Math.max(0, options.findIndex((o) => o.selected));
192
192
  const maxVisible = Math.max(3, Math.min(options.length, (process.stdout.rows || 24) - 4));
193
193
  let lastRenderedLines = 0;
194
194
 
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "444a3464",
2
+ "hash": "939c6a62",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "b44c3569",
5
- "browserHash": "30897244",
4
+ "lockfileHash": "2a42269f",
5
+ "browserHash": "10c140c1",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "cee74f3e",
10
+ "fileHash": "9fc34a31",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "9af6f54a",
16
+ "fileHash": "e0152e5e",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "5fe9f18b",
22
+ "fileHash": "c3b40a68",
23
23
  "needsInterop": true
24
24
  }
25
25
  },
@@ -152,7 +152,7 @@ The convoy engine is the **mandatory** execution mechanism for all project-relat
152
152
  | Work type | Approach |
153
153
  |-----------|----------|
154
154
  | Features, bug fixes, refactors (any subtask count) | **Convoy execution** — always generate a `.convoy.yml` spec, even for 1-task fixes |
155
- | Utility prompts (`bootstrap-customizations`, `create-skill`, `generate-convoy`, `brainstorm`, `quick-refinement`) | **Direct** — these are meta/tooling operations, not project code changes |
155
+ | Utility prompts (`create-skill`, `generate-convoy`, `brainstorm`, `quick-refinement`) | **Direct** — these are meta/tooling operations, not project code changes |
156
156
 
157
157
  ### How to generate a convoy spec
158
158
 
@@ -247,8 +247,10 @@ For each task:
247
247
  - High-stakes: panel review (load panel-majority-vote skill)
248
248
  - Discovered issues tracked (not silently ignored)
249
249
  - Lessons captured (if agent retried anything)
250
+ - Agent expertise updated (AGENT-EXPERTISE.md)
251
+ - Knowledge graph appended (KNOWLEDGE-GRAPH.md)
250
252
  6. PASS → log review (⛔ hard gate — do NOT proceed until logged), move issue → Done
251
- FAIL → re-delegate with failure details (max 3 attempts)
253
+ FAIL → re-delegate with failure details (max 3 attempts → log DLQ in AGENT-FAILURES.md)
252
254
  ```
253
255
 
254
256
  Fast review auto-PASS: research-only tasks, docs-only, or ≤10 lines across ≤2 files with all deterministic gates passing.
@@ -32,7 +32,7 @@ Skills reference these files with relative links like `../../.opencastle/stack/a
32
32
 
33
33
  | File | Purpose |
34
34
  |------|---------|
35
- | _(created by `bootstrap-customizations` prompt based on detected technologies)_ | |
35
+ | _(created by `opencastle init` based on detected technologies)_ | |
36
36
 
37
37
  ### `project/` — Project management config
38
38
 
@@ -41,7 +41,7 @@ Skills reference these files with relative links like `../../.opencastle/stack/a
41
41
  | `docs-structure.md` | Project documentation directory tree and practices |
42
42
  | `roadmap.md` | Project roadmap with planned features and their status |
43
43
  | `decisions.md` | Architecture Decision Records (ADRs) for the project |
44
- | _(task tracker config created by `bootstrap-customizations` prompt)_ | |
44
+ | _(task tracker config created by `opencastle init`)_ | |
45
45
 
46
46
  ### `logs/` — Append-only NDJSON session logs
47
47
 
@@ -58,4 +58,4 @@ Update these files when the project changes — new tables, new API routes, new
58
58
 
59
59
  ## Bootstrap
60
60
 
61
- Run the `bootstrap-customizations` prompt to auto-discover the project's structure and populate these files. It will scan for frameworks, databases, CMS, deployment config, and task tracking, then generate the appropriate `stack/` and `project/` files.
61
+ Run `npx opencastle init` to auto-discover the project's structure and populate these files. It scans for frameworks, databases, CMS, deployment config, and task tracking, then generates the appropriate `stack/` and `project/` files.
@@ -3,7 +3,7 @@
3
3
 
4
4
  Project-specific agent-to-model assignments and scope examples referenced by the `team-lead-reference` skill.
5
5
 
6
- <!-- Populated by the `bootstrap-customizations` prompt based on project structure. -->
6
+ <!-- Populated by `opencastle init` based on project structure. -->
7
7
 
8
8
  ## Specialist Agent Registry
9
9